From 3e41d49bc4be3b811428b534a279122cddefff14 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 11:49:33 +0700 Subject: [PATCH 01/25] feat(onboard): persist messaging plan in session for resume Stores the compiled SandboxMessagingPlan in the onboard session so that resume runs can restore the plan to env without re-running enrollment hooks (token paste, QR pairing). Fixes the gap where the registry entry lost its `messaging` field on rebuild because the plan was only held in a process env var that didn't survive across process restarts. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/onboard.ts | 4 ++- .../onboard/machine/handlers/sandbox.test.ts | 2 ++ src/lib/onboard/machine/handlers/sandbox.ts | 14 ++++++++ src/lib/onboard/session-updates.ts | 3 ++ src/lib/state/onboard-session.ts | 32 +++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7c8cffb831..98abbcc278 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -92,7 +92,7 @@ const { const { setupMessagingChannels: setupMessagingChannelsImpl, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); +const { MessagingHostStateApplier, MessagingSetupApplier }: typeof import("./messaging") = require("./messaging"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -6499,6 +6499,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, setupMessagingChannels, readMessagingChannelConfigFromEnv, + readMessagingPlanFromEnv: () => MessagingSetupApplier.readPlanFromEnv(), + writePlanToEnv: (plan) => MessagingSetupApplier.writePlanToEnv(plan), promptValidatedSandboxName, selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), stopStaleDashboardListenersForSandbox, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 51dbaf2352..10d1ec9233 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -72,6 +72,8 @@ function createDeps(overrides: Partial ["telegram"], setupMessagingChannels: calls.setupMessaging, readMessagingChannelConfigFromEnv: () => null, + readMessagingPlanFromEnv: () => null, + writePlanToEnv: () => undefined, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, stopStaleDashboardListenersForSandbox: calls.stopStale, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 26a26c08c8..5769c927fe 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; import { withSandboxPhaseTrace } from "../../tracing"; import { branchTo, type OnboardStateTransitionResult } from "../result"; @@ -62,6 +63,8 @@ export interface SandboxStateOptions; readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; + readMessagingPlanFromEnv(): SandboxMessagingPlan | null; + writePlanToEnv(plan: SandboxMessagingPlan): void; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; @@ -263,21 +266,32 @@ export async function handleSandboxState 0) { deps.note(` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`); + // Restore the compiled plan to env so createSandbox can read it via + // MessagingHostStateApplier.readPlanStateFromEnv() and write it to the + // registry. Without this, the plan is lost across process restarts and + // the registry entry loses its messaging state on rebuild. + if (session?.messagingPlan) { + deps.writePlanToEnv(session.messagingPlan); + messagingPlan = session.messagingPlan; + } } } else { const existing = sandboxName ? deps.getSandboxMessagingChannels(sandboxName) ?? session?.messagingChannels ?? null : session?.messagingChannels ?? null; selectedMessagingChannels = await deps.setupMessagingChannels(agent, existing, sandboxName); + messagingPlan = deps.readMessagingPlanFromEnv(); } const messagingChannelConfig = deps.readMessagingChannelConfigFromEnv(); session = deps.updateSession((current) => { current.messagingChannels = selectedMessagingChannels; current.messagingChannelConfig = messagingChannelConfig as Session["messagingChannelConfig"]; + current.messagingPlan = messagingPlan; return current; }); diff --git a/src/lib/onboard/session-updates.ts b/src/lib/onboard/session-updates.ts index 529d22e531..558bc3b62d 100644 --- a/src/lib/onboard/session-updates.ts +++ b/src/lib/onboard/session-updates.ts @@ -3,6 +3,7 @@ import type { WebSearchConfig } from "../inference/web-search"; import type { MessagingChannelConfig } from "../messaging-channel-config"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { HermesAuthMethod, SessionUpdates } from "../state/onboard-session"; export interface OnboardSessionUpdateInput { @@ -18,6 +19,7 @@ export interface OnboardSessionUpdateInput { policyPresets?: string[] | null; messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; + messagingPlan?: SandboxMessagingPlan | null; hermesToolGateways?: string[] | null; } @@ -57,6 +59,7 @@ export function toSessionUpdates(updates: OnboardSessionUpdateInput = {}): Sessi if (updates.messagingChannelConfig !== undefined) { normalized.messagingChannelConfig = updates.messagingChannelConfig; } + if (updates.messagingPlan !== undefined) normalized.messagingPlan = updates.messagingPlan; if (updates.hermesToolGateways !== undefined) normalized.hermesToolGateways = updates.hermesToolGateways; return normalized; diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 7de7b276dd..729bc33d66 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -18,6 +18,7 @@ import { type MessagingChannelConfig, sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import { createOnboardMachineEvent, emitOnboardMachineEvent, @@ -104,6 +105,7 @@ export interface Session { policyPresets: string[] | null; messagingChannels: string[] | null; messagingChannelConfig: MessagingChannelConfig | null; + messagingPlan: SandboxMessagingPlan | null; // Channels the operator paused via `nemoclaw channels stop `. // Mirrors `SandboxEntry.disabledChannels` so that `rebuild` — which // destroys the registry entry before calling `onboard --resume` — @@ -182,6 +184,7 @@ export interface SessionUpdates { policyPresets?: string[] | null; messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; + messagingPlan?: SandboxMessagingPlan | null; disabledChannels?: string[] | null; migratedLegacyValueHashes?: Record; gpuPassthrough?: boolean; @@ -307,6 +310,27 @@ function parseWechatConfig(value: unknown): WechatConfig | null { return Object.keys(result).length > 0 ? result : null; } +function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan | null { + if ( + !isObject(value) || + value.schemaVersion !== 1 || + typeof value.sandboxName !== "string" || + typeof value.agent !== "string" || + typeof value.workflow !== "string" || + !Array.isArray(value.channels) || + !Array.isArray(value.disabledChannels) || + !Array.isArray(value.credentialBindings) || + !isObject(value.networkPolicy) || + !Array.isArray(value.agentRender) || + !Array.isArray(value.buildSteps) || + !Array.isArray(value.stateUpdates) || + !Array.isArray(value.healthChecks) + ) { + return null; + } + return value as unknown as SandboxMessagingPlan; +} + function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { if (!isObject(value)) return undefined; return { @@ -459,6 +483,7 @@ export function createSession(overrides: Partial = {}): Session { policyPresets: readStringArray(overrides.policyPresets), messagingChannels: readStringArray(overrides.messagingChannels), messagingChannelConfig: sanitizeMessagingChannelConfig(overrides.messagingChannelConfig), + messagingPlan: parseSandboxMessagingPlan(overrides.messagingPlan), disabledChannels: readStringArray(overrides.disabledChannels), migratedLegacyValueHashes: overrides.migratedLegacyValueHashes ? readStringRecord(overrides.migratedLegacyValueHashes) @@ -501,6 +526,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): policyPresets: readStringArray(data.policyPresets), messagingChannels: readStringArray(data.messagingChannels), messagingChannelConfig: sanitizeMessagingChannelConfig(data.messagingChannelConfig), + messagingPlan: parseSandboxMessagingPlan(data.messagingPlan), disabledChannels: readStringArray(data.disabledChannels), migratedLegacyValueHashes: readStringRecord(data.migratedLegacyValueHashes), gpuPassthrough: data.gpuPassthrough === true, @@ -949,6 +975,12 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { const messagingChannelConfig = sanitizeMessagingChannelConfig(updates.messagingChannelConfig); if (messagingChannelConfig) safe.messagingChannelConfig = messagingChannelConfig; } + if (updates.messagingPlan === null) { + safe.messagingPlan = null; + } else { + const messagingPlan = parseSandboxMessagingPlan(updates.messagingPlan); + if (messagingPlan) safe.messagingPlan = messagingPlan; + } if (updates.disabledChannels === null) { safe.disabledChannels = null; } else if (Array.isArray(updates.disabledChannels)) { From 461c3997e3011309d6fdb90414d4449358630991 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 12:06:53 +0700 Subject: [PATCH 02/25] refactor(onboard): move messaging plan env helpers to messaging-channel-setup Exports readMessagingPlanFromEnv and writePlanToEnv from messaging-channel-setup.ts (which already owns MessagingSetupApplier) to keep src/lib/onboard.ts from growing. Collapses the one-name messaging-channel-setup require into a single line to free headroom. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/onboard.ts | 10 ++++------ src/lib/onboard/messaging-channel-setup.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 98abbcc278..937a74986c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -89,10 +89,8 @@ const { hasWechatConfigDrift, toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); -const { - setupMessagingChannels: setupMessagingChannelsImpl, -} = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const { MessagingHostStateApplier, MessagingSetupApplier }: typeof import("./messaging") = require("./messaging"); +const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); +const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -6499,8 +6497,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, setupMessagingChannels, readMessagingChannelConfigFromEnv, - readMessagingPlanFromEnv: () => MessagingSetupApplier.readPlanFromEnv(), - writePlanToEnv: (plan) => MessagingSetupApplier.writePlanToEnv(plan), + readMessagingPlanFromEnv, + writePlanToEnv, promptValidatedSandboxName, selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), stopStaleDashboardListenersForSandbox, diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index c0a3928c7e..0593915bcc 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -317,6 +317,14 @@ function printInSandboxQrStatus(manifest: ChannelManifest): void { } } +export function readMessagingPlanFromEnv(): SandboxMessagingPlan | null { + return MessagingSetupApplier.readPlanFromEnv(); +} + +export function writePlanToEnv(plan: SandboxMessagingPlan): void { + MessagingSetupApplier.writePlanToEnv(plan); +} + function resolveMessagingSetupSandboxName(options: SetupSelectedMessagingChannelsOptions): string { const explicitName = normalizeSandboxName(options.sandboxName); if (explicitName) return explicitName; From 9a4ed8edfbfdc6cfb1a9ce7eed1b730b24c79aee Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 13:06:57 +0700 Subject: [PATCH 03/25] fix(onboard): add plan identity guard and test coverage for resume plan persistence - Extract parseSandboxMessagingPlan to messaging-plan-session.ts to keep onboard-session.ts growth under the monolith threshold - Guard plan restoration with sandbox-name + agent identity check so stale plans from renamed sandboxes or agent switches are not reused - Add three behavior assertions in sandbox.test.ts: fresh setup persists env plan to session; matching plan is restored to env on non-interactive resume; mismatched sandbox name skips restoration Co-Authored-By: Claude Sonnet 4.6 --- .../onboard/machine/handlers/sandbox.test.ts | 53 +++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 15 ++++-- src/lib/onboard/messaging-plan-session.ts | 29 ++++++++++ src/lib/state/onboard-session.ts | 22 +------- 4 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 src/lib/onboard/messaging-plan-session.ts diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 10d1ec9233..ddfef1487a 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -3,9 +3,27 @@ import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import { handleSandboxState, type SandboxStateOptions } from "./sandbox"; +function makeMinimalPlan(sandboxName: string, agent = "openclaw"): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent: agent as SandboxMessagingPlan["agent"], + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + type Gpu = { type: string } | null; type Agent = { displayName?: string } | null; type WebSearchConfig = { fetchEnabled: true }; @@ -318,4 +336,39 @@ describe("handleSandboxState", () => { expect(calls.note).toHaveBeenCalledWith(" [non-interactive] Reusing messaging channel configuration: discord"); expect(result.selectedMessagingChannels).toEqual(["discord"]); }); + + it("persists plan from env into session after fresh messaging setup", async () => { + const mockPlan = makeMinimalPlan("my-assistant"); + const { deps, getSession } = createDeps({ + readMessagingPlanFromEnv: () => mockPlan, + }); + + await handleSandboxState({ ...baseOptions(deps) }); + + expect(getSession().messagingPlan).toEqual(mockPlan); + }); + + it("restores matching plan to env on non-interactive resume", async () => { + const mockPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: mockPlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); + + expect(writePlanToEnv).toHaveBeenCalledWith(mockPlan); + }); + + it("does not restore plan to env when sandbox name does not match", async () => { + const stalePlan = makeMinimalPlan("old-sandbox"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: stalePlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 5769c927fe..67ce452cc3 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -275,9 +275,18 @@ export async function handleSandboxState { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 729bc33d66..ce6601cb01 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -19,6 +19,7 @@ import { sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { parseSandboxMessagingPlan } from "../onboard/messaging-plan-session"; import { createOnboardMachineEvent, emitOnboardMachineEvent, @@ -310,27 +311,6 @@ function parseWechatConfig(value: unknown): WechatConfig | null { return Object.keys(result).length > 0 ? result : null; } -function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan | null { - if ( - !isObject(value) || - value.schemaVersion !== 1 || - typeof value.sandboxName !== "string" || - typeof value.agent !== "string" || - typeof value.workflow !== "string" || - !Array.isArray(value.channels) || - !Array.isArray(value.disabledChannels) || - !Array.isArray(value.credentialBindings) || - !isObject(value.networkPolicy) || - !Array.isArray(value.agentRender) || - !Array.isArray(value.buildSteps) || - !Array.isArray(value.stateUpdates) || - !Array.isArray(value.healthChecks) - ) { - return null; - } - return value as unknown as SandboxMessagingPlan; -} - function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { if (!isObject(value)) return undefined; return { From 2d05201e706c46f1e4933fd13d82b653b18e36b6 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 13:59:58 +0700 Subject: [PATCH 04/25] fix(onboard): prefer env-staged plan over session plan on non-interactive resume rebuild.ts stages an authoritative plan from the registry (which reflects post-stop/-start channel mutations) before calling onboard --resume. Previously, the session plan restoration was unconditionally overwriting that staged plan, causing stopped channels to reappear as active after rebuild. Now the handler checks the env first: if a plan is already staged (rebuild path), it is used as-is. The session plan is only restored when the env is empty, covering the plain process-restart resume case this PR was originally targeting. Also adds a test asserting the rebuild-path preference. Co-Authored-By: Claude Sonnet 4.6 --- .../onboard/machine/handlers/sandbox.test.ts | 32 ++++++++++++++-- src/lib/onboard/machine/handlers/sandbox.ts | 37 +++++++++++-------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index ddfef1487a..222ae5edcd 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -348,24 +348,50 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(mockPlan); }); - it("restores matching plan to env on non-interactive resume", async () => { + it("restores matching session plan to env on non-interactive resume when env is empty", async () => { const mockPlan = makeMinimalPlan("my-assistant"); const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: mockPlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); - const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + const { deps } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); expect(writePlanToEnv).toHaveBeenCalledWith(mockPlan); }); + it("prefers env-staged plan over session plan on non-interactive resume (rebuild path)", async () => { + const sessionPlan = makeMinimalPlan("my-assistant"); + const rebuiltPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: sessionPlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => rebuiltPlan, + }); + + await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(rebuiltPlan); + }); + it("does not restore plan to env when sandbox name does not match", async () => { const stalePlan = makeMinimalPlan("old-sandbox"); const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: stalePlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); - const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv }); + const { deps } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 67ce452cc3..e6350636dd 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -271,22 +271,27 @@ export async function handleSandboxState 0) { deps.note(` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`); - // Restore the compiled plan to env so createSandbox can read it via - // MessagingHostStateApplier.readPlanStateFromEnv() and write it to the - // registry. Without this, the plan is lost across process restarts and - // the registry entry loses its messaging state on rebuild. - // Guard: only restore if the plan's identity matches the current sandbox - // and agent. An agent change or renamed sandbox means the persisted plan - // is stale and must be recompiled through the normal setup path. - const storedPlan = session?.messagingPlan; - const agentName = (agent as { name?: string } | null)?.name; - const planMatchesCurrent = - storedPlan != null && - storedPlan.sandboxName === sandboxName && - (agentName == null || storedPlan.agent === agentName); - if (planMatchesCurrent && storedPlan != null) { - deps.writePlanToEnv(storedPlan); - messagingPlan = storedPlan; + // Prefer a plan already in env over the session plan. rebuild.ts stages + // a fresh plan from the registry entry before calling onboard --resume, + // and that plan reflects post-stop/-start channel mutations. Overwriting + // it with the session plan (saved at initial onboard) would lose the + // disabled state and reactivate stopped channels after rebuild. + // Only restore the session plan when the env is empty, i.e. for plain + // process-restart resumes where no external caller staged a plan. + const envPlan = deps.readMessagingPlanFromEnv(); + if (envPlan) { + messagingPlan = envPlan; + } else { + const storedPlan = session?.messagingPlan; + const agentName = (agent as { name?: string } | null)?.name; + const planMatchesCurrent = + storedPlan != null && + storedPlan.sandboxName === sandboxName && + (agentName == null || storedPlan.agent === agentName); + if (planMatchesCurrent && storedPlan != null) { + deps.writePlanToEnv(storedPlan); + messagingPlan = storedPlan; + } } } } else { From 673d441107604709e5963cfb7214e5b24bf9f1e6 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 16:04:39 +0700 Subject: [PATCH 05/25] refactor(onboard): establish messagingPlan as session source of truth - On non-interactive resume, restore plan from registry (always current after stop/start/add/remove) instead of stale session snapshot; env-first priority preserved so rebuild.ts staging still wins - In rebuild.ts, persist the staged plan to the session alongside messagingChannels/disabledChannels/messagingChannelConfig so the session is fully consistent during the rebuild window - Add getChannelsFromPlan, getDisabledChannelsFromPlan, and getMessagingChannelConfigFromPlan helpers in messaging-plan-session.ts so the next PR can replace the three individual session fields with plan-derived reads - Move MessagingHostStateApplier re-export to messaging-channel-setup and getRegistrySandboxMessagingPlan helper to keep onboard.ts net-neutral Co-Authored-By: Claude Sonnet 4.6 --- src/lib/actions/sandbox/rebuild.ts | 10 ++++-- src/lib/onboard.ts | 4 +-- .../onboard/machine/handlers/sandbox.test.ts | 23 ++++++------ src/lib/onboard/machine/handlers/sandbox.ts | 17 +++++---- src/lib/onboard/messaging-channel-setup.ts | 7 ++++ src/lib/onboard/messaging-plan-session.ts | 35 +++++++++++++++++++ 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index a6dfbe5dfd..63f06a0219 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -49,6 +49,7 @@ import { MessagingWorkflowPlanner, toMessagingAgentId, } from "../../messaging"; +import type { SandboxMessagingPlan } from "../../messaging/manifest"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -176,7 +177,7 @@ async function stageMessagingManifestPlanForRebuild( sandboxEntry: registry.SandboxEntry, rebuildAgent: string | null, log: (msg: string) => void, -): Promise { +): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); const plan = await planner.buildRebuildPlanFromSandboxEntry({ @@ -188,7 +189,7 @@ async function stageMessagingManifestPlanForRebuild( if (!plan || plan.channels.length === 0) { MessagingSetupApplier.clearPlanEnv(); log("Messaging manifest rebuild plan: no configured channels"); - return; + return null; } MessagingSetupApplier.writePlanToEnv(plan); log( @@ -196,6 +197,7 @@ async function stageMessagingManifestPlanForRebuild( .map((channel) => channel.channelId) .join(",")}`, ); + return plan; } /** @@ -439,8 +441,9 @@ export async function rebuildSandbox( ); } + let rebuildMessagingPlan: SandboxMessagingPlan | null = null; try { - await stageMessagingManifestPlanForRebuild(sandboxName, sb, rebuildAgent, log); + rebuildMessagingPlan = await stageMessagingManifestPlanForRebuild(sandboxName, sb, rebuildAgent, log); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(""); @@ -681,6 +684,7 @@ export async function rebuildSandbox( s.messagingChannels = rebuildMessagingChannels; s.messagingChannelConfig = rebuildMessagingChannelConfig; s.disabledChannels = rebuildDisabledChannels; + s.messagingPlan = rebuildMessagingPlan; s.hermesToolGateways = rebuildsHermesSandbox ? rebuildHermesToolGateways : []; // Persist inference selection from the about-to-be-removed registry entry // so onboard --resume can recreate with the same provider/model in diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 937a74986c..ebb54bd7f8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -89,8 +89,7 @@ const { hasWechatConfigDrift, toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); -const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); -const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); +const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv, getRegistrySandboxMessagingPlan, MessagingHostStateApplier } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -6499,6 +6498,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { readMessagingChannelConfigFromEnv, readMessagingPlanFromEnv, writePlanToEnv, + getRegistrySandboxMessagingPlan, promptValidatedSandboxName, selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), stopStaleDashboardListenersForSandbox, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 222ae5edcd..b52876f9e8 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -92,6 +92,7 @@ function createDeps(overrides: Partial null, readMessagingPlanFromEnv: () => null, writePlanToEnv: () => undefined, + getRegistrySandboxMessagingPlan: () => null, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, stopStaleDashboardListenersForSandbox: calls.stopStale, @@ -348,32 +349,34 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(mockPlan); }); - it("restores matching session plan to env on non-interactive resume when env is empty", async () => { - const mockPlan = makeMinimalPlan("my-assistant"); - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: mockPlan }); + it("restores registry plan to env on non-interactive resume when env is empty", async () => { + const registryPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => registryPlan, }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); - expect(writePlanToEnv).toHaveBeenCalledWith(mockPlan); + expect(writePlanToEnv).toHaveBeenCalledWith(registryPlan); }); - it("prefers env-staged plan over session plan on non-interactive resume (rebuild path)", async () => { - const sessionPlan = makeMinimalPlan("my-assistant"); + it("prefers env-staged plan over registry plan on non-interactive resume (rebuild path)", async () => { + const registryPlan = makeMinimalPlan("my-assistant"); const rebuiltPlan = makeMinimalPlan("my-assistant"); - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: sessionPlan }); + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps, getSession } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, readMessagingPlanFromEnv: () => rebuiltPlan, + getRegistrySandboxMessagingPlan: () => registryPlan, }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); @@ -382,15 +385,15 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(rebuiltPlan); }); - it("does not restore plan to env when sandbox name does not match", async () => { - const stalePlan = makeMinimalPlan("old-sandbox"); - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"], messagingPlan: stalePlan }); + it("does not restore plan to env when registry has no entry", async () => { + const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => null, }); await handleSandboxState({ ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant" }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index e6350636dd..07d6208d7b 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -65,6 +65,7 @@ export interface SandboxStateOptions; selectResourceProfileForSandbox(): Promise; stopStaleDashboardListenersForSandbox(sandboxes: unknown[], sandboxName: string): void; @@ -282,15 +283,13 @@ export async function handleSandboxState { return typeof value === "object" && value !== null && !Array.isArray(value); } + +/** Derive the equivalent of session.messagingChannels from a plan. */ +export function getChannelsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] | null { + if (!plan || plan.channels.length === 0) return null; + return plan.channels.map((c) => c.channelId); +} + +/** Derive the equivalent of session.disabledChannels from a plan. */ +export function getDisabledChannelsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] | null { + if (!plan) return null; + return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; +} + +/** + * Derive the equivalent of session.messagingChannelConfig from a plan. + * Config inputs (kind === "config") carry their resolved env-key/value pairs + * in plan.channels[].inputs, populated at compile time from process.env. + */ +export function getMessagingChannelConfigFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): MessagingChannelConfig | null { + if (!plan) return null; + const config: Record = {}; + for (const channel of plan.channels) { + for (const input of channel.inputs) { + if (input.kind === "config" && input.sourceEnv && input.value != null) { + config[input.sourceEnv] = String(input.value); + } + } + } + return Object.keys(config).length > 0 ? config : null; +} From 69131996978cfb0ea5cd89d88793678c98da6b5c Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 15:25:41 +0530 Subject: [PATCH 06/25] refactor(messaging): migrate conflict detection to manifest-plan architecture (phase 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/lib/messaging/applier/conflict-detection.ts with all core logic: ConflictRegistryEntry minimal interface, createMessagingConflictProbe factory (consolidates the duplicated tri-state probe pattern from onboard.ts, policy-channel.ts, and status-command-deps.ts), plan-to-request helpers (getActiveChannelIdsFromPlan, getCredentialHashesFromPlan, planToConflictChannelRequests), plan-preferred/legacy-fallback entry resolution (resolveActiveChannelsFromEntry, resolveCredentialHashesFromEntry), and pure detection functions (findConflictsInEntries, detectAllOverlapsInEntries, backfillLegacyEntryChannels) - Rewrite messaging-conflict.ts as a thin public adapter: no detection logic inside, all three public exports delegate to applier functions, re-exports createMessagingConflictProbe so callers don't need to import from applier directly; removes getChannelDef/getChannelTokenKeys import (stored hashes are self-describing — no channel-constant lookup needed) - Update onboard.ts conflict-check block: remove makeConflictProbe() and the MESSAGING_CHANNELS/hashCredential block; use createMessagingConflictProbe with injected openshell deps and findChannelConflictsFromPlan from plan - Add plan-path tests: planToConflictChannelRequests grouping and exclusions, findChannelConflictsFromPlan matching/disabled/no-hash cases, plan-only registry entries in findChannelConflicts and findAllOverlaps Closes #4392 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/messaging-conflict.test.ts | 269 ++++++++++++- src/lib/messaging-conflict.ts | 211 +++-------- .../messaging/applier/conflict-detection.ts | 357 ++++++++++++++++++ src/lib/messaging/applier/index.ts | 1 + src/lib/onboard.ts | 65 +--- 5 files changed, 679 insertions(+), 224 deletions(-) create mode 100644 src/lib/messaging/applier/conflict-detection.ts diff --git a/src/lib/messaging-conflict.test.ts b/src/lib/messaging-conflict.test.ts index c366c7bf5a..570c033001 100644 --- a/src/lib/messaging-conflict.test.ts +++ b/src/lib/messaging-conflict.test.ts @@ -3,15 +3,17 @@ import { describe, expect, it, vi } from "vitest"; -import type { SandboxEntry } from "./state/registry"; +import type { SandboxEntry, SandboxMessagingState } from "./state/registry"; +import type { SandboxMessagingPlan } from "./messaging/manifest"; import { backfillMessagingChannels, findAllOverlaps, findChannelConflicts, + findChannelConflictsFromPlan, } from "./messaging-conflict"; +import { planToConflictChannelRequests, type MessagingConflictProbe } from "./messaging/applier"; -type ConflictProbe = Parameters[1]; -type ProviderExists = ConflictProbe["providerExists"]; +type ProviderExists = MessagingConflictProbe["providerExists"]; function makeRegistry(sandboxes: SandboxEntry[]) { const store = new Map(sandboxes.map((s) => [s.name, { ...s }])); @@ -201,7 +203,7 @@ describe("findAllOverlaps", () => { describe("backfillMessagingChannels", () => { it("fills in missing messagingChannels by probing OpenShell", () => { const registry = makeRegistry([{ name: "alice" }]); - const probe: ConflictProbe = { + const probe: MessagingConflictProbe = { providerExists: vi.fn((name) => name === "alice-telegram-bridge" ? "present" : "absent", ), @@ -221,7 +223,7 @@ describe("backfillMessagingChannels", () => { // in PROVIDER_SUFFIXES; if wechat were ever dropped from that map, this // test starts catching the absent provider. const registry = makeRegistry([{ name: "alice" }]); - const probe: ConflictProbe = { + const probe: MessagingConflictProbe = { providerExists: vi.fn((name) => name === "alice-wechat-bridge" ? "present" : "absent", ), @@ -244,7 +246,7 @@ describe("backfillMessagingChannels", () => { it("leaves entries with existing messagingChannels alone", () => { const registry = makeRegistry([{ name: "alice", messagingChannels: ["telegram"] }]); - const probe: ConflictProbe = { + const probe: MessagingConflictProbe = { providerExists: vi.fn(() => "present"), }; backfillMessagingChannels(registry, probe); @@ -254,7 +256,7 @@ describe("backfillMessagingChannels", () => { it("writes an empty array when all probes return absent", () => { const registry = makeRegistry([{ name: "alice" }]); - const probe: ConflictProbe = { + const probe: MessagingConflictProbe = { providerExists: vi.fn(() => "absent"), }; backfillMessagingChannels(registry, probe); @@ -266,7 +268,7 @@ describe("backfillMessagingChannels", () => { // be collapsed into "provider not attached" and persisted, because that // would prevent all future backfill retries and hide real overlaps. const registry = makeRegistry([{ name: "alice" }]); - const probe: ConflictProbe = { + const probe: MessagingConflictProbe = { providerExists: vi.fn((name) => { if (name.endsWith("-telegram-bridge")) return "error"; return name.endsWith("-discord-bridge") ? "present" : "absent"; @@ -278,7 +280,7 @@ describe("backfillMessagingChannels", () => { it("also treats a thrown probe as error (defensive; callers should return 'error' instead)", () => { const registry = makeRegistry([{ name: "alice" }]); - const probe: ConflictProbe = { + const probe: MessagingConflictProbe = { providerExists: vi.fn(() => { throw new Error("unexpected"); }), @@ -290,7 +292,7 @@ describe("backfillMessagingChannels", () => { it("re-attempts backfill on a subsequent call after a prior error", () => { const registry = makeRegistry([{ name: "alice" }]); let firstPass = true; - const probe: ConflictProbe = { + const probe: MessagingConflictProbe = { providerExists: vi.fn((name) => { if (name.endsWith("-telegram-bridge") && firstPass) { firstPass = false; @@ -307,3 +309,250 @@ describe("backfillMessagingChannels", () => { }); }); }); + +// --------------------------------------------------------------------------- +// Helpers for plan-driven tests +// --------------------------------------------------------------------------- + +function makeMinimalPlan( + sandboxName: string, + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +function makePlanEntry(sandboxName: string, plan: SandboxMessagingPlan): SandboxEntry { + const state: SandboxMessagingState = { schemaVersion: 1, plan }; + return { name: sandboxName, messaging: state }; +} + +// --------------------------------------------------------------------------- +// planToConflictChannelRequests +// --------------------------------------------------------------------------- + +describe("planToConflictChannelRequests", () => { + it("returns one request per active channel with its credential hash", () => { + const plan = makeMinimalPlan("alice", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-tg" }, + ], + }); + expect(planToConflictChannelRequests(plan)).toEqual([ + { channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg" } }, + ]); + }); + + it("groups multiple bindings for the same channel (e.g. Slack bot + app tokens)", () => { + const plan = makeMinimalPlan("alice", { + channels: [{ channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "slack", credentialId: "slackBotToken", sourceInput: "botToken", providerName: "alice-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-bot" }, + { channelId: "slack", credentialId: "slackAppToken", sourceInput: "appToken", providerName: "alice-slack-bridge", providerEnvKey: "SLACK_APP_TOKEN", placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", credentialAvailable: true, credentialHash: "hash-app" }, + ], + }); + expect(planToConflictChannelRequests(plan)).toEqual([ + { channel: "slack", credentialHashes: { SLACK_BOT_TOKEN: "hash-bot", SLACK_APP_TOKEN: "hash-app" } }, + ]); + }); + + it("skips bindings without a credentialHash (credential not supplied)", () => { + const plan = makeMinimalPlan("alice", { + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: false }, + ], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); + + it("skips channels listed in disabledChannels (bridge is paused, not in use)", () => { + const plan = makeMinimalPlan("alice", { + disabledChannels: ["telegram"], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-tg" }, + ], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// findChannelConflictsFromPlan +// --------------------------------------------------------------------------- + +describe("findChannelConflictsFromPlan", () => { + it("detects a matching-token conflict against a plan-backed registry entry", () => { + const alicePlan = makeMinimalPlan("alice", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const bobPlan = makeMinimalPlan("bob", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); + expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([ + { channel: "telegram", sandbox: "alice", reason: "matching-token" }, + ]); + }); + + it("returns no conflict when credential hashes differ", () => { + const alicePlan = makeMinimalPlan("alice", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const bobPlan = makeMinimalPlan("bob", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-b" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); + expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([]); + }); + + it("does not conflict with the current sandbox itself", () => { + const plan = makeMinimalPlan("alice", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", plan)]); + expect(findChannelConflictsFromPlan("alice", plan, registry)).toEqual([]); + }); + + it("returns no conflict when the stored entry has the channel disabled", () => { + const alicePlan = makeMinimalPlan("alice", { + disabledChannels: ["telegram"], + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: false, selected: true, configured: true, disabled: true, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const bobPlan = makeMinimalPlan("bob", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); + expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([]); + }); + + it("returns no conflict when the incoming plan has no credential hashes", () => { + const alicePlan = makeMinimalPlan("alice", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const bobPlan = makeMinimalPlan("bob", { + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: false }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); + expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Plan-backed registry entries in findChannelConflicts / findAllOverlaps +// --------------------------------------------------------------------------- + +describe("findChannelConflicts with plan-backed registry entries", () => { + it("detects a conflict against a plan-only entry (no legacy messagingChannels field)", () => { + const alicePlan = makeMinimalPlan("alice", { + channels: [{ channelId: "discord", displayName: "Discord", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "discord", credentialId: "discordBotToken", sourceInput: "botToken", providerName: "alice-discord-bridge", providerEnvKey: "DISCORD_BOT_TOKEN", placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-dc" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); + expect( + findChannelConflicts( + "bob", + [{ channel: "discord", credentialHashes: { DISCORD_BOT_TOKEN: "hash-dc" } }], + registry, + ), + ).toEqual([{ channel: "discord", sandbox: "alice", reason: "matching-token" }]); + }); + + it("ignores a disabled channel in a plan-backed entry", () => { + const alicePlan = makeMinimalPlan("alice", { + disabledChannels: ["discord"], + channels: [{ channelId: "discord", displayName: "Discord", authMode: "token-paste", active: false, selected: true, configured: true, disabled: true, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "discord", credentialId: "discordBotToken", sourceInput: "botToken", providerName: "alice-discord-bridge", providerEnvKey: "DISCORD_BOT_TOKEN", placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-dc" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); + expect( + findChannelConflicts( + "bob", + [{ channel: "discord", credentialHashes: { DISCORD_BOT_TOKEN: "hash-dc" } }], + registry, + ), + ).toEqual([]); + }); +}); + +describe("findAllOverlaps with plan-backed registry entries", () => { + it("reports a matching-token overlap between two plan-backed entries", () => { + const alicePlan = makeMinimalPlan("alice", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const bobPlan = makeMinimalPlan("bob", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan), makePlanEntry("bob", bobPlan)]); + expect(findAllOverlaps(registry)).toEqual([ + { channel: "telegram", sandboxes: ["alice", "bob"], reason: "matching-token" }, + ]); + }); + + it("does not report an overlap when the shared channel is disabled in one plan", () => { + const alicePlan = makeMinimalPlan("alice", { + disabledChannels: ["telegram"], + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: false, selected: true, configured: true, disabled: true, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const bobPlan = makeMinimalPlan("bob", { + channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [ + { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, + ], + }); + const registry = makeRegistry([makePlanEntry("alice", alicePlan), makePlanEntry("bob", bobPlan)]); + expect(findAllOverlaps(registry)).toEqual([]); + }); +}); diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index 6d31d6cc64..e2323e96a3 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -8,56 +8,33 @@ // sandboxes sharing the same token silently break both bridges; see issue #1953. // // The registry persists which channels each sandbox uses plus a non-secret hash -// of the provider credential when available. This module detects true same-token -// overlaps and — because pre-existing sandboxes created before the field was -// added have no record — can optionally backfill the channel field by probing -// the live OpenShell gateway for known provider names. +// of the provider credential when available. This module is a thin public +// adapter over `src/lib/messaging/applier/conflict-detection.ts`, which holds +// all core detection logic and the probe factory. import type { SandboxEntry } from "./state/registry"; -import { getChannelDef, getChannelTokenKeys } from "./sandbox/channels"; - -type ProbeResult = "present" | "absent" | "error"; -type ConflictReason = "matching-token" | "unknown-token"; - -interface ConflictProbe { - // Tri-state — "error" is distinct from "absent" so a transient gateway - // failure does not get collapsed into "provider not attached" and then - // persisted as a bogus empty messagingChannels. - providerExists: (name: string) => ProbeResult; -} +import type { SandboxMessagingPlan } from "./messaging/manifest"; +import { + backfillLegacyEntryChannels, + createMessagingConflictProbe, + detectAllOverlapsInEntries, + findConflictsInEntries, + planToConflictChannelRequests, + type ConflictMatch, + type ConflictReason, + type MessagingConflictProbe, +} from "./messaging/applier"; + +export { createMessagingConflictProbe } from "./messaging/applier"; interface ConflictRegistry { listSandboxes: () => { sandboxes: SandboxEntry[]; defaultSandbox?: string | null }; updateSandbox: (name: string, updates: Partial) => boolean; } -interface RequestedChannel { - channel: string; - credentialHashes?: Record; -} - -type ChannelRequest = string | RequestedChannel; - -interface Conflict { - channel: string; - sandbox: string; - reason: ConflictReason; -} +type ChannelRequest = string | { channel: string; credentialHashes?: Record }; -// NemoClaw attaches one OpenShell provider per messaging channel per sandbox. -// The provider name pattern is established in src/lib/onboard.ts at sandbox -// creation time; when a sandbox predates the messagingChannels registry field, -// the live provider is the only record of which channels it uses. -const PROVIDER_SUFFIXES: Record = { - telegram: "-telegram-bridge", - discord: "-discord-bridge", - slack: "-slack-bridge", - wechat: "-wechat-bridge", -}; - -const KNOWN_CHANNELS = Object.keys(PROVIDER_SUFFIXES); - -function normalizeRequest(request: ChannelRequest): RequestedChannel | null { +function normalizeRequest(request: ChannelRequest) { if (typeof request === "string") { return request ? { channel: request, credentialHashes: {} } : null; } @@ -65,67 +42,6 @@ function normalizeRequest(request: ChannelRequest): RequestedChannel | null { return request; } -function getTokenKeys(channel: string): string[] { - const def = getChannelDef(channel); - return def ? getChannelTokenKeys(def) : []; -} - -function hasStoredChannel(entry: SandboxEntry, channel: string): boolean { - if (!Array.isArray(entry.messagingChannels) || !entry.messagingChannels.includes(channel)) { - return false; - } - // A `channels stop` sandbox keeps the channel in messagingChannels so a later - // `channels start` can recover, but its bridge is paused — the credential is - // not in use, so it must not block another sandbox from claiming the token. - return !(Array.isArray(entry.disabledChannels) && entry.disabledChannels.includes(channel)); -} - -function conflictReasonForRequest( - entry: SandboxEntry, - request: RequestedChannel, -): ConflictReason | null { - if (!hasStoredChannel(entry, request.channel)) return null; - const requestedHashes = request.credentialHashes || {}; - const storedHashes = entry.providerCredentialHashes || {}; - const tokenKeys = getTokenKeys(request.channel); - const comparisonKeys = tokenKeys.length > 0 ? tokenKeys : Object.keys(requestedHashes); - if (comparisonKeys.length === 0) return "unknown-token"; - - let sawUnknown = false; - for (const key of comparisonKeys) { - const requestedHash = requestedHashes[key] || null; - const storedHash = storedHashes[key] || null; - if (requestedHash && storedHash) { - if (requestedHash === storedHash) return "matching-token"; - continue; - } - sawUnknown = true; - } - return sawUnknown ? "unknown-token" : null; -} - -function conflictReasonForPair( - channel: string, - left: SandboxEntry, - right: SandboxEntry, -): ConflictReason | null { - if (!hasStoredChannel(left, channel) || !hasStoredChannel(right, channel)) return null; - const tokenKeys = getTokenKeys(channel); - if (tokenKeys.length === 0) return "unknown-token"; - - let sawUnknown = false; - for (const key of tokenKeys) { - const leftHash = left.providerCredentialHashes?.[key] || null; - const rightHash = right.providerCredentialHashes?.[key] || null; - if (leftHash && rightHash) { - if (leftHash === rightHash) return "matching-token"; - continue; - } - sawUnknown = true; - } - return sawUnknown ? "unknown-token" : null; -} - /** * For registry entries missing `messagingChannels`, probe OpenShell to infer * which channels the sandbox was onboarded with, and write the result back to @@ -135,35 +51,12 @@ function conflictReasonForPair( */ export function backfillMessagingChannels( registry: ConflictRegistry, - probe: ConflictProbe, + probe: MessagingConflictProbe, ): void { const { sandboxes } = registry.listSandboxes(); - for (const entry of sandboxes) { - if (Array.isArray(entry.messagingChannels)) continue; - const discovered: string[] = []; - let probeFailed = false; - for (const channel of KNOWN_CHANNELS) { - const providerName = `${entry.name}${PROVIDER_SUFFIXES[channel]}`; - let state: ProbeResult; - try { - state = probe.providerExists(providerName); - } catch { - state = "error"; - } - if (state === "present") { - discovered.push(channel); - } else if (state === "error") { - // Partial results can't be persisted: writing a partial/empty list - // sets messagingChannels, preventing future retries and permanently - // hiding real overlaps. Skip the write so we retry on next call. - probeFailed = true; - break; - } - } - if (!probeFailed) { - registry.updateSandbox(entry.name, { messagingChannels: discovered }); - } - } + backfillLegacyEntryChannels(sandboxes, probe, (name, channels) => { + registry.updateSandbox(name, { messagingChannels: channels }); + }); } /** @@ -175,20 +68,14 @@ export function findChannelConflicts( currentSandbox: string | null, enabledChannels: ChannelRequest[], registry: ConflictRegistry, -): Conflict[] { +): ConflictMatch[] { if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; - const requests = enabledChannels.map(normalizeRequest).filter((r): r is RequestedChannel => !!r); + const requests = enabledChannels.map(normalizeRequest).filter( + (r): r is NonNullable> => !!r, + ); if (requests.length === 0) return []; const { sandboxes } = registry.listSandboxes(); - const others = sandboxes.filter( - (s) => s.name !== currentSandbox && Array.isArray(s.messagingChannels), - ); - return requests.flatMap((request) => - others.flatMap((sandbox) => { - const reason = conflictReasonForRequest(sandbox, request); - return reason ? [{ channel: request.channel, sandbox: sandbox.name, reason }] : []; - }), - ); + return findConflictsInEntries(currentSandbox, requests, sandboxes); } /** @@ -202,31 +89,21 @@ export function findAllOverlaps(registry: ConflictRegistry): Array<{ reason: ConflictReason; }> { const { sandboxes } = registry.listSandboxes(); - const byChannel = new Map(); - for (const entry of sandboxes) { - if (!Array.isArray(entry.messagingChannels)) continue; - const disabled = new Set( - Array.isArray(entry.disabledChannels) ? entry.disabledChannels : [], - ); - for (const channel of entry.messagingChannels) { - if (disabled.has(channel)) continue; - const list = byChannel.get(channel) || []; - list.push(entry); - byChannel.set(channel, list); - } - } - const overlaps: Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> = - []; - for (const [channel, entries] of byChannel) { - if (entries.length < 2) continue; - for (let i = 0; i < entries.length; i += 1) { - for (let j = i + 1; j < entries.length; j += 1) { - const reason = conflictReasonForPair(channel, entries[i], entries[j]); - if (reason) { - overlaps.push({ channel, sandboxes: [entries[i].name, entries[j].name], reason }); - } - } - } - } - return overlaps; + return detectAllOverlapsInEntries(sandboxes); +} + +/** + * Plan-driven variant of `findChannelConflicts`. Derives the channel request + * list from a compiled `SandboxMessagingPlan` instead of requiring the caller + * to build credential hashes from raw channel constants. + * + * Disabled channels and bindings without a credential hash are excluded + * automatically by `planToConflictChannelRequests`. + */ +export function findChannelConflictsFromPlan( + currentSandbox: string | null, + plan: SandboxMessagingPlan, + registry: ConflictRegistry, +): ConflictMatch[] { + return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); } diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts new file mode 100644 index 0000000000..d1955c5f1f --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -0,0 +1,357 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../manifest"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ProbeResult = "present" | "absent" | "error"; +export type ConflictReason = "matching-token" | "unknown-token"; + +export interface MessagingConflictProbe { + // Tri-state — "error" is distinct from "absent" so a transient gateway + // failure does not get collapsed into "provider not attached" and then + // persisted as a bogus empty messagingChannels. + providerExists: (name: string) => ProbeResult; +} + +export interface MessagingConflictProbeGatewayDeps { + /** Run `openshell sandbox list`; return true if the gateway answered. */ + checkGatewayLiveness: () => boolean; + /** Check if the named OpenShell provider exists; assumes gateway is alive. */ + providerExists: (name: string) => boolean; +} + +export interface ConflictRequest { + readonly channel: string; + readonly credentialHashes?: Record; +} + +export interface ConflictMatch { + readonly channel: string; + readonly sandbox: string; + readonly reason: ConflictReason; +} + +/** + * Minimal shape of a registry entry that conflict detection needs. + * Satisfied by `SandboxEntry` from `./state/registry`. + */ +export interface ConflictRegistryEntry { + readonly name: string; + readonly messaging?: { readonly plan: SandboxMessagingPlan } | null; + readonly messagingChannels?: readonly string[] | null; + readonly disabledChannels?: readonly string[] | null; + readonly providerCredentialHashes?: Record | null; +} + +// --------------------------------------------------------------------------- +// Constants — provider name suffixes for legacy probe-based backfill. +// NemoClaw attaches one OpenShell provider per messaging channel per sandbox. +// When a sandbox predates the messagingChannels registry field, probing the +// live gateway by known provider name is the only record of its channels. +// --------------------------------------------------------------------------- + +export const PROVIDER_SUFFIXES: Record = { + telegram: "-telegram-bridge", + discord: "-discord-bridge", + slack: "-slack-bridge", + wechat: "-wechat-bridge", +}; + +export const KNOWN_CHANNEL_IDS: readonly string[] = Object.keys(PROVIDER_SUFFIXES); + +// --------------------------------------------------------------------------- +// Probe factory +// --------------------------------------------------------------------------- + +/** + * Build a tri-state `MessagingConflictProbe` from plain openshell runner deps. + * + * The liveness result is cached so the `sandbox list` call is issued at most + * once per probe instance. A transient gateway failure (`checkGatewayLiveness` + * returns false) causes all subsequent `providerExists` calls to return "error" + * rather than "absent", preventing a flaky gateway from being mis-recorded as + * "no providers" and permanently suppressing future backfill retries. + */ +export function createMessagingConflictProbe( + deps: MessagingConflictProbeGatewayDeps, +): MessagingConflictProbe { + let alive: boolean | null = null; + return { + providerExists: (name) => { + if (alive === null) alive = deps.checkGatewayLiveness(); + if (!alive) return "error"; + return deps.providerExists(name) ? "present" : "absent"; + }, + }; +} + +// --------------------------------------------------------------------------- +// Plan-to-request helpers +// --------------------------------------------------------------------------- + +/** + * Return the channel IDs that are active (not disabled) in a compiled plan. + */ +export function getActiveChannelIdsFromPlan(plan: SandboxMessagingPlan): string[] { + const disabled = new Set(plan.disabledChannels); + return plan.channels.filter((c) => !disabled.has(c.channelId)).map((c) => c.channelId); +} + +/** + * Return credential hashes keyed by providerEnvKey from a compiled plan. + * Only bindings that have a `credentialHash` (i.e. the credential was present + * when the plan was compiled) are included. + */ +export function getCredentialHashesFromPlan(plan: SandboxMessagingPlan): Record { + const hashes: Record = {}; + for (const b of plan.credentialBindings) { + if (b.credentialHash) hashes[b.providerEnvKey] = b.credentialHash; + } + return hashes; +} + +/** + * Build a `ConflictRequest[]` from a compiled plan's credential bindings. + * + * Groups bindings by channelId (e.g. Slack has SLACK_BOT_TOKEN and + * SLACK_APP_TOKEN) and excludes: + * - channels in `plan.disabledChannels` (bridge is paused, not in use) + * - bindings without a `credentialHash` (credential was not supplied) + * + * The result feeds directly into `findConflictsInEntries`. + */ +export function planToConflictChannelRequests(plan: SandboxMessagingPlan): ConflictRequest[] { + const disabledSet = new Set(plan.disabledChannels); + const byChannel = new Map>(); + + for (const binding of plan.credentialBindings) { + if (disabledSet.has(binding.channelId) || !binding.credentialHash) continue; + const hashes = byChannel.get(binding.channelId) ?? {}; + hashes[binding.providerEnvKey] = binding.credentialHash; + byChannel.set(binding.channelId, hashes); + } + + return Array.from(byChannel.entries()).map(([channel, credentialHashes]) => ({ + channel, + credentialHashes, + })); +} + +// --------------------------------------------------------------------------- +// Entry resolution — plan-preferred, legacy-fallback +// --------------------------------------------------------------------------- + +/** + * Return the active (non-disabled) channel IDs for a registry entry. + * Prefers `entry.messaging.plan` data; falls back to the legacy + * `messagingChannels`/`disabledChannels` flat fields for entries that predate + * the plan architecture. Returns `null` when the entry has neither. + */ +export function resolveActiveChannelsFromEntry( + entry: ConflictRegistryEntry, +): string[] | null { + if (entry.messaging?.plan) { + return getActiveChannelIdsFromPlan(entry.messaging.plan); + } + if (!Array.isArray(entry.messagingChannels)) return null; + const disabled = new Set(Array.isArray(entry.disabledChannels) ? entry.disabledChannels : []); + return (entry.messagingChannels as string[]).filter((c) => !disabled.has(c)); +} + +/** + * Return credential hashes keyed by providerEnvKey for a registry entry. + * Prefers `entry.messaging.plan` credential bindings; falls back to the legacy + * `providerCredentialHashes` flat field. + */ +export function resolveCredentialHashesFromEntry( + entry: ConflictRegistryEntry, +): Record { + if (entry.messaging?.plan) return getCredentialHashesFromPlan(entry.messaging.plan); + return (entry.providerCredentialHashes as Record) ?? {}; +} + +// --------------------------------------------------------------------------- +// Detection — pure functions operating on ConflictRegistryEntry +// --------------------------------------------------------------------------- + +/** + * True when `channel` is active (present and not disabled) in `entry`. + * Disabled channels must not block another sandbox from claiming the same + * token — the bridge is paused so the credential is not in use. + */ +export function hasStoredChannelInEntry( + entry: ConflictRegistryEntry, + channel: string, +): boolean { + return resolveActiveChannelsFromEntry(entry)?.includes(channel) ?? false; +} + +/** + * Determine the conflict reason between `entry`'s stored state and a new + * channel request, or `null` if there is no conflict. + * + * Comparison keys are derived from stored hashes first (authoritative), then + * from requested hashes if stored is empty (legacy entries with no hashes). + * This removes the need for concrete channel-constant lookups. + */ +export function conflictReasonForRequest( + entry: ConflictRegistryEntry, + request: ConflictRequest, +): ConflictReason | null { + if (!hasStoredChannelInEntry(entry, request.channel)) return null; + const requestedHashes = request.credentialHashes ?? {}; + const storedHashes = resolveCredentialHashesFromEntry(entry); + const keys = + Object.keys(storedHashes).length > 0 + ? Object.keys(storedHashes) + : Object.keys(requestedHashes); + if (keys.length === 0) return "unknown-token"; + + let sawUnknown = false; + for (const key of keys) { + const rh = (requestedHashes[key] as string | null | undefined) ?? null; + const sh = storedHashes[key] ?? null; + if (rh && sh) { + if (rh === sh) return "matching-token"; + continue; + } + sawUnknown = true; + } + return sawUnknown ? "unknown-token" : null; +} + +/** + * Determine the conflict reason between two registry entries sharing `channel`, + * or `null` if there is no conflict. Returns each pair at most once (the + * caller is responsible for ordered iteration). + */ +export function conflictReasonForPair( + channel: string, + left: ConflictRegistryEntry, + right: ConflictRegistryEntry, +): ConflictReason | null { + if (!hasStoredChannelInEntry(left, channel) || !hasStoredChannelInEntry(right, channel)) { + return null; + } + const lh = resolveCredentialHashesFromEntry(left); + const rh = resolveCredentialHashesFromEntry(right); + const keys = [...new Set([...Object.keys(lh), ...Object.keys(rh)])]; + if (keys.length === 0) return "unknown-token"; + + let sawUnknown = false; + for (const key of keys) { + const l = lh[key] ?? null; + const r = rh[key] ?? null; + if (l && r) { + if (l === r) return "matching-token"; + continue; + } + sawUnknown = true; + } + return sawUnknown ? "unknown-token" : null; +} + +/** + * Return every (channel, other-sandbox) pair where another entry already has + * one of the requested channels in use with either a matching credential hash + * or insufficient hash metadata to prove it differs. + */ +export function findConflictsInEntries( + currentSandbox: string | null, + requests: readonly ConflictRequest[], + entries: readonly ConflictRegistryEntry[], +): ConflictMatch[] { + const others = entries.filter( + (e) => + e.name !== currentSandbox && + (Array.isArray(e.messagingChannels) || e.messaging?.plan != null), + ); + return requests.flatMap((request) => + others.flatMap((entry) => { + const reason = conflictReasonForRequest(entry, request); + return reason ? [{ channel: request.channel, sandbox: entry.name, reason }] : []; + }), + ); +} + +/** + * Detect overlaps across all entries, returning each pair at most once. + * Used by `nemoclaw status` to surface sandboxes that already share a token. + */ +export function detectAllOverlapsInEntries( + entries: readonly ConflictRegistryEntry[], +): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { + const byChannel = new Map(); + for (const entry of entries) { + const activeChannels = resolveActiveChannelsFromEntry(entry); + if (!activeChannels) continue; + for (const channel of activeChannels) { + const list = byChannel.get(channel) ?? []; + list.push(entry); + byChannel.set(channel, list); + } + } + + const overlaps: Array<{ + channel: string; + sandboxes: [string, string]; + reason: ConflictReason; + }> = []; + for (const [channel, channelEntries] of byChannel) { + if (channelEntries.length < 2) continue; + for (let i = 0; i < channelEntries.length; i += 1) { + for (let j = i + 1; j < channelEntries.length; j += 1) { + const reason = conflictReasonForPair(channel, channelEntries[i], channelEntries[j]); + if (reason) { + overlaps.push({ + channel, + sandboxes: [channelEntries[i].name, channelEntries[j].name], + reason, + }); + } + } + } + } + return overlaps; +} + +/** + * For entries missing `messagingChannels`, probe OpenShell to infer which + * channels the sandbox was onboarded with, and call `updateEntry` for each + * resolved sandbox. Safe to call repeatedly — entries with `messagingChannels` + * already set are skipped. Probe errors abort the write for that sandbox so a + * flaky gateway does not permanently hide real overlaps. + */ +export function backfillLegacyEntryChannels( + entries: readonly ConflictRegistryEntry[], + probe: MessagingConflictProbe, + updateEntry: (name: string, channels: string[]) => void, +): void { + for (const entry of entries) { + if (Array.isArray(entry.messagingChannels)) continue; + const discovered: string[] = []; + let probeFailed = false; + for (const channel of KNOWN_CHANNEL_IDS) { + const providerName = `${entry.name}${PROVIDER_SUFFIXES[channel]}`; + let state: ProbeResult; + try { + state = probe.providerExists(providerName); + } catch { + state = "error"; + } + if (state === "present") { + discovered.push(channel); + } else if (state === "error") { + probeFailed = true; + break; + } + } + if (!probeFailed) { + updateEntry(entry.name, discovered); + } + } +} diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index f9e4dcf486..85a6eae92a 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -4,6 +4,7 @@ export * from "./setup-applier"; export * from "./host-state-applier"; export * from "./agent-config"; +export * from "./conflict-detection"; export * from "./openshell-provider"; export * from "./policy"; export type * from "./types"; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ebb54bd7f8..768a1e1f83 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -915,30 +915,6 @@ function upsertMessagingProviders( } const providerExistsInGateway = (name: string) => onboardProviders.providerExistsInGateway(name, runOpenshell); -// Tri-state probe factory for messaging-conflict backfill. An upfront liveness -// check is necessary because `openshell provider get` exits non-zero for both -// "provider not attached" and "gateway unreachable"; without the liveness -// gate, a transient gateway failure would be recorded as "no providers" and -// permanently suppress future backfill retries. -function makeConflictProbe() { - let gatewayAlive: boolean | null = null; - const isGatewayAlive = () => { - if (gatewayAlive === null) { - const result = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); - // runCaptureOpenshell returns stdout/stderr as a single string; treat - // any non-empty output as a sign openshell answered. Empty output with - // ignoreError typically means the binary failed to produce anything. - gatewayAlive = typeof result === "string" && result.length > 0; - } - return gatewayAlive; - }; - return { - providerExists: (name: string) => { - if (!isGatewayAlive()) return "error"; - return providerExistsInGateway(name) ? "present" : "absent"; - }, - }; -} function verifyInferenceRoute(_provider: string, _model: string): void { const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); @@ -2815,33 +2791,28 @@ async function createSandbox( // the sandbox reuse decision so we can detect stale sandboxes that were created // without provider attachments (security: prevents legacy raw-env-var leaks). - // The UI toggle list can include channels the user toggled on but then - // skipped the token prompt for. Only channels with a real token will have a - // provider attached, so the conflict check must filter out the skipped ones - // (otherwise we warn about phantom channels that will never poll). - const conflictCheckChannels = Array.isArray(enabledChannels) - ? enabledChannels.flatMap((name) => { - const def = MESSAGING_CHANNELS.find((c) => c.name === name); - if (!def || !def.envKey || !getValidatedMessagingToken(def, def.envKey)) return []; - const tokenEnvKeys = getChannelTokenKeys(def); - const credentialHashes: Record = {}; - for (const envKey of tokenEnvKeys) { - const hash = hashCredential(getValidatedMessagingToken(def, envKey)); - if (hash) credentialHashes[envKey] = hash; - } - if (Object.keys(credentialHashes).length === 0) return []; - return [{ channel: name, credentialHashes }]; - }) - : []; - // Messaging channels like Telegram (getUpdates), Discord (gateway), and Slack // (Socket Mode) enforce one consumer per channel credential. Two sandboxes // sharing a credential silently break both bridges (see #1953). Warn before // we commit. - if (conflictCheckChannels.length > 0) { - const { backfillMessagingChannels, findChannelConflicts } = require("./messaging-conflict"); - backfillMessagingChannels(registry, makeConflictProbe()); - const conflicts = findChannelConflicts(sandboxName, conflictCheckChannels, registry); + // + // The compiled plan (written to env by setupMessagingChannels) is the source + // of truth: credential hashes and active-channel membership are read from + // plan.credentialBindings rather than from MESSAGING_CHANNELS constants. + const currentPlan = readMessagingPlanFromEnv(); + const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialHash) ?? false; + if (hasPlanCredentials) { + const { backfillMessagingChannels, findChannelConflictsFromPlan, createMessagingConflictProbe } = + require("./messaging-conflict") as typeof import("./messaging-conflict"); + const probe = createMessagingConflictProbe({ + checkGatewayLiveness: () => { + const result = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); + return typeof result === "string" && result.length > 0; + }, + providerExists: (name) => providerExistsInGateway(name), + }); + backfillMessagingChannels(registry, probe); + const conflicts = findChannelConflictsFromPlan(sandboxName, currentPlan!, registry); if (conflicts.length > 0) { for (const { channel, sandbox, reason } of conflicts) { const detail = From 9e57fc0938236b79bccfa2fe5c1f080de783a122 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 15:46:33 +0530 Subject: [PATCH 07/25] fix(messaging): address CodeRabbit review on conflict detection - Remove redundant createMessagingConflictProbe named import from messaging-conflict.ts (symbol is already re-exported on the line below; flagged by CodeQL unused-import rule) - Fix checkGatewayLiveness in onboard.ts to use runOpenshell exit status instead of stdout length; a healthy gateway with no sandboxes returns empty output with status 0, which the previous check incorrectly treated as "down" Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/messaging-conflict.ts | 1 - src/lib/onboard.ts | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index e2323e96a3..23db63bea2 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -16,7 +16,6 @@ import type { SandboxEntry } from "./state/registry"; import type { SandboxMessagingPlan } from "./messaging/manifest"; import { backfillLegacyEntryChannels, - createMessagingConflictProbe, detectAllOverlapsInEntries, findConflictsInEntries, planToConflictChannelRequests, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 768a1e1f83..0f4b53869c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2805,10 +2805,8 @@ async function createSandbox( const { backfillMessagingChannels, findChannelConflictsFromPlan, createMessagingConflictProbe } = require("./messaging-conflict") as typeof import("./messaging-conflict"); const probe = createMessagingConflictProbe({ - checkGatewayLiveness: () => { - const result = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); - return typeof result === "string" && result.length > 0; - }, + checkGatewayLiveness: () => + runOpenshell(["sandbox", "list"], { ignoreError: true, suppressOutput: true }).status === 0, providerExists: (name) => providerExistsInGateway(name), }); backfillMessagingChannels(registry, probe); From aa028bd5bd9f106e17cf0072280bd9c16d1a2a22 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 16:20:34 +0530 Subject: [PATCH 08/25] fix(messaging): address Advisor review findings on conflict detection - Fix credentialHash gate: gate on credentialAvailable (set by the compiler for all real onboarding flows) instead of credentialHash (not yet populated), which previously caused the conflict check to be silently skipped for all manifest-plan onboarding; planToConflictChannelRequests now includes channels with credentialAvailable=true and no hash, falling through to unknown-token conservative detection - Fix cross-channel hash contamination: getCredentialHashesFromPlan now accepts an optional channelId filter; conflictReasonForRequest and conflictReasonForPair use the new resolveChannelHashesFromEntry helper so Slack or Discord hashes cannot produce false positive unknown-token results when checking a Telegram request - Fix legacy hash fallback: when a plan-backed entry exists but carries no credentialHash for the requested channel (migration in flight), fall back to providerCredentialHashes so matching-token detection is not silently lost - Align active-channel semantics with plan-filter.ts: getActiveChannelIdsFromPlan now also checks channel.active && !channel.disabled, not only disabledChannels - Split plan-driven tests into src/lib/messaging/applier/conflict-detection.test.ts to offset the messaging-conflict.test.ts monolith growth; adds WhatsApp no-op test, cross-channel isolation, credentialAvailable-gate, and legacy-fallback cases Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/messaging-conflict.test.ts | 262 +--------- .../applier/conflict-detection.test.ts | 473 ++++++++++++++++++ .../messaging/applier/conflict-detection.ts | 74 ++- src/lib/onboard.ts | 6 +- 4 files changed, 537 insertions(+), 278 deletions(-) create mode 100644 src/lib/messaging/applier/conflict-detection.test.ts diff --git a/src/lib/messaging-conflict.test.ts b/src/lib/messaging-conflict.test.ts index 570c033001..25f2ebd622 100644 --- a/src/lib/messaging-conflict.test.ts +++ b/src/lib/messaging-conflict.test.ts @@ -1,17 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// Legacy-field (messagingChannels / providerCredentialHashes) conflict tests. +// Plan-driven tests live in src/lib/messaging/applier/conflict-detection.test.ts + import { describe, expect, it, vi } from "vitest"; -import type { SandboxEntry, SandboxMessagingState } from "./state/registry"; -import type { SandboxMessagingPlan } from "./messaging/manifest"; +import type { SandboxEntry } from "./state/registry"; import { backfillMessagingChannels, findAllOverlaps, findChannelConflicts, - findChannelConflictsFromPlan, } from "./messaging-conflict"; -import { planToConflictChannelRequests, type MessagingConflictProbe } from "./messaging/applier"; +import { type MessagingConflictProbe } from "./messaging/applier"; type ProviderExists = MessagingConflictProbe["providerExists"]; @@ -219,9 +220,6 @@ describe("backfillMessagingChannels", () => { }); it("backfills wechat when only the wechat bridge provider is present", () => { - // The probe-by-suffix mechanism relies on every channel having an entry - // in PROVIDER_SUFFIXES; if wechat were ever dropped from that map, this - // test starts catching the absent provider. const registry = makeRegistry([{ name: "alice" }]); const probe: MessagingConflictProbe = { providerExists: vi.fn((name) => @@ -264,9 +262,6 @@ describe("backfillMessagingChannels", () => { }); it("does NOT persist when a probe returns error (retry on next call)", () => { - // "error" is distinct from "absent": a transient gateway failure must not - // be collapsed into "provider not attached" and persisted, because that - // would prevent all future backfill retries and hide real overlaps. const registry = makeRegistry([{ name: "alice" }]); const probe: MessagingConflictProbe = { providerExists: vi.fn((name) => { @@ -309,250 +304,3 @@ describe("backfillMessagingChannels", () => { }); }); }); - -// --------------------------------------------------------------------------- -// Helpers for plan-driven tests -// --------------------------------------------------------------------------- - -function makeMinimalPlan( - sandboxName: string, - overrides: Partial = {}, -): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName, - agent: "openclaw", - workflow: "onboard", - channels: [], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - ...overrides, - }; -} - -function makePlanEntry(sandboxName: string, plan: SandboxMessagingPlan): SandboxEntry { - const state: SandboxMessagingState = { schemaVersion: 1, plan }; - return { name: sandboxName, messaging: state }; -} - -// --------------------------------------------------------------------------- -// planToConflictChannelRequests -// --------------------------------------------------------------------------- - -describe("planToConflictChannelRequests", () => { - it("returns one request per active channel with its credential hash", () => { - const plan = makeMinimalPlan("alice", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-tg" }, - ], - }); - expect(planToConflictChannelRequests(plan)).toEqual([ - { channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg" } }, - ]); - }); - - it("groups multiple bindings for the same channel (e.g. Slack bot + app tokens)", () => { - const plan = makeMinimalPlan("alice", { - channels: [{ channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "slack", credentialId: "slackBotToken", sourceInput: "botToken", providerName: "alice-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-bot" }, - { channelId: "slack", credentialId: "slackAppToken", sourceInput: "appToken", providerName: "alice-slack-bridge", providerEnvKey: "SLACK_APP_TOKEN", placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", credentialAvailable: true, credentialHash: "hash-app" }, - ], - }); - expect(planToConflictChannelRequests(plan)).toEqual([ - { channel: "slack", credentialHashes: { SLACK_BOT_TOKEN: "hash-bot", SLACK_APP_TOKEN: "hash-app" } }, - ]); - }); - - it("skips bindings without a credentialHash (credential not supplied)", () => { - const plan = makeMinimalPlan("alice", { - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: false }, - ], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); - - it("skips channels listed in disabledChannels (bridge is paused, not in use)", () => { - const plan = makeMinimalPlan("alice", { - disabledChannels: ["telegram"], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-tg" }, - ], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// findChannelConflictsFromPlan -// --------------------------------------------------------------------------- - -describe("findChannelConflictsFromPlan", () => { - it("detects a matching-token conflict against a plan-backed registry entry", () => { - const alicePlan = makeMinimalPlan("alice", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const bobPlan = makeMinimalPlan("bob", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); - expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([ - { channel: "telegram", sandbox: "alice", reason: "matching-token" }, - ]); - }); - - it("returns no conflict when credential hashes differ", () => { - const alicePlan = makeMinimalPlan("alice", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const bobPlan = makeMinimalPlan("bob", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-b" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); - expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([]); - }); - - it("does not conflict with the current sandbox itself", () => { - const plan = makeMinimalPlan("alice", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", plan)]); - expect(findChannelConflictsFromPlan("alice", plan, registry)).toEqual([]); - }); - - it("returns no conflict when the stored entry has the channel disabled", () => { - const alicePlan = makeMinimalPlan("alice", { - disabledChannels: ["telegram"], - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: false, selected: true, configured: true, disabled: true, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const bobPlan = makeMinimalPlan("bob", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); - expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([]); - }); - - it("returns no conflict when the incoming plan has no credential hashes", () => { - const alicePlan = makeMinimalPlan("alice", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const bobPlan = makeMinimalPlan("bob", { - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: false }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); - expect(findChannelConflictsFromPlan("bob", bobPlan, registry)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// Plan-backed registry entries in findChannelConflicts / findAllOverlaps -// --------------------------------------------------------------------------- - -describe("findChannelConflicts with plan-backed registry entries", () => { - it("detects a conflict against a plan-only entry (no legacy messagingChannels field)", () => { - const alicePlan = makeMinimalPlan("alice", { - channels: [{ channelId: "discord", displayName: "Discord", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "discord", credentialId: "discordBotToken", sourceInput: "botToken", providerName: "alice-discord-bridge", providerEnvKey: "DISCORD_BOT_TOKEN", placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-dc" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); - expect( - findChannelConflicts( - "bob", - [{ channel: "discord", credentialHashes: { DISCORD_BOT_TOKEN: "hash-dc" } }], - registry, - ), - ).toEqual([{ channel: "discord", sandbox: "alice", reason: "matching-token" }]); - }); - - it("ignores a disabled channel in a plan-backed entry", () => { - const alicePlan = makeMinimalPlan("alice", { - disabledChannels: ["discord"], - channels: [{ channelId: "discord", displayName: "Discord", authMode: "token-paste", active: false, selected: true, configured: true, disabled: true, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "discord", credentialId: "discordBotToken", sourceInput: "botToken", providerName: "alice-discord-bridge", providerEnvKey: "DISCORD_BOT_TOKEN", placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-dc" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan)]); - expect( - findChannelConflicts( - "bob", - [{ channel: "discord", credentialHashes: { DISCORD_BOT_TOKEN: "hash-dc" } }], - registry, - ), - ).toEqual([]); - }); -}); - -describe("findAllOverlaps with plan-backed registry entries", () => { - it("reports a matching-token overlap between two plan-backed entries", () => { - const alicePlan = makeMinimalPlan("alice", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const bobPlan = makeMinimalPlan("bob", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan), makePlanEntry("bob", bobPlan)]); - expect(findAllOverlaps(registry)).toEqual([ - { channel: "telegram", sandboxes: ["alice", "bob"], reason: "matching-token" }, - ]); - }); - - it("does not report an overlap when the shared channel is disabled in one plan", () => { - const alicePlan = makeMinimalPlan("alice", { - disabledChannels: ["telegram"], - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: false, selected: true, configured: true, disabled: true, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "alice-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const bobPlan = makeMinimalPlan("bob", { - channels: [{ channelId: "telegram", displayName: "Telegram", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], - credentialBindings: [ - { channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "bob-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "hash-a" }, - ], - }); - const registry = makeRegistry([makePlanEntry("alice", alicePlan), makePlanEntry("bob", bobPlan)]); - expect(findAllOverlaps(registry)).toEqual([]); - }); -}); diff --git a/src/lib/messaging/applier/conflict-detection.test.ts b/src/lib/messaging/applier/conflict-detection.test.ts new file mode 100644 index 0000000000..dca7764190 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection.test.ts @@ -0,0 +1,473 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingPlan } from "../manifest"; +import type { SandboxMessagingState } from "../../state/registry"; +import { + conflictReasonForPair, + conflictReasonForRequest, + detectAllOverlapsInEntries, + findConflictsInEntries, + getActiveChannelIdsFromPlan, + getCredentialHashesFromPlan, + hasStoredChannelInEntry, + planToConflictChannelRequests, + type ConflictRegistryEntry, +} from "./conflict-detection"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makePlan( + sandboxName: string, + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +function tgChannel(active = true, disabled = false) { + return { + channelId: "telegram" as const, + displayName: "Telegram", + authMode: "token-paste" as const, + active, + selected: true, + configured: true, + disabled, + inputs: [], + hooks: [], + }; +} + +function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { + return { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "sb-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: hash !== undefined || true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + +function slackBindings(botHash?: string, appHash?: string) { + return [ + { + channelId: "slack" as const, + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "sb-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + ...(botHash ? { credentialHash: botHash } : {}), + }, + { + channelId: "slack" as const, + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: "sb-slack-bridge", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", + credentialAvailable: true, + ...(appHash ? { credentialHash: appHash } : {}), + }, + ]; +} + +function planEntry(name: string, plan: SandboxMessagingPlan): ConflictRegistryEntry { + const state: SandboxMessagingState = { schemaVersion: 1, plan }; + return { name, messaging: state }; +} + +// --------------------------------------------------------------------------- +// getActiveChannelIdsFromPlan +// --------------------------------------------------------------------------- + +describe("getActiveChannelIdsFromPlan", () => { + it("returns active channel ids", () => { + const plan = makePlan("sb", { channels: [tgChannel(true, false)] }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual(["telegram"]); + }); + + it("excludes channels in disabledChannels", () => { + const plan = makePlan("sb", { + disabledChannels: ["telegram"], + channels: [tgChannel(true, false)], + }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + }); + + it("excludes channels where channel.disabled is true (#plan-filter parity)", () => { + const plan = makePlan("sb", { channels: [tgChannel(false, true)] }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + }); + + it("excludes channels where channel.active is false (#plan-filter parity)", () => { + const plan = makePlan("sb", { channels: [tgChannel(false, false)] }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// getCredentialHashesFromPlan +// --------------------------------------------------------------------------- + +describe("getCredentialHashesFromPlan", () => { + it("returns hashes keyed by providerEnvKey", () => { + const plan = makePlan("sb", { credentialBindings: [tgBinding("hash-x")] }); + expect(getCredentialHashesFromPlan(plan)).toEqual({ TELEGRAM_BOT_TOKEN: "hash-x" }); + }); + + it("scopes to a single channel when channelId is provided", () => { + const plan = makePlan("sb", { + credentialBindings: [tgBinding("hash-tg"), ...slackBindings("hash-bot", "hash-app")], + }); + expect(getCredentialHashesFromPlan(plan, "telegram")).toEqual({ + TELEGRAM_BOT_TOKEN: "hash-tg", + }); + expect(getCredentialHashesFromPlan(plan, "slack")).toEqual({ + SLACK_BOT_TOKEN: "hash-bot", + SLACK_APP_TOKEN: "hash-app", + }); + }); + + it("omits bindings without a credentialHash", () => { + const plan = makePlan("sb", { credentialBindings: [tgBinding()] }); + expect(getCredentialHashesFromPlan(plan)).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// planToConflictChannelRequests +// --------------------------------------------------------------------------- + +describe("planToConflictChannelRequests", () => { + it("returns one request per active channel that has a credential available", () => { + const plan = makePlan("sb", { + channels: [tgChannel()], + credentialBindings: [tgBinding("hash-tg")], + }); + expect(planToConflictChannelRequests(plan)).toEqual([ + { channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg" } }, + ]); + }); + + it("includes a channel with credentialAvailable=true but no hash (unknown-token fallback)", () => { + const binding = { ...tgBinding(), credentialAvailable: true }; + const plan = makePlan("sb", { credentialBindings: [binding] }); + const requests = planToConflictChannelRequests(plan); + expect(requests).toHaveLength(1); + expect(requests[0].channel).toBe("telegram"); + expect(requests[0].credentialHashes).toEqual({}); + }); + + it("groups multiple bindings for the same channel (Slack bot + app tokens)", () => { + const plan = makePlan("sb", { + credentialBindings: slackBindings("hash-bot", "hash-app"), + }); + expect(planToConflictChannelRequests(plan)).toEqual([ + { channel: "slack", credentialHashes: { SLACK_BOT_TOKEN: "hash-bot", SLACK_APP_TOKEN: "hash-app" } }, + ]); + }); + + it("skips bindings where credentialAvailable is false", () => { + const plan = makePlan("sb", { + credentialBindings: [{ ...tgBinding("hash-tg"), credentialAvailable: false }], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); + + it("skips channels in disabledChannels (bridge is paused)", () => { + const plan = makePlan("sb", { + disabledChannels: ["telegram"], + credentialBindings: [tgBinding("hash-tg")], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); + + it("WhatsApp — no-op: empty credentials produce no conflict requests (#4392)", () => { + // WhatsApp uses in-sandbox-qr pairing; it has no host-side token provider + // and therefore no credentialBindings. planToConflictChannelRequests must + // not emit a request for it, so it never participates in token-backed + // conflict detection or legacy provider probing. + const plan = makePlan("sb", { + channels: [ + { + channelId: "whatsapp", + displayName: "WhatsApp", + authMode: "in-sandbox-qr", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + credentialBindings: [], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// hasStoredChannelInEntry +// --------------------------------------------------------------------------- + +describe("hasStoredChannelInEntry", () => { + it("returns true for an active channel in a plan-backed entry", () => { + const entry = planEntry("sb", makePlan("sb", { channels: [tgChannel()] })); + expect(hasStoredChannelInEntry(entry, "telegram")).toBe(true); + }); + + it("returns false when channel is in plan.disabledChannels", () => { + const entry = planEntry( + "sb", + makePlan("sb", { disabledChannels: ["telegram"], channels: [tgChannel(false, true)] }), + ); + expect(hasStoredChannelInEntry(entry, "telegram")).toBe(false); + }); + + it("returns false when channel.active is false", () => { + const entry = planEntry("sb", makePlan("sb", { channels: [tgChannel(false, false)] })); + expect(hasStoredChannelInEntry(entry, "telegram")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// conflictReasonForRequest — channel-scoped hash comparison +// --------------------------------------------------------------------------- + +describe("conflictReasonForRequest (plan-backed entry)", () => { + it("detects matching-token when same channel hash matches", () => { + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [tgChannel()], + credentialBindings: [tgBinding("hash-a")], + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "telegram", + credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, + }), + ).toBe("matching-token"); + }); + + it("returns null when same channel hash differs", () => { + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [tgChannel()], + credentialBindings: [tgBinding("hash-a")], + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "telegram", + credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-b" }, + }), + ).toBeNull(); + }); + + it("does not create false positives from unrelated-channel hashes in the same entry", () => { + // alice has both Telegram (hash-tg-a) and Slack; bob checks Telegram with a + // different hash (hash-tg-b). Slack's keys must not pollute the comparison + // and cause a spurious unknown-token result. + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [ + tgChannel(), + { channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }, + ], + credentialBindings: [tgBinding("hash-tg-a"), ...slackBindings("hash-slack")], + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "telegram", + credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-b" }, + }), + ).toBeNull(); + }); + + it("falls back to legacy providerCredentialHashes when plan has no hashes for the channel", () => { + // During the migration window the plan may exist but carry no credentialHash yet. + // The function must fall back to the legacy field so safety is preserved. + const entry: ConflictRegistryEntry = { + name: "alice", + messaging: { + plan: makePlan("alice", { + channels: [tgChannel()], + credentialBindings: [tgBinding()], // no credentialHash + }), + }, + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-legacy" }, + }; + expect( + conflictReasonForRequest(entry, { + channel: "telegram", + credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-legacy" }, + }), + ).toBe("matching-token"); + }); + + it("returns unknown-token when plan has no hashes and no legacy hashes", () => { + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [tgChannel()], + credentialBindings: [tgBinding()], // no credentialHash + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "telegram", + credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, + }), + ).toBe("unknown-token"); + }); +}); + +// --------------------------------------------------------------------------- +// conflictReasonForPair — channel-scoped hash comparison +// --------------------------------------------------------------------------- + +describe("conflictReasonForPair", () => { + it("detects matching-token between two plan-backed entries", () => { + const alice = planEntry( + "alice", + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + expect(conflictReasonForPair("telegram", alice, bob)).toBe("matching-token"); + }); + + it("returns null when same-channel hashes differ", () => { + const alice = planEntry( + "alice", + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-b")] }), + ); + expect(conflictReasonForPair("telegram", alice, bob)).toBeNull(); + }); + + it("scopes comparison to the requested channel, ignoring other channels", () => { + // Both sandboxes have Telegram (different hashes) and Slack (same hash). + // Checking Telegram must NOT produce a conflict from the shared Slack hash. + const alice = planEntry( + "alice", + makePlan("alice", { + channels: [tgChannel(), { channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [tgBinding("hash-tg-a"), ...slackBindings("hash-slack")], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { + channels: [tgChannel(), { channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + credentialBindings: [tgBinding("hash-tg-b"), ...slackBindings("hash-slack")], + }), + ); + expect(conflictReasonForPair("telegram", alice, bob)).toBeNull(); + expect(conflictReasonForPair("slack", alice, bob)).toBe("matching-token"); + }); +}); + +// --------------------------------------------------------------------------- +// findConflictsInEntries / detectAllOverlapsInEntries — plan-backed entries +// --------------------------------------------------------------------------- + +describe("findConflictsInEntries (plan-backed entries)", () => { + it("detects matching-token against a plan-only entry (no legacy messagingChannels)", () => { + const alice = planEntry( + "alice", + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + expect( + findConflictsInEntries( + "bob", + [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], + [alice], + ), + ).toEqual([{ channel: "telegram", sandbox: "alice", reason: "matching-token" }]); + }); + + it("ignores a disabled channel in a plan-backed entry", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + disabledChannels: ["telegram"], + channels: [tgChannel(false, true)], + credentialBindings: [tgBinding("hash-a")], + }), + ); + expect( + findConflictsInEntries( + "bob", + [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], + [alice], + ), + ).toEqual([]); + }); +}); + +describe("detectAllOverlapsInEntries (plan-backed entries)", () => { + it("reports matching-token overlap between two plan-backed entries", () => { + const alice = planEntry( + "alice", + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + expect(detectAllOverlapsInEntries([alice, bob])).toEqual([ + { channel: "telegram", sandboxes: ["alice", "bob"], reason: "matching-token" }, + ]); + }); + + it("does not report overlap when shared channel is disabled in one plan", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + disabledChannels: ["telegram"], + channels: [tgChannel(false, true)], + credentialBindings: [tgBinding("hash-a")], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + expect(detectAllOverlapsInEntries([alice, bob])).toEqual([]); + }); +}); diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index d1955c5f1f..3e6d273999 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -95,20 +95,33 @@ export function createMessagingConflictProbe( /** * Return the channel IDs that are active (not disabled) in a compiled plan. + * Aligns with `enabledPlanChannels()` in plan-filter.ts: a channel is active + * only when `channel.active && !channel.disabled` AND it is not in + * `plan.disabledChannels`. */ export function getActiveChannelIdsFromPlan(plan: SandboxMessagingPlan): string[] { const disabled = new Set(plan.disabledChannels); - return plan.channels.filter((c) => !disabled.has(c.channelId)).map((c) => c.channelId); + return plan.channels + .filter((c) => c.active && !c.disabled && !disabled.has(c.channelId)) + .map((c) => c.channelId); } /** - * Return credential hashes keyed by providerEnvKey from a compiled plan. - * Only bindings that have a `credentialHash` (i.e. the credential was present - * when the plan was compiled) are included. + * Return credential hashes keyed by providerEnvKey from a compiled plan, + * optionally scoped to a single channel. + * + * Only bindings that carry a `credentialHash` are included. When `channelId` + * is provided only that channel's bindings are returned, which prevents + * hashes from other channels in the same sandbox from contaminating + * single-channel conflict comparisons. */ -export function getCredentialHashesFromPlan(plan: SandboxMessagingPlan): Record { +export function getCredentialHashesFromPlan( + plan: SandboxMessagingPlan, + channelId?: string, +): Record { const hashes: Record = {}; for (const b of plan.credentialBindings) { + if (channelId !== undefined && b.channelId !== channelId) continue; if (b.credentialHash) hashes[b.providerEnvKey] = b.credentialHash; } return hashes; @@ -120,18 +133,23 @@ export function getCredentialHashesFromPlan(plan: SandboxMessagingPlan): Record< * Groups bindings by channelId (e.g. Slack has SLACK_BOT_TOKEN and * SLACK_APP_TOKEN) and excludes: * - channels in `plan.disabledChannels` (bridge is paused, not in use) - * - bindings without a `credentialHash` (credential was not supplied) + * - bindings where the credential is not available (`credentialAvailable` + * false) — e.g. WhatsApp, which has no host-side token provider * - * The result feeds directly into `findConflictsInEntries`. + * When a binding has no `credentialHash` yet (the compiler populates this + * field once the credential-binding engine is updated), the channel is still + * included with an empty `credentialHashes` map, which falls through to + * `"unknown-token"` conservative detection. This preserves the safety + * behaviour while the compiler migration is in flight. */ export function planToConflictChannelRequests(plan: SandboxMessagingPlan): ConflictRequest[] { const disabledSet = new Set(plan.disabledChannels); const byChannel = new Map>(); for (const binding of plan.credentialBindings) { - if (disabledSet.has(binding.channelId) || !binding.credentialHash) continue; + if (disabledSet.has(binding.channelId) || !binding.credentialAvailable) continue; const hashes = byChannel.get(binding.channelId) ?? {}; - hashes[binding.providerEnvKey] = binding.credentialHash; + if (binding.credentialHash) hashes[binding.providerEnvKey] = binding.credentialHash; byChannel.set(binding.channelId, hashes); } @@ -163,14 +181,27 @@ export function resolveActiveChannelsFromEntry( } /** - * Return credential hashes keyed by providerEnvKey for a registry entry. - * Prefers `entry.messaging.plan` credential bindings; falls back to the legacy - * `providerCredentialHashes` flat field. + * Return credential hashes scoped to `channelId` for a registry entry. + * + * For plan-backed entries the lookup is channel-scoped: only bindings for the + * requested channel are considered. When the plan exists but carries no hashes + * for the channel (compiler migration in flight), the function falls back to + * the legacy `providerCredentialHashes` flat field so no safety coverage is + * lost during the transition. + * + * For legacy entries without a plan the entire `providerCredentialHashes` + * object is returned unchanged (same behavior as before this architecture). */ -export function resolveCredentialHashesFromEntry( +function resolveChannelHashesFromEntry( entry: ConflictRegistryEntry, + channelId: string, ): Record { - if (entry.messaging?.plan) return getCredentialHashesFromPlan(entry.messaging.plan); + if (entry.messaging?.plan) { + const planHashes = getCredentialHashesFromPlan(entry.messaging.plan, channelId); + if (Object.keys(planHashes).length > 0) return planHashes; + // Plan exists but no hashes yet for this channel — fall back to legacy + // field so matching-token detection is not silently downgraded. + } return (entry.providerCredentialHashes as Record) ?? {}; } @@ -194,9 +225,10 @@ export function hasStoredChannelInEntry( * Determine the conflict reason between `entry`'s stored state and a new * channel request, or `null` if there is no conflict. * - * Comparison keys are derived from stored hashes first (authoritative), then - * from requested hashes if stored is empty (legacy entries with no hashes). - * This removes the need for concrete channel-constant lookups. + * Hash comparison is scoped to the requested channel so that credentials + * from other channels on the same sandbox do not produce false positives. + * When no hashes are available the comparison falls back to "unknown-token" + * (conservative: warn even without definitive proof of sharing). */ export function conflictReasonForRequest( entry: ConflictRegistryEntry, @@ -204,7 +236,7 @@ export function conflictReasonForRequest( ): ConflictReason | null { if (!hasStoredChannelInEntry(entry, request.channel)) return null; const requestedHashes = request.credentialHashes ?? {}; - const storedHashes = resolveCredentialHashesFromEntry(entry); + const storedHashes = resolveChannelHashesFromEntry(entry, request.channel); const keys = Object.keys(storedHashes).length > 0 ? Object.keys(storedHashes) @@ -228,6 +260,8 @@ export function conflictReasonForRequest( * Determine the conflict reason between two registry entries sharing `channel`, * or `null` if there is no conflict. Returns each pair at most once (the * caller is responsible for ordered iteration). + * + * Hash comparison is scoped to `channel` for plan-backed entries. */ export function conflictReasonForPair( channel: string, @@ -237,8 +271,8 @@ export function conflictReasonForPair( if (!hasStoredChannelInEntry(left, channel) || !hasStoredChannelInEntry(right, channel)) { return null; } - const lh = resolveCredentialHashesFromEntry(left); - const rh = resolveCredentialHashesFromEntry(right); + const lh = resolveChannelHashesFromEntry(left, channel); + const rh = resolveChannelHashesFromEntry(right, channel); const keys = [...new Set([...Object.keys(lh), ...Object.keys(rh)])]; if (keys.length === 0) return "unknown-token"; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0f4b53869c..2c9b9b9aef 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2800,7 +2800,11 @@ async function createSandbox( // of truth: credential hashes and active-channel membership are read from // plan.credentialBindings rather than from MESSAGING_CHANNELS constants. const currentPlan = readMessagingPlanFromEnv(); - const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialHash) ?? false; + // Gate on credentialAvailable (set by the compiler) rather than credentialHash + // (populated later once the credential-binding engine is updated). Using the + // hash field alone would silently skip conflict detection for all current + // onboarding paths because the compiler does not yet emit it. + const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; if (hasPlanCredentials) { const { backfillMessagingChannels, findChannelConflictsFromPlan, createMessagingConflictProbe } = require("./messaging-conflict") as typeof import("./messaging-conflict"); From d12db3c18362370ea24bb165d2ed85c217b75337 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 19:55:46 +0530 Subject: [PATCH 09/25] fix(messaging): decouple channel registry from conflict-detection core and populate credentialHash in plan bindings Remove hardcoded PROVIDER_SUFFIXES/KNOWN_CHANNEL_IDS from conflict-detection.ts; backfillLegacyEntryChannels now receives the suffix map as an injected parameter. The adapter layer (messaging-conflict.ts) derives it from BUILT_IN_CHANNEL_MANIFESTS credentials so adding a channel to the manifest registry propagates automatically. Also fix credential-binding-engine to hash the env var value into credentialHash when credentialAvailable is true, enabling matching-token conflict detection on the plan-driven path. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/messaging-conflict.ts | 11 +++++++++- .../messaging/applier/conflict-detection.ts | 21 +++---------------- .../engines/credential-binding-engine.ts | 15 +++++++++---- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index 23db63bea2..b7d5c271de 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -23,6 +23,15 @@ import { type ConflictReason, type MessagingConflictProbe, } from "./messaging/applier"; +import { BUILT_IN_CHANNEL_MANIFESTS } from "./messaging/channels"; + +const PROVIDER_SUFFIXES: Record = Object.fromEntries( + BUILT_IN_CHANNEL_MANIFESTS.flatMap((m) => { + const cred = m.credentials[0]; + if (!cred?.providerName) return []; + return [[m.id, cred.providerName.replace("{sandboxName}", "")]]; + }), +); export { createMessagingConflictProbe } from "./messaging/applier"; @@ -55,7 +64,7 @@ export function backfillMessagingChannels( const { sandboxes } = registry.listSandboxes(); backfillLegacyEntryChannels(sandboxes, probe, (name, channels) => { registry.updateSandbox(name, { messagingChannels: channels }); - }); + }, PROVIDER_SUFFIXES); } /** diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index 3e6d273999..e5d4966a6b 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -47,22 +47,6 @@ export interface ConflictRegistryEntry { readonly providerCredentialHashes?: Record | null; } -// --------------------------------------------------------------------------- -// Constants — provider name suffixes for legacy probe-based backfill. -// NemoClaw attaches one OpenShell provider per messaging channel per sandbox. -// When a sandbox predates the messagingChannels registry field, probing the -// live gateway by known provider name is the only record of its channels. -// --------------------------------------------------------------------------- - -export const PROVIDER_SUFFIXES: Record = { - telegram: "-telegram-bridge", - discord: "-discord-bridge", - slack: "-slack-bridge", - wechat: "-wechat-bridge", -}; - -export const KNOWN_CHANNEL_IDS: readonly string[] = Object.keys(PROVIDER_SUFFIXES); - // --------------------------------------------------------------------------- // Probe factory // --------------------------------------------------------------------------- @@ -364,13 +348,14 @@ export function backfillLegacyEntryChannels( entries: readonly ConflictRegistryEntry[], probe: MessagingConflictProbe, updateEntry: (name: string, channels: string[]) => void, + providerSuffixes: Record, ): void { for (const entry of entries) { if (Array.isArray(entry.messagingChannels)) continue; const discovered: string[] = []; let probeFailed = false; - for (const channel of KNOWN_CHANNEL_IDS) { - const providerName = `${entry.name}${PROVIDER_SUFFIXES[channel]}`; + for (const channel of Object.keys(providerSuffixes)) { + const providerName = `${entry.name}${providerSuffixes[channel]}`; let state: ProbeResult; try { state = probe.providerExists(providerName); diff --git a/src/lib/messaging/compiler/engines/credential-binding-engine.ts b/src/lib/messaging/compiler/engines/credential-binding-engine.ts index 523f94ea2a..605babc5e0 100644 --- a/src/lib/messaging/compiler/engines/credential-binding-engine.ts +++ b/src/lib/messaging/compiler/engines/credential-binding-engine.ts @@ -7,6 +7,7 @@ import type { SandboxMessagingInputReference, } from "../../manifest"; import type { ManifestCompilerContext } from "../types"; +import { hashCredential } from "../../../security/credential-hash"; import { resolveSandboxNameTemplate } from "./template"; export function planCredentialBindings( @@ -16,6 +17,14 @@ export function planCredentialBindings( ): SandboxMessagingCredentialBindingPlan[] { return manifest.credentials.map((credential) => { const sourceInput = inputs.find((input) => input.inputId === credential.sourceInput); + const credentialAvailable = + sourceInput?.credentialAvailable === true || + context.credentialAvailability?.[credential.id] === true || + context.credentialAvailability?.[`${manifest.id}.${credential.id}`] === true; + + const envKey = sourceInput?.sourceEnv ?? credential.providerEnvKey; + const credentialHash = + credentialAvailable ? (hashCredential(process.env[envKey]) ?? undefined) : undefined; return { channelId: manifest.id, @@ -24,10 +33,8 @@ export function planCredentialBindings( providerName: resolveSandboxNameTemplate(credential.providerName, context.sandboxName), providerEnvKey: credential.providerEnvKey, placeholder: credential.placeholder, - credentialAvailable: - sourceInput?.credentialAvailable === true || - context.credentialAvailability?.[credential.id] === true || - context.credentialAvailability?.[`${manifest.id}.${credential.id}`] === true, + credentialAvailable, + ...(credentialHash !== undefined ? { credentialHash } : {}), }; }); } From b2117c86932977600e359e4a6ab7af155cd0814c Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 20:43:04 +0530 Subject: [PATCH 10/25] fix(messaging): address PR Review Advisor findings on conflict detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove legacy providerCredentialHashes fallback from resolveChannelHashesFromEntry — entries without a plan now return {} and fall through to unknown-token (conservative). Fix PROVIDER_SUFFIXES to collect all credential suffixes per manifest rather than only credentials[0], so multi-credential channels like Slack probe all their provider names during backfill (channel active if any present). Split 473-line conflict-detection.test.ts monolith into conflict-detection-plan.test.ts (plan helpers) and conflict-detection-entry.test.ts (detection functions) to stay under the monolith growth threshold. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/messaging-conflict.ts | 8 +- ...st.ts => conflict-detection-entry.test.ts} | 224 +++--------------- .../applier/conflict-detection-plan.test.ts | 214 +++++++++++++++++ .../messaging/applier/conflict-detection.ts | 44 ++-- 4 files changed, 270 insertions(+), 220 deletions(-) rename src/lib/messaging/applier/{conflict-detection.test.ts => conflict-detection-entry.test.ts} (53%) create mode 100644 src/lib/messaging/applier/conflict-detection-plan.test.ts diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index b7d5c271de..07622f166c 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -25,11 +25,11 @@ import { } from "./messaging/applier"; import { BUILT_IN_CHANNEL_MANIFESTS } from "./messaging/channels"; -const PROVIDER_SUFFIXES: Record = Object.fromEntries( +const PROVIDER_SUFFIXES: Record = Object.fromEntries( BUILT_IN_CHANNEL_MANIFESTS.flatMap((m) => { - const cred = m.credentials[0]; - if (!cred?.providerName) return []; - return [[m.id, cred.providerName.replace("{sandboxName}", "")]]; + const suffixes = m.credentials.map((c) => c.providerName.replace("{sandboxName}", "")); + if (suffixes.length === 0) return []; + return [[m.id, suffixes]]; }), ); diff --git a/src/lib/messaging/applier/conflict-detection.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts similarity index 53% rename from src/lib/messaging/applier/conflict-detection.test.ts rename to src/lib/messaging/applier/conflict-detection-entry.test.ts index dca7764190..6174a46b24 100644 --- a/src/lib/messaging/applier/conflict-detection.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -10,10 +10,7 @@ import { conflictReasonForRequest, detectAllOverlapsInEntries, findConflictsInEntries, - getActiveChannelIdsFromPlan, - getCredentialHashesFromPlan, hasStoredChannelInEntry, - planToConflictChannelRequests, type ConflictRegistryEntry, } from "./conflict-detection"; @@ -56,6 +53,20 @@ function tgChannel(active = true, disabled = false) { }; } +function slackChannel() { + return { + channelId: "slack" as const, + displayName: "Slack", + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }; +} + function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { return { channelId: "telegram", @@ -64,7 +75,7 @@ function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][nu providerName: "sb-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - credentialAvailable: hash !== undefined || true, + credentialAvailable: true, ...(hash !== undefined ? { credentialHash: hash } : {}), }; } @@ -85,7 +96,7 @@ function slackBindings(botHash?: string, appHash?: string) { channelId: "slack" as const, credentialId: "slackAppToken", sourceInput: "appToken", - providerName: "sb-slack-bridge", + providerName: "sb-slack-app", providerEnvKey: "SLACK_APP_TOKEN", placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", credentialAvailable: true, @@ -99,137 +110,6 @@ function planEntry(name: string, plan: SandboxMessagingPlan): ConflictRegistryEn return { name, messaging: state }; } -// --------------------------------------------------------------------------- -// getActiveChannelIdsFromPlan -// --------------------------------------------------------------------------- - -describe("getActiveChannelIdsFromPlan", () => { - it("returns active channel ids", () => { - const plan = makePlan("sb", { channels: [tgChannel(true, false)] }); - expect(getActiveChannelIdsFromPlan(plan)).toEqual(["telegram"]); - }); - - it("excludes channels in disabledChannels", () => { - const plan = makePlan("sb", { - disabledChannels: ["telegram"], - channels: [tgChannel(true, false)], - }); - expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); - }); - - it("excludes channels where channel.disabled is true (#plan-filter parity)", () => { - const plan = makePlan("sb", { channels: [tgChannel(false, true)] }); - expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); - }); - - it("excludes channels where channel.active is false (#plan-filter parity)", () => { - const plan = makePlan("sb", { channels: [tgChannel(false, false)] }); - expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// getCredentialHashesFromPlan -// --------------------------------------------------------------------------- - -describe("getCredentialHashesFromPlan", () => { - it("returns hashes keyed by providerEnvKey", () => { - const plan = makePlan("sb", { credentialBindings: [tgBinding("hash-x")] }); - expect(getCredentialHashesFromPlan(plan)).toEqual({ TELEGRAM_BOT_TOKEN: "hash-x" }); - }); - - it("scopes to a single channel when channelId is provided", () => { - const plan = makePlan("sb", { - credentialBindings: [tgBinding("hash-tg"), ...slackBindings("hash-bot", "hash-app")], - }); - expect(getCredentialHashesFromPlan(plan, "telegram")).toEqual({ - TELEGRAM_BOT_TOKEN: "hash-tg", - }); - expect(getCredentialHashesFromPlan(plan, "slack")).toEqual({ - SLACK_BOT_TOKEN: "hash-bot", - SLACK_APP_TOKEN: "hash-app", - }); - }); - - it("omits bindings without a credentialHash", () => { - const plan = makePlan("sb", { credentialBindings: [tgBinding()] }); - expect(getCredentialHashesFromPlan(plan)).toEqual({}); - }); -}); - -// --------------------------------------------------------------------------- -// planToConflictChannelRequests -// --------------------------------------------------------------------------- - -describe("planToConflictChannelRequests", () => { - it("returns one request per active channel that has a credential available", () => { - const plan = makePlan("sb", { - channels: [tgChannel()], - credentialBindings: [tgBinding("hash-tg")], - }); - expect(planToConflictChannelRequests(plan)).toEqual([ - { channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg" } }, - ]); - }); - - it("includes a channel with credentialAvailable=true but no hash (unknown-token fallback)", () => { - const binding = { ...tgBinding(), credentialAvailable: true }; - const plan = makePlan("sb", { credentialBindings: [binding] }); - const requests = planToConflictChannelRequests(plan); - expect(requests).toHaveLength(1); - expect(requests[0].channel).toBe("telegram"); - expect(requests[0].credentialHashes).toEqual({}); - }); - - it("groups multiple bindings for the same channel (Slack bot + app tokens)", () => { - const plan = makePlan("sb", { - credentialBindings: slackBindings("hash-bot", "hash-app"), - }); - expect(planToConflictChannelRequests(plan)).toEqual([ - { channel: "slack", credentialHashes: { SLACK_BOT_TOKEN: "hash-bot", SLACK_APP_TOKEN: "hash-app" } }, - ]); - }); - - it("skips bindings where credentialAvailable is false", () => { - const plan = makePlan("sb", { - credentialBindings: [{ ...tgBinding("hash-tg"), credentialAvailable: false }], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); - - it("skips channels in disabledChannels (bridge is paused)", () => { - const plan = makePlan("sb", { - disabledChannels: ["telegram"], - credentialBindings: [tgBinding("hash-tg")], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); - - it("WhatsApp — no-op: empty credentials produce no conflict requests (#4392)", () => { - // WhatsApp uses in-sandbox-qr pairing; it has no host-side token provider - // and therefore no credentialBindings. planToConflictChannelRequests must - // not emit a request for it, so it never participates in token-backed - // conflict detection or legacy provider probing. - const plan = makePlan("sb", { - channels: [ - { - channelId: "whatsapp", - displayName: "WhatsApp", - authMode: "in-sandbox-qr", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); -}); - // --------------------------------------------------------------------------- // hasStoredChannelInEntry // --------------------------------------------------------------------------- @@ -255,17 +135,14 @@ describe("hasStoredChannelInEntry", () => { }); // --------------------------------------------------------------------------- -// conflictReasonForRequest — channel-scoped hash comparison +// conflictReasonForRequest // --------------------------------------------------------------------------- -describe("conflictReasonForRequest (plan-backed entry)", () => { +describe("conflictReasonForRequest", () => { it("detects matching-token when same channel hash matches", () => { const entry = planEntry( "alice", - makePlan("alice", { - channels: [tgChannel()], - credentialBindings: [tgBinding("hash-a")], - }), + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), ); expect( conflictReasonForRequest(entry, { @@ -278,10 +155,7 @@ describe("conflictReasonForRequest (plan-backed entry)", () => { it("returns null when same channel hash differs", () => { const entry = planEntry( "alice", - makePlan("alice", { - channels: [tgChannel()], - credentialBindings: [tgBinding("hash-a")], - }), + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), ); expect( conflictReasonForRequest(entry, { @@ -291,17 +165,11 @@ describe("conflictReasonForRequest (plan-backed entry)", () => { ).toBeNull(); }); - it("does not create false positives from unrelated-channel hashes in the same entry", () => { - // alice has both Telegram (hash-tg-a) and Slack; bob checks Telegram with a - // different hash (hash-tg-b). Slack's keys must not pollute the comparison - // and cause a spurious unknown-token result. + it("does not produce false positives from unrelated-channel hashes", () => { const entry = planEntry( "alice", makePlan("alice", { - channels: [ - tgChannel(), - { channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }, - ], + channels: [tgChannel(), slackChannel()], credentialBindings: [tgBinding("hash-tg-a"), ...slackBindings("hash-slack")], }), ); @@ -313,34 +181,10 @@ describe("conflictReasonForRequest (plan-backed entry)", () => { ).toBeNull(); }); - it("falls back to legacy providerCredentialHashes when plan has no hashes for the channel", () => { - // During the migration window the plan may exist but carry no credentialHash yet. - // The function must fall back to the legacy field so safety is preserved. - const entry: ConflictRegistryEntry = { - name: "alice", - messaging: { - plan: makePlan("alice", { - channels: [tgChannel()], - credentialBindings: [tgBinding()], // no credentialHash - }), - }, - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-legacy" }, - }; - expect( - conflictReasonForRequest(entry, { - channel: "telegram", - credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-legacy" }, - }), - ).toBe("matching-token"); - }); - - it("returns unknown-token when plan has no hashes and no legacy hashes", () => { + it("returns unknown-token when plan has no hashes for the channel", () => { const entry = planEntry( "alice", - makePlan("alice", { - channels: [tgChannel()], - credentialBindings: [tgBinding()], // no credentialHash - }), + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding()] }), ); expect( conflictReasonForRequest(entry, { @@ -352,7 +196,7 @@ describe("conflictReasonForRequest (plan-backed entry)", () => { }); // --------------------------------------------------------------------------- -// conflictReasonForPair — channel-scoped hash comparison +// conflictReasonForPair // --------------------------------------------------------------------------- describe("conflictReasonForPair", () => { @@ -381,19 +225,17 @@ describe("conflictReasonForPair", () => { }); it("scopes comparison to the requested channel, ignoring other channels", () => { - // Both sandboxes have Telegram (different hashes) and Slack (same hash). - // Checking Telegram must NOT produce a conflict from the shared Slack hash. const alice = planEntry( "alice", makePlan("alice", { - channels: [tgChannel(), { channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + channels: [tgChannel(), slackChannel()], credentialBindings: [tgBinding("hash-tg-a"), ...slackBindings("hash-slack")], }), ); const bob = planEntry( "bob", makePlan("bob", { - channels: [tgChannel(), { channelId: "slack", displayName: "Slack", authMode: "token-paste", active: true, selected: true, configured: true, disabled: false, inputs: [], hooks: [] }], + channels: [tgChannel(), slackChannel()], credentialBindings: [tgBinding("hash-tg-b"), ...slackBindings("hash-slack")], }), ); @@ -403,11 +245,11 @@ describe("conflictReasonForPair", () => { }); // --------------------------------------------------------------------------- -// findConflictsInEntries / detectAllOverlapsInEntries — plan-backed entries +// findConflictsInEntries // --------------------------------------------------------------------------- -describe("findConflictsInEntries (plan-backed entries)", () => { - it("detects matching-token against a plan-only entry (no legacy messagingChannels)", () => { +describe("findConflictsInEntries", () => { + it("detects matching-token against a plan-only entry", () => { const alice = planEntry( "alice", makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), @@ -440,7 +282,11 @@ describe("findConflictsInEntries (plan-backed entries)", () => { }); }); -describe("detectAllOverlapsInEntries (plan-backed entries)", () => { +// --------------------------------------------------------------------------- +// detectAllOverlapsInEntries +// --------------------------------------------------------------------------- + +describe("detectAllOverlapsInEntries", () => { it("reports matching-token overlap between two plan-backed entries", () => { const alice = planEntry( "alice", diff --git a/src/lib/messaging/applier/conflict-detection-plan.test.ts b/src/lib/messaging/applier/conflict-detection-plan.test.ts new file mode 100644 index 0000000000..73a6f96281 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-plan.test.ts @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingPlan } from "../manifest"; +import { + getActiveChannelIdsFromPlan, + getCredentialHashesFromPlan, + planToConflictChannelRequests, +} from "./conflict-detection"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makePlan( + sandboxName: string, + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +function tgChannel(active = true, disabled = false) { + return { + channelId: "telegram" as const, + displayName: "Telegram", + authMode: "token-paste" as const, + active, + selected: true, + configured: true, + disabled, + inputs: [], + hooks: [], + }; +} + +function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { + return { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "sb-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + +function slackBindings(botHash?: string, appHash?: string) { + return [ + { + channelId: "slack" as const, + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "sb-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + ...(botHash ? { credentialHash: botHash } : {}), + }, + { + channelId: "slack" as const, + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: "sb-slack-app", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", + credentialAvailable: true, + ...(appHash ? { credentialHash: appHash } : {}), + }, + ]; +} + +// --------------------------------------------------------------------------- +// getActiveChannelIdsFromPlan +// --------------------------------------------------------------------------- + +describe("getActiveChannelIdsFromPlan", () => { + it("returns active channel ids", () => { + const plan = makePlan("sb", { channels: [tgChannel(true, false)] }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual(["telegram"]); + }); + + it("excludes channels in disabledChannels", () => { + const plan = makePlan("sb", { + disabledChannels: ["telegram"], + channels: [tgChannel(true, false)], + }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + }); + + it("excludes channels where channel.disabled is true", () => { + const plan = makePlan("sb", { channels: [tgChannel(false, true)] }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + }); + + it("excludes channels where channel.active is false", () => { + const plan = makePlan("sb", { channels: [tgChannel(false, false)] }); + expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// getCredentialHashesFromPlan +// --------------------------------------------------------------------------- + +describe("getCredentialHashesFromPlan", () => { + it("returns hashes keyed by providerEnvKey", () => { + const plan = makePlan("sb", { credentialBindings: [tgBinding("hash-x")] }); + expect(getCredentialHashesFromPlan(plan)).toEqual({ TELEGRAM_BOT_TOKEN: "hash-x" }); + }); + + it("scopes to a single channel when channelId is provided", () => { + const plan = makePlan("sb", { + credentialBindings: [tgBinding("hash-tg"), ...slackBindings("hash-bot", "hash-app")], + }); + expect(getCredentialHashesFromPlan(plan, "telegram")).toEqual({ + TELEGRAM_BOT_TOKEN: "hash-tg", + }); + expect(getCredentialHashesFromPlan(plan, "slack")).toEqual({ + SLACK_BOT_TOKEN: "hash-bot", + SLACK_APP_TOKEN: "hash-app", + }); + }); + + it("omits bindings without a credentialHash", () => { + const plan = makePlan("sb", { credentialBindings: [tgBinding()] }); + expect(getCredentialHashesFromPlan(plan)).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// planToConflictChannelRequests +// --------------------------------------------------------------------------- + +describe("planToConflictChannelRequests", () => { + it("returns one request per active channel that has a credential available", () => { + const plan = makePlan("sb", { + channels: [tgChannel()], + credentialBindings: [tgBinding("hash-tg")], + }); + expect(planToConflictChannelRequests(plan)).toEqual([ + { channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg" } }, + ]); + }); + + it("includes a channel with credentialAvailable=true but no hash (unknown-token fallback)", () => { + const plan = makePlan("sb", { credentialBindings: [tgBinding()] }); + const requests = planToConflictChannelRequests(plan); + expect(requests).toHaveLength(1); + expect(requests[0].channel).toBe("telegram"); + expect(requests[0].credentialHashes).toEqual({}); + }); + + it("groups multiple bindings for the same channel (Slack bot + app tokens)", () => { + const plan = makePlan("sb", { + credentialBindings: slackBindings("hash-bot", "hash-app"), + }); + expect(planToConflictChannelRequests(plan)).toEqual([ + { channel: "slack", credentialHashes: { SLACK_BOT_TOKEN: "hash-bot", SLACK_APP_TOKEN: "hash-app" } }, + ]); + }); + + it("skips bindings where credentialAvailable is false", () => { + const plan = makePlan("sb", { + credentialBindings: [{ ...tgBinding("hash-tg"), credentialAvailable: false }], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); + + it("skips channels in disabledChannels", () => { + const plan = makePlan("sb", { + disabledChannels: ["telegram"], + credentialBindings: [tgBinding("hash-tg")], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); + + it("WhatsApp — no-op: empty credentials produce no conflict requests", () => { + const plan = makePlan("sb", { + channels: [ + { + channelId: "whatsapp", + displayName: "WhatsApp", + authMode: "in-sandbox-qr", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + credentialBindings: [], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); +}); diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index e5d4966a6b..f43aed8d0e 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -166,27 +166,17 @@ export function resolveActiveChannelsFromEntry( /** * Return credential hashes scoped to `channelId` for a registry entry. - * - * For plan-backed entries the lookup is channel-scoped: only bindings for the - * requested channel are considered. When the plan exists but carries no hashes - * for the channel (compiler migration in flight), the function falls back to - * the legacy `providerCredentialHashes` flat field so no safety coverage is - * lost during the transition. - * - * For legacy entries without a plan the entire `providerCredentialHashes` - * object is returned unchanged (same behavior as before this architecture). + * Only plan-backed entries are considered; entries without a plan return `{}` + * which causes callers to fall through to "unknown-token" (conservative). */ function resolveChannelHashesFromEntry( entry: ConflictRegistryEntry, channelId: string, ): Record { if (entry.messaging?.plan) { - const planHashes = getCredentialHashesFromPlan(entry.messaging.plan, channelId); - if (Object.keys(planHashes).length > 0) return planHashes; - // Plan exists but no hashes yet for this channel — fall back to legacy - // field so matching-token detection is not silently downgraded. + return getCredentialHashesFromPlan(entry.messaging.plan, channelId); } - return (entry.providerCredentialHashes as Record) ?? {}; + return {}; } // --------------------------------------------------------------------------- @@ -348,26 +338,26 @@ export function backfillLegacyEntryChannels( entries: readonly ConflictRegistryEntry[], probe: MessagingConflictProbe, updateEntry: (name: string, channels: string[]) => void, - providerSuffixes: Record, + providerSuffixes: Record, ): void { for (const entry of entries) { if (Array.isArray(entry.messagingChannels)) continue; const discovered: string[] = []; let probeFailed = false; for (const channel of Object.keys(providerSuffixes)) { - const providerName = `${entry.name}${providerSuffixes[channel]}`; - let state: ProbeResult; - try { - state = probe.providerExists(providerName); - } catch { - state = "error"; - } - if (state === "present") { - discovered.push(channel); - } else if (state === "error") { - probeFailed = true; - break; + let channelPresent = false; + for (const suffix of providerSuffixes[channel]) { + let state: ProbeResult; + try { + state = probe.providerExists(`${entry.name}${suffix}`); + } catch { + state = "error"; + } + if (state === "present") { channelPresent = true; break; } + if (state === "error") { probeFailed = true; break; } } + if (probeFailed) break; + if (channelPresent) discovered.push(channel); } if (!probeFailed) { updateEntry(entry.name, discovered); From b545d0aba324eed1e7ea384e779b44b998d7d6f0 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 20:47:05 +0530 Subject: [PATCH 11/25] fix(messaging): validate env plan sandbox identity and update stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject the env-staged plan when its sandboxName does not match the current sandbox being created, preventing a stale plan from a prior run from gating or bypassing conflict detection for a different sandbox. Update stale comments that described the credential-binding engine as not yet emitting credentialHash — it does now. Rephrase to reflect the remaining no-hash case (registry-only resume without a compiler re-run). Co-Authored-By: Claude Sonnet 4.6 --- src/lib/messaging/applier/conflict-detection.ts | 9 ++++----- src/lib/onboard.ts | 10 +++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index f43aed8d0e..ad0300d000 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -120,11 +120,10 @@ export function getCredentialHashesFromPlan( * - bindings where the credential is not available (`credentialAvailable` * false) — e.g. WhatsApp, which has no host-side token provider * - * When a binding has no `credentialHash` yet (the compiler populates this - * field once the credential-binding engine is updated), the channel is still - * included with an empty `credentialHashes` map, which falls through to - * `"unknown-token"` conservative detection. This preserves the safety - * behaviour while the compiler migration is in flight. + * When a binding has no `credentialHash` (e.g. a registry-only resume that + * did not re-run the compiler), the channel is still included with an empty + * `credentialHashes` map, which falls through to `"unknown-token"` conservative + * detection. */ export function planToConflictChannelRequests(plan: SandboxMessagingPlan): ConflictRequest[] { const disabledSet = new Set(plan.disabledChannels); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0f6bc45921..e39f4e92a9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2774,11 +2774,11 @@ async function createSandbox( // The compiled plan (written to env by setupMessagingChannels) is the source // of truth: credential hashes and active-channel membership are read from // plan.credentialBindings rather than from MESSAGING_CHANNELS constants. - const currentPlan = readMessagingPlanFromEnv(); - // Gate on credentialAvailable (set by the compiler) rather than credentialHash - // (populated later once the credential-binding engine is updated). Using the - // hash field alone would silently skip conflict detection for all current - // onboarding paths because the compiler does not yet emit it. + // Validate sandbox identity before trusting the env plan: a stale plan from a + // prior run of a different sandbox must not gate or bypass conflict detection + // for the current sandbox creation. + const envPlan = readMessagingPlanFromEnv(); + const currentPlan = envPlan?.sandboxName === sandboxName ? envPlan : null; const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; if (hasPlanCredentials) { const { backfillMessagingChannels, findChannelConflictsFromPlan, createMessagingConflictProbe } = From 7bee701911b943a91f79cf8e4845a51f9a836c5e Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 21:07:32 +0530 Subject: [PATCH 12/25] fix(messaging): restore legacy hash fallback and fix disabled-channel test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the providerCredentialHashes fallback in resolveChannelHashesFromEntry for entries whose plan carries no channel hashes (e.g. registry-only resume without a compiler re-run), preserving safety coverage during migration. Fix tgChannel(false, true) → tgChannel(true, true) in disabled-channel test cases so assertions isolate plan.disabledChannels as the only gate rather than passing via the inactive-channel path. Co-Authored-By: Claude Sonnet 4.6 --- .../applier/conflict-detection-entry.test.ts | 6 +++--- .../applier/conflict-detection-plan.test.ts | 2 +- src/lib/messaging/applier/conflict-detection.ts | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/messaging/applier/conflict-detection-entry.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts index 6174a46b24..c2aab2bc88 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -123,7 +123,7 @@ describe("hasStoredChannelInEntry", () => { it("returns false when channel is in plan.disabledChannels", () => { const entry = planEntry( "sb", - makePlan("sb", { disabledChannels: ["telegram"], channels: [tgChannel(false, true)] }), + makePlan("sb", { disabledChannels: ["telegram"], channels: [tgChannel(true, true)] }), ); expect(hasStoredChannelInEntry(entry, "telegram")).toBe(false); }); @@ -268,7 +268,7 @@ describe("findConflictsInEntries", () => { "alice", makePlan("alice", { disabledChannels: ["telegram"], - channels: [tgChannel(false, true)], + channels: [tgChannel(true, true)], credentialBindings: [tgBinding("hash-a")], }), ); @@ -306,7 +306,7 @@ describe("detectAllOverlapsInEntries", () => { "alice", makePlan("alice", { disabledChannels: ["telegram"], - channels: [tgChannel(false, true)], + channels: [tgChannel(true, true)], credentialBindings: [tgBinding("hash-a")], }), ); diff --git a/src/lib/messaging/applier/conflict-detection-plan.test.ts b/src/lib/messaging/applier/conflict-detection-plan.test.ts index 73a6f96281..e109d19742 100644 --- a/src/lib/messaging/applier/conflict-detection-plan.test.ts +++ b/src/lib/messaging/applier/conflict-detection-plan.test.ts @@ -106,7 +106,7 @@ describe("getActiveChannelIdsFromPlan", () => { }); it("excludes channels where channel.disabled is true", () => { - const plan = makePlan("sb", { channels: [tgChannel(false, true)] }); + const plan = makePlan("sb", { channels: [tgChannel(true, true)] }); expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); }); diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index ad0300d000..7d8ef42c74 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -165,17 +165,25 @@ export function resolveActiveChannelsFromEntry( /** * Return credential hashes scoped to `channelId` for a registry entry. - * Only plan-backed entries are considered; entries without a plan return `{}` - * which causes callers to fall through to "unknown-token" (conservative). + * + * For plan-backed entries the lookup is channel-scoped. When the plan exists + * but carries no hashes for the channel (e.g. a registry-only resume that did + * not re-run the compiler), falls back to the legacy `providerCredentialHashes` + * flat field so safety coverage is preserved. + * + * For legacy entries without a plan the entire `providerCredentialHashes` + * object is returned, which may include keys from other channels — callers + * that need channel-scoped comparison should prefer plan-backed entries. */ function resolveChannelHashesFromEntry( entry: ConflictRegistryEntry, channelId: string, ): Record { if (entry.messaging?.plan) { - return getCredentialHashesFromPlan(entry.messaging.plan, channelId); + const planHashes = getCredentialHashesFromPlan(entry.messaging.plan, channelId); + if (Object.keys(planHashes).length > 0) return planHashes; } - return {}; + return (entry.providerCredentialHashes as Record) ?? {}; } // --------------------------------------------------------------------------- From 40142b9db1aec4b44817e77a85619346bfd17cdf Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 21:32:08 +0530 Subject: [PATCH 13/25] fix(messaging): scope legacy hashes by channel and intersect plan bindings with active channels Address PR Review Advisor findings on phase-4a conflict detection: - resolveChannelHashesFromEntry now filters legacy providerCredentialHashes to only the providerEnvKey values declared for the requested channel in BUILT_IN_CHANNEL_MANIFESTS, preventing unrelated Slack hashes from producing false Telegram conflicts and vice versa. Unknown channels retain the full map. - planToConflictChannelRequests now intersects credentialBindings with getActiveChannelIdsFromPlan so stale bindings for inactive or absent channels cannot generate false conflict requests. - Fix misleading JSDoc on findChannelConflictsFromPlan: available bindings with no credentialHash are included (not excluded) and fall through to conservative unknown-token detection. - Add regression tests: cross-channel legacy hash scoping, active-channel binding intersection (absent channel, active=false), and slackChannel fixture for plan test file. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/messaging-conflict.ts | 6 +- .../applier/conflict-detection-entry.test.ts | 61 +++++++++++++++++++ .../applier/conflict-detection-plan.test.ts | 32 +++++++++- .../messaging/applier/conflict-detection.ts | 40 +++++++++--- 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index 07622f166c..affa49e990 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -105,8 +105,10 @@ export function findAllOverlaps(registry: ConflictRegistry): Array<{ * list from a compiled `SandboxMessagingPlan` instead of requiring the caller * to build credential hashes from raw channel constants. * - * Disabled channels and bindings without a credential hash are excluded - * automatically by `planToConflictChannelRequests`. + * Disabled channels and bindings where the credential is unavailable are excluded + * automatically by `planToConflictChannelRequests`. Bindings with `credentialAvailable` + * but no `credentialHash` are included and fall through to conservative + * `"unknown-token"` detection. */ export function findChannelConflictsFromPlan( currentSandbox: string | null, diff --git a/src/lib/messaging/applier/conflict-detection-entry.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts index c2aab2bc88..275483257b 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -317,3 +317,64 @@ describe("detectAllOverlapsInEntries", () => { expect(detectAllOverlapsInEntries([alice, bob])).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// Legacy-entry cross-channel hash scoping +// --------------------------------------------------------------------------- + +describe("legacy entry cross-channel hash scoping", () => { + it("conflictReasonForRequest — does not produce unknown-token from unrelated Slack keys on a Telegram request", () => { + // A legacy entry with both Telegram and Slack hashes stored in the flat + // providerCredentialHashes map must not make Slack keys visible when the + // request is for Telegram only. + const legacy: ConflictRegistryEntry = { + name: "alice", + messagingChannels: ["telegram", "slack"], + providerCredentialHashes: { + TELEGRAM_BOT_TOKEN: "hash-tg-a", + SLACK_BOT_TOKEN: "hash-slack", + SLACK_APP_TOKEN: "hash-slack-app", + }, + }; + expect( + conflictReasonForRequest(legacy, { + channel: "telegram", + credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-b" }, + }), + ).toBeNull(); // distinct Telegram tokens — Slack keys must not inflate this to unknown-token + }); + + it("conflictReasonForRequest — still detects matching-token for the correct channel", () => { + const legacy: ConflictRegistryEntry = { + name: "alice", + messagingChannels: ["telegram", "slack"], + providerCredentialHashes: { + TELEGRAM_BOT_TOKEN: "hash-tg-shared", + SLACK_BOT_TOKEN: "hash-slack", + }, + }; + expect( + conflictReasonForRequest(legacy, { + channel: "telegram", + credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-shared" }, + }), + ).toBe("matching-token"); + }); + + it("conflictReasonForPair — does not report matching-token Telegram overlap from matching Slack hashes", () => { + const alice: ConflictRegistryEntry = { + name: "alice", + messagingChannels: ["telegram", "slack"], + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-a", SLACK_BOT_TOKEN: "shared-slack" }, + }; + const bob: ConflictRegistryEntry = { + name: "bob", + messagingChannels: ["telegram", "slack"], + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-b", SLACK_BOT_TOKEN: "shared-slack" }, + }; + // Telegram tokens differ — Slack match must not bleed into Telegram comparison + expect(conflictReasonForPair("telegram", alice, bob)).toBeNull(); + // Slack comparison should still correctly flag the shared Slack hash + expect(conflictReasonForPair("slack", alice, bob)).toBe("matching-token"); + }); +}); diff --git a/src/lib/messaging/applier/conflict-detection-plan.test.ts b/src/lib/messaging/applier/conflict-detection-plan.test.ts index e109d19742..0bbc1c06b2 100644 --- a/src/lib/messaging/applier/conflict-detection-plan.test.ts +++ b/src/lib/messaging/applier/conflict-detection-plan.test.ts @@ -62,6 +62,20 @@ function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][nu }; } +function slackChannel() { + return { + channelId: "slack" as const, + displayName: "Slack", + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }; +} + function slackBindings(botHash?: string, appHash?: string) { return [ { @@ -161,7 +175,7 @@ describe("planToConflictChannelRequests", () => { }); it("includes a channel with credentialAvailable=true but no hash (unknown-token fallback)", () => { - const plan = makePlan("sb", { credentialBindings: [tgBinding()] }); + const plan = makePlan("sb", { channels: [tgChannel()], credentialBindings: [tgBinding()] }); const requests = planToConflictChannelRequests(plan); expect(requests).toHaveLength(1); expect(requests[0].channel).toBe("telegram"); @@ -170,6 +184,7 @@ describe("planToConflictChannelRequests", () => { it("groups multiple bindings for the same channel (Slack bot + app tokens)", () => { const plan = makePlan("sb", { + channels: [slackChannel()], credentialBindings: slackBindings("hash-bot", "hash-app"), }); expect(planToConflictChannelRequests(plan)).toEqual([ @@ -179,6 +194,7 @@ describe("planToConflictChannelRequests", () => { it("skips bindings where credentialAvailable is false", () => { const plan = makePlan("sb", { + channels: [tgChannel()], credentialBindings: [{ ...tgBinding("hash-tg"), credentialAvailable: false }], }); expect(planToConflictChannelRequests(plan)).toEqual([]); @@ -187,6 +203,20 @@ describe("planToConflictChannelRequests", () => { it("skips channels in disabledChannels", () => { const plan = makePlan("sb", { disabledChannels: ["telegram"], + channels: [tgChannel(true, true)], + credentialBindings: [tgBinding("hash-tg")], + }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); + + it("skips credentialAvailable bindings whose channel is absent from plan.channels", () => { + const plan = makePlan("sb", { credentialBindings: [tgBinding("hash-tg")] }); + expect(planToConflictChannelRequests(plan)).toEqual([]); + }); + + it("skips credentialAvailable bindings whose channel.active is false", () => { + const plan = makePlan("sb", { + channels: [tgChannel(false, false)], credentialBindings: [tgBinding("hash-tg")], }); expect(planToConflictChannelRequests(plan)).toEqual([]); diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index 7d8ef42c74..774f7d7841 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -2,6 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import type { SandboxMessagingPlan } from "../manifest"; +import { BUILT_IN_CHANNEL_MANIFESTS } from "../channels"; + +// Map channelId → providerEnvKey values declared in built-in manifests. +// Used to scope legacy providerCredentialHashes to a single channel so hashes +// from other channels on the same sandbox don't produce cross-channel conflicts. +const CHANNEL_CREDENTIAL_ENV_KEYS: Readonly> = + Object.fromEntries( + BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]), + ); // --------------------------------------------------------------------------- // Types @@ -126,11 +135,11 @@ export function getCredentialHashesFromPlan( * detection. */ export function planToConflictChannelRequests(plan: SandboxMessagingPlan): ConflictRequest[] { - const disabledSet = new Set(plan.disabledChannels); + const activeChannelIds = new Set(getActiveChannelIdsFromPlan(plan)); const byChannel = new Map>(); for (const binding of plan.credentialBindings) { - if (disabledSet.has(binding.channelId) || !binding.credentialAvailable) continue; + if (!activeChannelIds.has(binding.channelId) || !binding.credentialAvailable) continue; const hashes = byChannel.get(binding.channelId) ?? {}; if (binding.credentialHash) hashes[binding.providerEnvKey] = binding.credentialHash; byChannel.set(binding.channelId, hashes); @@ -166,14 +175,18 @@ export function resolveActiveChannelsFromEntry( /** * Return credential hashes scoped to `channelId` for a registry entry. * - * For plan-backed entries the lookup is channel-scoped. When the plan exists - * but carries no hashes for the channel (e.g. a registry-only resume that did - * not re-run the compiler), falls back to the legacy `providerCredentialHashes` - * flat field so safety coverage is preserved. + * For plan-backed entries the lookup is channel-scoped via `getCredentialHashesFromPlan`. + * When the plan exists but carries no hashes for the channel (e.g. a registry-only + * resume that did not re-run the compiler), falls back to the legacy + * `providerCredentialHashes` flat field so safety coverage is preserved. * - * For legacy entries without a plan the entire `providerCredentialHashes` - * object is returned, which may include keys from other channels — callers - * that need channel-scoped comparison should prefer plan-backed entries. + * For legacy entries without a plan, `providerCredentialHashes` is filtered to + * only the env keys that are declared for `channelId` in the built-in manifests. + * This prevents hashes from other channels on the same sandbox from producing + * cross-channel false positives (e.g. a Slack hash matching in a Telegram comparison). + * When no manifest-declared keys are found in the stored hashes the result is an + * empty map, which falls through to `"unknown-token"` conservative detection in + * the callers. */ function resolveChannelHashesFromEntry( entry: ConflictRegistryEntry, @@ -183,7 +196,14 @@ function resolveChannelHashesFromEntry( const planHashes = getCredentialHashesFromPlan(entry.messaging.plan, channelId); if (Object.keys(planHashes).length > 0) return planHashes; } - return (entry.providerCredentialHashes as Record) ?? {}; + const allHashes = (entry.providerCredentialHashes as Record) ?? {}; + const knownKeys = CHANNEL_CREDENTIAL_ENV_KEYS[channelId]; + if (!knownKeys || knownKeys.length === 0) return allHashes; + const scoped: Record = {}; + for (const key of knownKeys) { + if (allHashes[key]) scoped[key] = allHashes[key]; + } + return scoped; } // --------------------------------------------------------------------------- From 50633fe889f023fb5adf004afd24de1fefde0d51 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 21:49:48 +0530 Subject: [PATCH 14/25] fix(messaging): use manifest keys for multi-credential conflict comparison and add no-plan fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address second round of PR Review Advisor findings: - conflictReasonForRequest and conflictReasonForPair now use manifest-declared CHANNEL_CREDENTIAL_ENV_KEYS as the primary comparison set. For multi-credential channels like Slack (SLACK_BOT_TOKEN + SLACK_APP_TOKEN), a differing bot token with a missing app token previously returned null; it now returns unknown-token because the missing manifest key marks the comparison as incomplete. - onboard.ts conflict gate: when hasPlanCredentials is false but token-backed channels were selected (enabledChannels contains non-QR channels), fall back to findChannelConflicts with just the channel names to preserve conservative unknown-token warnings even when the compiled plan has no credential data (e.g. credential-store-backed availability or stale env plan). - policy-channel.ts: add explicit comment scoping the add-channel path as a known limitation — no compiled plan is available at channels-add time, so findChannelConflicts is used with caller-built hashes; plan-driven migration is follow-up work. - Tests: four Slack partial-hash suppression cases covering the false-negative regression (conflictReasonForRequest with missing app token, both differ, conflictReasonForPair with missing app token from both sides, both differ). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/actions/sandbox/policy-channel.ts | 5 + .../applier/conflict-detection-entry.test.ts | 105 ++++++++++++++++++ .../messaging/applier/conflict-detection.ts | 28 +++-- src/lib/onboard.ts | 20 +++- 4 files changed, 146 insertions(+), 12 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 3cfbeec6b7..e4458fa2df 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -407,6 +407,11 @@ async function checkChannelAddConflict( } if (Object.keys(credentialHashes).length === 0) return true; + // `channels add` does not have a compiled SandboxMessagingPlan in env — the + // plan is only written during full onboarding (createSandbox). Hashes are + // built directly from the acquired tokens keyed by providerEnvKey, which is + // equivalent to what planToConflictChannelRequests produces from bindings. + // Migrating this path to a plan-driven approach is tracked as follow-up work. const { backfillMessagingChannels, findChannelConflicts } = require("../../messaging-conflict") as typeof import("../../messaging-conflict"); diff --git a/src/lib/messaging/applier/conflict-detection-entry.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts index 275483257b..44f3faebd2 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -378,3 +378,108 @@ describe("legacy entry cross-channel hash scoping", () => { expect(conflictReasonForPair("slack", alice, bob)).toBe("matching-token"); }); }); + +// --------------------------------------------------------------------------- +// Multi-credential channel partial-hash suppression (Slack SLACK_BOT_TOKEN + +// SLACK_APP_TOKEN). Both manifest keys are required; a differing bot token +// with a missing app token must NOT return null — it must return unknown-token. +// --------------------------------------------------------------------------- + +describe("multi-credential channel partial hash suppression", () => { + it("conflictReasonForRequest — returns unknown-token when Slack bot tokens differ but app token is missing from stored entry", () => { + // stored: only SLACK_BOT_TOKEN; missing SLACK_APP_TOKEN + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [ + { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "alice-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash-bot-a", + }, + // No SLACK_APP_TOKEN binding + ], + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "slack", + credentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-x" }, + }), + ).toBe("unknown-token"); // bot tokens differ, but app token unknown → conservative + }); + + it("conflictReasonForRequest — returns null when both Slack tokens are present and both differ", () => { + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [ + { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "alice-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash-bot-a", + }, + { + channelId: "slack", + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: "alice-slack-app", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", + credentialAvailable: true, + credentialHash: "hash-app-a", + }, + ], + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "slack", + credentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-b" }, + }), + ).toBeNull(); + }); + + it("conflictReasonForPair — returns unknown-token when Slack bot tokens differ but app token is absent from both", () => { + // Both entries have only SLACK_BOT_TOKEN; without manifest keys the union + // would be ["SLACK_BOT_TOKEN"], bot tokens differ → null. With manifest keys + // SLACK_APP_TOKEN is also required → unknown-token. + const alice: ConflictRegistryEntry = { + name: "alice", + messagingChannels: ["slack"], + providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-a" }, + }; + const bob: ConflictRegistryEntry = { + name: "bob", + messagingChannels: ["slack"], + providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b" }, + }; + expect(conflictReasonForPair("slack", alice, bob)).toBe("unknown-token"); + }); + + it("conflictReasonForPair — returns null when both Slack tokens are present and both differ", () => { + const alice: ConflictRegistryEntry = { + name: "alice", + messagingChannels: ["slack"], + providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-a", SLACK_APP_TOKEN: "hash-app-a" }, + }; + const bob: ConflictRegistryEntry = { + name: "bob", + messagingChannels: ["slack"], + providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-b" }, + }; + expect(conflictReasonForPair("slack", alice, bob)).toBeNull(); + }); +}); diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index 774f7d7841..db0b1ffa31 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -226,10 +226,11 @@ export function hasStoredChannelInEntry( * Determine the conflict reason between `entry`'s stored state and a new * channel request, or `null` if there is no conflict. * - * Hash comparison is scoped to the requested channel so that credentials - * from other channels on the same sandbox do not produce false positives. - * When no hashes are available the comparison falls back to "unknown-token" - * (conservative: warn even without definitive proof of sharing). + * Comparison keys are taken from manifest-declared credentials for the channel + * so that a missing hash for one of multiple required credentials (e.g. Slack's + * SLACK_APP_TOKEN when only SLACK_BOT_TOKEN differs) conservatively marks the + * result as "unknown-token" rather than silently returning null. Falls back to + * the union of present stored/requested keys for channels not in the manifest. */ export function conflictReasonForRequest( entry: ConflictRegistryEntry, @@ -238,10 +239,13 @@ export function conflictReasonForRequest( if (!hasStoredChannelInEntry(entry, request.channel)) return null; const requestedHashes = request.credentialHashes ?? {}; const storedHashes = resolveChannelHashesFromEntry(entry, request.channel); + const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[request.channel]; const keys = - Object.keys(storedHashes).length > 0 - ? Object.keys(storedHashes) - : Object.keys(requestedHashes); + manifestKeys && manifestKeys.length > 0 + ? [...manifestKeys] + : Object.keys(storedHashes).length > 0 + ? Object.keys(storedHashes) + : Object.keys(requestedHashes); if (keys.length === 0) return "unknown-token"; let sawUnknown = false; @@ -262,7 +266,9 @@ export function conflictReasonForRequest( * or `null` if there is no conflict. Returns each pair at most once (the * caller is responsible for ordered iteration). * - * Hash comparison is scoped to `channel` for plan-backed entries. + * Comparison keys are taken from manifest-declared credentials for the channel + * so that a missing hash on either side conservatively produces "unknown-token" + * rather than null for multi-credential channels like Slack. */ export function conflictReasonForPair( channel: string, @@ -274,7 +280,11 @@ export function conflictReasonForPair( } const lh = resolveChannelHashesFromEntry(left, channel); const rh = resolveChannelHashesFromEntry(right, channel); - const keys = [...new Set([...Object.keys(lh), ...Object.keys(rh)])]; + const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[channel]; + const keys = + manifestKeys && manifestKeys.length > 0 + ? [...manifestKeys] + : [...new Set([...Object.keys(lh), ...Object.keys(rh)])]; if (keys.length === 0) return "unknown-token"; let sawUnknown = false; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e39f4e92a9..3ca1ed2756 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2780,8 +2780,20 @@ async function createSandbox( const envPlan = readMessagingPlanFromEnv(); const currentPlan = envPlan?.sandboxName === sandboxName ? envPlan : null; const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; - if (hasPlanCredentials) { - const { backfillMessagingChannels, findChannelConflictsFromPlan, createMessagingConflictProbe } = + + // Fallback: if no plan credentials are available (e.g. credential-store-backed + // availability, all channels QR-paired, or stale/missing env plan) but token-backed + // channels were selected, still run a channel-name-only overlap check so we at + // minimum emit conservative unknown-token warnings for any channel conflict. + const selectedTokenChannels = Array.isArray(enabledChannels) + ? enabledChannels.filter((name) => { + const def = MESSAGING_CHANNELS.find((c) => c.name === name); + return def ? getChannelTokenKeys(def).length > 0 : false; + }) + : []; + + if (hasPlanCredentials || selectedTokenChannels.length > 0) { + const { backfillMessagingChannels, findChannelConflictsFromPlan, findChannelConflicts, createMessagingConflictProbe } = require("./messaging-conflict") as typeof import("./messaging-conflict"); const probe = createMessagingConflictProbe({ checkGatewayLiveness: () => @@ -2789,7 +2801,9 @@ async function createSandbox( providerExists: (name) => providerExistsInGateway(name), }); backfillMessagingChannels(registry, probe); - const conflicts = findChannelConflictsFromPlan(sandboxName, currentPlan!, registry); + const conflicts = hasPlanCredentials + ? findChannelConflictsFromPlan(sandboxName, currentPlan!, registry) + : findChannelConflicts(sandboxName, selectedTokenChannels, registry); if (conflicts.length > 0) { for (const { channel, sandbox, reason } of conflicts) { const detail = From 2e6f1e3efaee24ea13dcfd1b95c4af34fe96e959 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 22:07:33 +0530 Subject: [PATCH 15/25] fix(messaging): close plan coverage gap and migrate add-channel to manifest-driven hashing Address third round of PR Review Advisor findings: - messaging-conflict.ts: add findChannelConflictsForOnboarding() that unions the plan-based hash-precise check with a channel-name-only fallback for any selectedTokenChannels not covered by the plan's credentialAvailable bindings. This ensures a partial or stale same-sandbox plan cannot silently skip channels it omits. onboard.ts now delegates to this single function rather than inlining the union logic. - onboard.ts conflict gate: replace the multi-branch plan/fallback block with a single call to findChannelConflictsForOnboarding(sandboxName, currentPlan, selectedTokenChannels, registry), keeping all conflict logic in messaging-conflict.ts. - policy-channel.ts checkChannelAddConflict: replace raw acquired-map iteration with manifest-driven hash building via createBuiltInChannelManifestRegistry(). Iterates channelManifest.credentials[].providerEnvKey to build credentialHashes, mirroring planToConflictChannelRequests without requiring a compiled plan. QR-only channels (WhatsApp: credentials=[]) exit early with no conflict possible. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/actions/sandbox/policy-channel.ts | 24 +++++++-------- src/lib/messaging-conflict.ts | 37 +++++++++++++++++++++++ src/lib/onboard.ts | 16 +++++----- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index e4458fa2df..7134794b06 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -396,22 +396,22 @@ async function checkChannelAddConflict( acquired: Record, force: boolean, ): Promise { - // QR-paired / tokenless adds have empty `acquired` and no host-side - // credential to hash. Skip — there is no credential to collide on, and - // findChannelConflicts with empty credentialHashes would only ever report - // "unknown-token" noise against every other sandbox holding the channel. + // Build credential hashes from the manifest's declared providerEnvKey values. + // This scopes the lookup to the channel's known credential keys, mirroring + // what planToConflictChannelRequests() produces from bindings. QR-only + // channels (e.g. WhatsApp) have no manifest credentials → early exit with no + // conflict possible. Unknown channelName → also exits early. + const channelManifest = createBuiltInChannelManifestRegistry().list().find((m) => m.id === channelName); + if (!channelManifest || channelManifest.credentials.length === 0) return true; + const credentialHashes: Record = {}; - for (const [envKey, token] of Object.entries(acquired)) { - const hash = hashCredential(token); - if (hash) credentialHashes[envKey] = hash; + for (const cred of channelManifest.credentials) { + const token = acquired[cred.providerEnvKey]; + const hash = token ? hashCredential(token) : null; + if (hash) credentialHashes[cred.providerEnvKey] = hash; } if (Object.keys(credentialHashes).length === 0) return true; - // `channels add` does not have a compiled SandboxMessagingPlan in env — the - // plan is only written during full onboarding (createSandbox). Hashes are - // built directly from the acquired tokens keyed by providerEnvKey, which is - // equivalent to what planToConflictChannelRequests produces from bindings. - // Migrating this path to a plan-driven approach is tracked as follow-up work. const { backfillMessagingChannels, findChannelConflicts } = require("../../messaging-conflict") as typeof import("../../messaging-conflict"); diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index affa49e990..765c2a384e 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -117,3 +117,40 @@ export function findChannelConflictsFromPlan( ): ConflictMatch[] { return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); } + +/** + * Onboarding conflict check: runs the plan-based hash-precise check for + * channels represented in `plan`, plus a conservative channel-name-only check + * for any `selectedTokenChannels` NOT covered by the plan. + * + * Two paths are always unioned so a partial or stale plan cannot silently skip + * selected token-backed channels that it omits. Callers pass `null` for `plan` + * when no env plan is available; the fallback then covers all selected channels. + */ +export function findChannelConflictsForOnboarding( + currentSandbox: string | null, + plan: SandboxMessagingPlan | null, + selectedTokenChannels: string[], + registry: ConflictRegistry, +): ConflictMatch[] { + const planConflicts = plan + ? findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry) + : []; + + const planCoveredIds = new Set( + plan + ? plan.credentialBindings.filter((b) => b.credentialAvailable).map((b) => b.channelId) + : [], + ); + const uncoveredChannels = selectedTokenChannels.filter((ch) => !planCoveredIds.has(ch)); + const fallbackConflicts = + uncoveredChannels.length > 0 + ? findChannelConflicts(currentSandbox, uncoveredChannels, registry) + : []; + + const seen = new Set(); + return [...planConflicts, ...fallbackConflicts].filter(({ channel, sandbox }) => { + const key = `${channel}\0${sandbox}`; + return seen.has(key) ? false : (seen.add(key), true); + }); +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3ca1ed2756..589db4a590 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2780,11 +2780,6 @@ async function createSandbox( const envPlan = readMessagingPlanFromEnv(); const currentPlan = envPlan?.sandboxName === sandboxName ? envPlan : null; const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; - - // Fallback: if no plan credentials are available (e.g. credential-store-backed - // availability, all channels QR-paired, or stale/missing env plan) but token-backed - // channels were selected, still run a channel-name-only overlap check so we at - // minimum emit conservative unknown-token warnings for any channel conflict. const selectedTokenChannels = Array.isArray(enabledChannels) ? enabledChannels.filter((name) => { const def = MESSAGING_CHANNELS.find((c) => c.name === name); @@ -2793,7 +2788,7 @@ async function createSandbox( : []; if (hasPlanCredentials || selectedTokenChannels.length > 0) { - const { backfillMessagingChannels, findChannelConflictsFromPlan, findChannelConflicts, createMessagingConflictProbe } = + const { backfillMessagingChannels, findChannelConflictsForOnboarding, createMessagingConflictProbe } = require("./messaging-conflict") as typeof import("./messaging-conflict"); const probe = createMessagingConflictProbe({ checkGatewayLiveness: () => @@ -2801,9 +2796,12 @@ async function createSandbox( providerExists: (name) => providerExistsInGateway(name), }); backfillMessagingChannels(registry, probe); - const conflicts = hasPlanCredentials - ? findChannelConflictsFromPlan(sandboxName, currentPlan!, registry) - : findChannelConflicts(sandboxName, selectedTokenChannels, registry); + const conflicts = findChannelConflictsForOnboarding( + sandboxName, + currentPlan, + selectedTokenChannels, + registry, + ); if (conflicts.length > 0) { for (const { channel, sandbox, reason } of conflicts) { const detail = From 5e3977ab03c31499b0694da46d56a4eb67792771 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 22:29:06 +0530 Subject: [PATCH 16/25] refactor(messaging): remove providerCredentialHashes from conflict detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy entries no longer carry hash metadata into conflict comparisons. resolveChannelHashesFromEntry returns {} for entries without a plan, which falls through to conservative unknown-token detection in all callers. - ConflictRegistryEntry: drop providerCredentialHashes field - resolveChannelHashesFromEntry: plan path only; legacy entries → {} - CHANNEL_CREDENTIAL_ENV_KEYS comment updated (now solely for manifest-key comparison set, not legacy scoping) - Section comment: "plan-preferred, legacy-fallback" → "Entry resolution" - Remove findChannelConflictsForOnboarding and fallback logic; onboard.ts calls findChannelConflictsFromPlan directly (rationale in issue #4392) Test updates: - conflict-detection-entry.test.ts: remove legacy cross-channel hash scoping describe block; migrate Slack pair tests from providerCredentialHashes fixtures to plan-backed entries - messaging-conflict.test.ts: remove hash-comparison tests (covered by plan-entry tests); update remaining fixtures to drop providerCredentialHashes; update file header comment Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/messaging-conflict.test.ts | 79 +-------- src/lib/messaging-conflict.ts | 36 ---- .../applier/conflict-detection-entry.test.ts | 167 +++++++++--------- .../messaging/applier/conflict-detection.ts | 42 ++--- src/lib/onboard.ts | 18 +- 5 files changed, 103 insertions(+), 239 deletions(-) diff --git a/src/lib/messaging-conflict.test.ts b/src/lib/messaging-conflict.test.ts index 25f2ebd622..a9ae8365fd 100644 --- a/src/lib/messaging-conflict.test.ts +++ b/src/lib/messaging-conflict.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Legacy-field (messagingChannels / providerCredentialHashes) conflict tests. -// Plan-driven tests live in src/lib/messaging/applier/conflict-detection.test.ts +// Legacy-field (messagingChannels / disabledChannels) conflict tests. +// Hash-precise (plan-backed) tests live in src/lib/messaging/applier/conflict-detection-entry.test.ts import { describe, expect, it, vi } from "vitest"; @@ -43,18 +43,10 @@ describe("findChannelConflicts", () => { ]); }); - it("returns conflicts only when the same channel credential hash matches", () => { + it("returns unknown-token for any legacy entry sharing the channel (no hash data)", () => { const registry = makeRegistry([ - { - name: "alice", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, - }, - { - name: "carol", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-c" }, - }, + { name: "alice", messagingChannels: ["telegram"] }, + { name: "carol", messagingChannels: ["telegram"] }, ]); expect( findChannelConflicts( @@ -62,24 +54,10 @@ describe("findChannelConflicts", () => { [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], registry, ), - ).toEqual([{ channel: "telegram", sandbox: "alice", reason: "matching-token" }]); - }); - - it("allows multiple telegram sandboxes with distinct token hashes", () => { - const registry = makeRegistry([ - { - name: "alice", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, - }, + ).toEqual([ + { channel: "telegram", sandbox: "alice", reason: "unknown-token" }, + { channel: "telegram", sandbox: "carol", reason: "unknown-token" }, ]); - expect( - findChannelConflicts( - "bob", - [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-b" } }], - registry, - ), - ).toEqual([]); }); it("excludes the current sandbox from its own conflicts", () => { @@ -103,7 +81,6 @@ describe("findChannelConflicts", () => { name: "alice", messagingChannels: ["telegram"], disabledChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, }, ]); expect( @@ -141,39 +118,6 @@ describe("findAllOverlaps", () => { ]); }); - it("does not report overlaps when same-channel credential hashes differ", () => { - const registry = makeRegistry([ - { - name: "alice", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, - }, - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-b" }, - }, - ]); - expect(findAllOverlaps(registry)).toEqual([]); - }); - - it("reports matching-token overlaps when same-channel credential hashes match", () => { - const registry = makeRegistry([ - { - name: "alice", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, - }, - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, - }, - ]); - expect(findAllOverlaps(registry)).toEqual([ - { channel: "telegram", sandboxes: ["alice", "bob"], reason: "matching-token" }, - ]); - }); it("returns empty when channels do not overlap", () => { const registry = makeRegistry([ @@ -189,13 +133,8 @@ describe("findAllOverlaps", () => { name: "alice", messagingChannels: ["telegram"], disabledChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, - }, - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" }, }, + { name: "bob", messagingChannels: ["telegram"] }, ]); expect(findAllOverlaps(registry)).toEqual([]); }); diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index 765c2a384e..cfb9093ef8 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -118,39 +118,3 @@ export function findChannelConflictsFromPlan( return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); } -/** - * Onboarding conflict check: runs the plan-based hash-precise check for - * channels represented in `plan`, plus a conservative channel-name-only check - * for any `selectedTokenChannels` NOT covered by the plan. - * - * Two paths are always unioned so a partial or stale plan cannot silently skip - * selected token-backed channels that it omits. Callers pass `null` for `plan` - * when no env plan is available; the fallback then covers all selected channels. - */ -export function findChannelConflictsForOnboarding( - currentSandbox: string | null, - plan: SandboxMessagingPlan | null, - selectedTokenChannels: string[], - registry: ConflictRegistry, -): ConflictMatch[] { - const planConflicts = plan - ? findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry) - : []; - - const planCoveredIds = new Set( - plan - ? plan.credentialBindings.filter((b) => b.credentialAvailable).map((b) => b.channelId) - : [], - ); - const uncoveredChannels = selectedTokenChannels.filter((ch) => !planCoveredIds.has(ch)); - const fallbackConflicts = - uncoveredChannels.length > 0 - ? findChannelConflicts(currentSandbox, uncoveredChannels, registry) - : []; - - const seen = new Set(); - return [...planConflicts, ...fallbackConflicts].filter(({ channel, sandbox }) => { - const key = `${channel}\0${sandbox}`; - return seen.has(key) ? false : (seen.add(key), true); - }); -} diff --git a/src/lib/messaging/applier/conflict-detection-entry.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts index 44f3faebd2..1f63f91d1f 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -318,67 +318,6 @@ describe("detectAllOverlapsInEntries", () => { }); }); -// --------------------------------------------------------------------------- -// Legacy-entry cross-channel hash scoping -// --------------------------------------------------------------------------- - -describe("legacy entry cross-channel hash scoping", () => { - it("conflictReasonForRequest — does not produce unknown-token from unrelated Slack keys on a Telegram request", () => { - // A legacy entry with both Telegram and Slack hashes stored in the flat - // providerCredentialHashes map must not make Slack keys visible when the - // request is for Telegram only. - const legacy: ConflictRegistryEntry = { - name: "alice", - messagingChannels: ["telegram", "slack"], - providerCredentialHashes: { - TELEGRAM_BOT_TOKEN: "hash-tg-a", - SLACK_BOT_TOKEN: "hash-slack", - SLACK_APP_TOKEN: "hash-slack-app", - }, - }; - expect( - conflictReasonForRequest(legacy, { - channel: "telegram", - credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-b" }, - }), - ).toBeNull(); // distinct Telegram tokens — Slack keys must not inflate this to unknown-token - }); - - it("conflictReasonForRequest — still detects matching-token for the correct channel", () => { - const legacy: ConflictRegistryEntry = { - name: "alice", - messagingChannels: ["telegram", "slack"], - providerCredentialHashes: { - TELEGRAM_BOT_TOKEN: "hash-tg-shared", - SLACK_BOT_TOKEN: "hash-slack", - }, - }; - expect( - conflictReasonForRequest(legacy, { - channel: "telegram", - credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-shared" }, - }), - ).toBe("matching-token"); - }); - - it("conflictReasonForPair — does not report matching-token Telegram overlap from matching Slack hashes", () => { - const alice: ConflictRegistryEntry = { - name: "alice", - messagingChannels: ["telegram", "slack"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-a", SLACK_BOT_TOKEN: "shared-slack" }, - }; - const bob: ConflictRegistryEntry = { - name: "bob", - messagingChannels: ["telegram", "slack"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-tg-b", SLACK_BOT_TOKEN: "shared-slack" }, - }; - // Telegram tokens differ — Slack match must not bleed into Telegram comparison - expect(conflictReasonForPair("telegram", alice, bob)).toBeNull(); - // Slack comparison should still correctly flag the shared Slack hash - expect(conflictReasonForPair("slack", alice, bob)).toBe("matching-token"); - }); -}); - // --------------------------------------------------------------------------- // Multi-credential channel partial-hash suppression (Slack SLACK_BOT_TOKEN + // SLACK_APP_TOKEN). Both manifest keys are required; a differing bot token @@ -386,8 +325,7 @@ describe("legacy entry cross-channel hash scoping", () => { // --------------------------------------------------------------------------- describe("multi-credential channel partial hash suppression", () => { - it("conflictReasonForRequest — returns unknown-token when Slack bot tokens differ but app token is missing from stored entry", () => { - // stored: only SLACK_BOT_TOKEN; missing SLACK_APP_TOKEN + it("conflictReasonForRequest — returns unknown-token when Slack bot tokens differ but app token is missing from stored plan", () => { const entry = planEntry( "alice", makePlan("alice", { @@ -412,7 +350,7 @@ describe("multi-credential channel partial hash suppression", () => { channel: "slack", credentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-x" }, }), - ).toBe("unknown-token"); // bot tokens differ, but app token unknown → conservative + ).toBe("unknown-token"); // bot tokens differ but app token unknown → conservative }); it("conflictReasonForRequest — returns null when both Slack tokens are present and both differ", () => { @@ -452,34 +390,87 @@ describe("multi-credential channel partial hash suppression", () => { ).toBeNull(); }); - it("conflictReasonForPair — returns unknown-token when Slack bot tokens differ but app token is absent from both", () => { - // Both entries have only SLACK_BOT_TOKEN; without manifest keys the union - // would be ["SLACK_BOT_TOKEN"], bot tokens differ → null. With manifest keys - // SLACK_APP_TOKEN is also required → unknown-token. - const alice: ConflictRegistryEntry = { - name: "alice", - messagingChannels: ["slack"], - providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-a" }, - }; - const bob: ConflictRegistryEntry = { - name: "bob", - messagingChannels: ["slack"], - providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b" }, - }; + it("conflictReasonForPair — returns unknown-token when Slack bot tokens differ but app token is absent from both plans", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [ + { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "alice-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash-bot-a", + }, + ], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { + channels: [slackChannel()], + credentialBindings: [ + { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "bob-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash-bot-b", + }, + ], + }), + ); expect(conflictReasonForPair("slack", alice, bob)).toBe("unknown-token"); }); it("conflictReasonForPair — returns null when both Slack tokens are present and both differ", () => { - const alice: ConflictRegistryEntry = { - name: "alice", - messagingChannels: ["slack"], - providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-a", SLACK_APP_TOKEN: "hash-app-a" }, - }; - const bob: ConflictRegistryEntry = { - name: "bob", - messagingChannels: ["slack"], - providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-b" }, - }; + const alice = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [ + { + channelId: "slack", credentialId: "slackBotToken", sourceInput: "botToken", + providerName: "alice-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, credentialHash: "hash-bot-a", + }, + { + channelId: "slack", credentialId: "slackAppToken", sourceInput: "appToken", + providerName: "alice-slack-app", providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", + credentialAvailable: true, credentialHash: "hash-app-a", + }, + ], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { + channels: [slackChannel()], + credentialBindings: [ + { + channelId: "slack", credentialId: "slackBotToken", sourceInput: "botToken", + providerName: "bob-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, credentialHash: "hash-bot-b", + }, + { + channelId: "slack", credentialId: "slackAppToken", sourceInput: "appToken", + providerName: "bob-slack-app", providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", + credentialAvailable: true, credentialHash: "hash-app-b", + }, + ], + }), + ); expect(conflictReasonForPair("slack", alice, bob)).toBeNull(); }); }); diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index db0b1ffa31..f317842bd6 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -5,8 +5,9 @@ import type { SandboxMessagingPlan } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS } from "../channels"; // Map channelId → providerEnvKey values declared in built-in manifests. -// Used to scope legacy providerCredentialHashes to a single channel so hashes -// from other channels on the same sandbox don't produce cross-channel conflicts. +// Used as the primary key set for hash comparison so a missing credential for +// one of a channel's required credentials conservatively marks the comparison +// as unknown-token rather than silently returning null. const CHANNEL_CREDENTIAL_ENV_KEYS: Readonly> = Object.fromEntries( BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]), @@ -53,7 +54,6 @@ export interface ConflictRegistryEntry { readonly messaging?: { readonly plan: SandboxMessagingPlan } | null; readonly messagingChannels?: readonly string[] | null; readonly disabledChannels?: readonly string[] | null; - readonly providerCredentialHashes?: Record | null; } // --------------------------------------------------------------------------- @@ -152,14 +152,14 @@ export function planToConflictChannelRequests(plan: SandboxMessagingPlan): Confl } // --------------------------------------------------------------------------- -// Entry resolution — plan-preferred, legacy-fallback +// Entry resolution // --------------------------------------------------------------------------- /** * Return the active (non-disabled) channel IDs for a registry entry. - * Prefers `entry.messaging.plan` data; falls back to the legacy - * `messagingChannels`/`disabledChannels` flat fields for entries that predate - * the plan architecture. Returns `null` when the entry has neither. + * Uses `entry.messaging.plan` when available; falls back to the legacy + * `messagingChannels`/`disabledChannels` flat fields for pre-plan entries. + * Returns `null` when the entry has neither. */ export function resolveActiveChannelsFromEntry( entry: ConflictRegistryEntry, @@ -174,36 +174,18 @@ export function resolveActiveChannelsFromEntry( /** * Return credential hashes scoped to `channelId` for a registry entry. - * - * For plan-backed entries the lookup is channel-scoped via `getCredentialHashesFromPlan`. - * When the plan exists but carries no hashes for the channel (e.g. a registry-only - * resume that did not re-run the compiler), falls back to the legacy - * `providerCredentialHashes` flat field so safety coverage is preserved. - * - * For legacy entries without a plan, `providerCredentialHashes` is filtered to - * only the env keys that are declared for `channelId` in the built-in manifests. - * This prevents hashes from other channels on the same sandbox from producing - * cross-channel false positives (e.g. a Slack hash matching in a Telegram comparison). - * When no manifest-declared keys are found in the stored hashes the result is an - * empty map, which falls through to `"unknown-token"` conservative detection in - * the callers. + * Plan-backed entries return channel-scoped hashes from `getCredentialHashesFromPlan`. + * Legacy entries without a plan return an empty map, which falls through to + * conservative `"unknown-token"` detection in the callers. */ function resolveChannelHashesFromEntry( entry: ConflictRegistryEntry, channelId: string, ): Record { if (entry.messaging?.plan) { - const planHashes = getCredentialHashesFromPlan(entry.messaging.plan, channelId); - if (Object.keys(planHashes).length > 0) return planHashes; - } - const allHashes = (entry.providerCredentialHashes as Record) ?? {}; - const knownKeys = CHANNEL_CREDENTIAL_ENV_KEYS[channelId]; - if (!knownKeys || knownKeys.length === 0) return allHashes; - const scoped: Record = {}; - for (const key of knownKeys) { - if (allHashes[key]) scoped[key] = allHashes[key]; + return getCredentialHashesFromPlan(entry.messaging.plan, channelId); } - return scoped; + return {}; } // --------------------------------------------------------------------------- diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 589db4a590..e39f4e92a9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2780,15 +2780,8 @@ async function createSandbox( const envPlan = readMessagingPlanFromEnv(); const currentPlan = envPlan?.sandboxName === sandboxName ? envPlan : null; const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; - const selectedTokenChannels = Array.isArray(enabledChannels) - ? enabledChannels.filter((name) => { - const def = MESSAGING_CHANNELS.find((c) => c.name === name); - return def ? getChannelTokenKeys(def).length > 0 : false; - }) - : []; - - if (hasPlanCredentials || selectedTokenChannels.length > 0) { - const { backfillMessagingChannels, findChannelConflictsForOnboarding, createMessagingConflictProbe } = + if (hasPlanCredentials) { + const { backfillMessagingChannels, findChannelConflictsFromPlan, createMessagingConflictProbe } = require("./messaging-conflict") as typeof import("./messaging-conflict"); const probe = createMessagingConflictProbe({ checkGatewayLiveness: () => @@ -2796,12 +2789,7 @@ async function createSandbox( providerExists: (name) => providerExistsInGateway(name), }); backfillMessagingChannels(registry, probe); - const conflicts = findChannelConflictsForOnboarding( - sandboxName, - currentPlan, - selectedTokenChannels, - registry, - ); + const conflicts = findChannelConflictsFromPlan(sandboxName, currentPlan!, registry); if (conflicts.length > 0) { for (const { channel, sandbox, reason } of conflicts) { const detail = From 0fe368533325433f3625db1213c1dcb72863c259 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 23:31:47 +0530 Subject: [PATCH 17/25] refactor(messaging): remove providerCredentialHashes from all write paths Completes the providerCredentialHashes deprecation started in the phase 4a conflict-detection migration. The field was written by onboard.ts, policy-channel.ts, and rebuild.ts, and read by messaging-credentials.ts and workflow-planner.ts for rotation detection and credential-availability inference. All write paths are removed. Reads are replaced with plan-backed equivalents: detectMessagingCredentialRotation now reads credentialBindings.credentialHash from the compiled SandboxMessagingPlan, and credentialAvailabilityFromSandboxEntry reads plan.credentialBindings[].credentialAvailable. The field is removed from SandboxEntry and MessagingWorkflowPlannerSandboxEntry entirely; existing sandboxes.json files with stale providerCredentialHashes entries are unaffected at runtime (unknown JSON fields are ignored). Tests updated to use plan-backed fixtures for matching-token conflict scenarios; credential-rotation tests use messaging.plan.credentialBindings fixtures. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../sandbox/policy-channel-conflict.test.ts | 166 ++++++++---------- src/lib/actions/sandbox/policy-channel.ts | 24 +-- src/lib/actions/sandbox/rebuild.ts | 1 - src/lib/inventory/index.ts | 1 - src/lib/messaging-conflict.ts | 1 - .../compiler/workflow-planner.test.ts | 3 - .../messaging/compiler/workflow-planner.ts | 10 +- src/lib/onboard.ts | 20 --- src/lib/onboard/messaging-credentials.ts | 13 +- src/lib/state/registry.ts | 2 - test/channels-add-preset.test.ts | 14 -- test/channels-remove-full-teardown.test.ts | 2 - test/cli/status-health.test.ts | 5 +- test/credential-rotation.test.ts | 73 +++++--- test/onboard-messaging.test.ts | 12 -- test/rebuild-credential-preflight.test.ts | 4 - 16 files changed, 135 insertions(+), 216 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 323b50ded8..378efe90a7 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -54,6 +54,56 @@ const { addSandboxChannel } = D("actions/sandbox/policy-channel.js") as { const TELEGRAM_TOKEN = "123456:AAH-secret-bot-token-value"; const TELEGRAM_HASH = hashCredential(TELEGRAM_TOKEN) as string; +// Build a minimal plan-backed SandboxEntry for conflict-detection fixtures. +// Callers supply credential bindings as { providerEnvKey, credentialHash? }. +function makePlanEntry( + name: string, + channelId: "telegram" | "slack" | "discord" | "wechat" | "whatsapp", + bindings: Array<{ providerEnvKey: string; credentialHash?: string }>, +): SandboxEntry { + return { + name, + messaging: { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName: name, + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: bindings.map((b) => ({ + channelId, + credentialId: b.providerEnvKey.toLowerCase(), + sourceInput: b.providerEnvKey.toLowerCase(), + providerName: `${name}-${channelId}-bridge`, + providerEnvKey: b.providerEnvKey, + placeholder: `openshell:resolve:env:${b.providerEnvKey}`, + credentialAvailable: true, + ...(b.credentialHash ? { credentialHash: b.credentialHash } : {}), + })), + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, + } as unknown as SandboxEntry; +} + let spies: MockInstance[]; let logSpy: MockInstance; let errSpy: MockInstance; @@ -195,13 +245,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("interactive matching-token conflict: warns, user continues, add proceeds", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("y"); @@ -219,13 +263,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("interactive matching-token conflict: user aborts, nothing is mutated", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("n"); @@ -241,13 +279,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("interactive matching-token conflict: empty answer (default N) aborts", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue(""); // bare Enter -> default No @@ -262,13 +294,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("non-interactive matching-token conflict: aborts with exit(1) and guidance", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -292,13 +318,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("--force bypasses the conflict even in non-interactive mode", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -318,7 +338,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("unknown-token wording when the other sandbox has the channel but no hash", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [{ name: "bob", messagingChannels: ["telegram"] }], // no providerCredentialHashes + others: [{ name: "bob", messagingChannels: ["telegram"] }], // no plan — legacy entry, unknown-token }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("y"); @@ -334,15 +354,9 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("different hash on the other sandbox is NOT a conflict (no warning, add proceeds)", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { - TELEGRAM_BOT_TOKEN: hashCredential("a-completely-different-token") as string, - }, - }, - ], + others: [makePlanEntry("bob", "telegram", [ + { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: hashCredential("a-completely-different-token") as string }, + ])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("n"); // would abort IF prompted; proves no prompt happens @@ -360,11 +374,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 6 it("idempotent same-sandbox re-add does not self-conflict", async () => { arrangeRegistry({ - current: { - name: "alpha", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, + current: makePlanEntry("alpha", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }]), }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("n"); // would abort IF prompted @@ -383,13 +393,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("--dry-run never runs the conflict check or touches credentials", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); await addSandboxChannel("alpha", { channel: "telegram", dryRun: true }); @@ -411,13 +415,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { const wechatHash = hashCredential(wechatToken) as string; arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["wechat"], - providerCredentialHashes: { WECHAT_BOT_TOKEN: wechatHash }, - }, - ], + others: [makePlanEntry("bob", "wechat", [{ providerEnvKey: "WECHAT_BOT_TOKEN", credentialHash: wechatHash }])], }); // The hook planner skips non-interactive host-QR enrollment, but the // conflict guard should still see a cached WeChat credential. @@ -439,13 +437,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("in-sandbox-qr whatsapp skips the credential conflict check", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["whatsapp"], - providerCredentialHashes: { WHATSAPP_TOKEN: "irrelevant" }, - }, - ], + others: [{ name: "bob", messagingChannels: ["whatsapp"] }], }); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -463,11 +455,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, + makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }]), // Legacy entry with NO messagingChannels field — backfill probes the // (alive) gateway, gets "absent" for every provider, then writes // messagingChannels:[] for it. We make THAT write throw to genuinely @@ -515,13 +503,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("never prints the raw token value in any conflict output (proceed path)", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("y"); @@ -537,13 +519,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("non-interactive abort path also keeps the raw token out of output", async () => { arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: TELEGRAM_HASH }, - }, - ], + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -562,13 +538,9 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { const slackBotHash = hashCredential(slackBot) as string; arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, - others: [ - { - name: "bob", - messagingChannels: ["slack"], - providerCredentialHashes: { SLACK_BOT_TOKEN: slackBotHash }, // only bot token matches - }, - ], + // only bot token stored — app token unknown → conservative unknown-token OR + // matching-token if bot token matches; test verifies the conflict is surfaced. + others: [makePlanEntry("bob", "slack", [{ providerEnvKey: "SLACK_BOT_TOKEN", credentialHash: slackBotHash }])], }); getCredentialMock.mockImplementation((key: string) => { if (key === "SLACK_BOT_TOKEN") return slackBot; diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 7134794b06..14bb85e9ce 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -494,16 +494,9 @@ async function applyChannelAddToGatewayAndRegistry( const enabled = new Set(entry.messagingChannels || []); enabled.add(channelName); const disabled = (entry.disabledChannels || []).filter((c: string) => c !== channelName); - const providerCredentialHashes = { ...(entry.providerCredentialHashes || {}) }; - for (const [envKey, token] of Object.entries(acquired)) { - const hash = hashCredential(token); - if (hash) providerCredentialHashes[envKey] = hash; - } registry.updateSandbox(sandboxName, { messagingChannels: Array.from(enabled).sort(), disabledChannels: disabled, - providerCredentialHashes: - Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, }); } } @@ -614,15 +607,7 @@ async function applyChannelRemoveToGatewayAndRegistry( const entry = registry.getSandbox(sandboxName); if (entry) { const enabled = (entry.messagingChannels || []).filter((c: string) => c !== channelName); - const providerCredentialHashes = { ...(entry.providerCredentialHashes || {}) }; - for (const envKey of channelTokenKeys) { - delete providerCredentialHashes[envKey]; - } - registry.updateSandbox(sandboxName, { - messagingChannels: enabled, - providerCredentialHashes: - Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, - }); + registry.updateSandbox(sandboxName, { messagingChannels: enabled }); } return { ok: residual.length === 0, residual }; @@ -1095,9 +1080,6 @@ export async function addSandboxChannel( ? [...priorEntry.messagingChannels] : []; const wasAlreadyEnabled = priorMessagingChannels.includes(canonical); - const priorHashes: Record = { - ...((priorEntry?.providerCredentialHashes as Record) || {}), - }; const channelTokenKeys = getChannelTokenKeys(channelDef); const priorCreds: Record = {}; for (const key of channelTokenKeys) { @@ -1117,7 +1099,6 @@ export async function addSandboxChannel( await rollbackChannelAdd(sandboxName, channelDef, canonical, { wasAlreadyEnabled, priorMessagingChannels, - priorHashes, priorCreds, }); process.exit(1); @@ -1137,7 +1118,6 @@ async function rollbackChannelAdd( snapshot: { wasAlreadyEnabled: boolean; priorMessagingChannels: string[]; - priorHashes: Record; priorCreds: Record; }, ): Promise<{ ok: boolean; residual: string[] }> { @@ -1147,8 +1127,6 @@ async function rollbackChannelAdd( ); registry.updateSandbox(sandboxName, { messagingChannels: snapshot.priorMessagingChannels, - providerCredentialHashes: - Object.keys(snapshot.priorHashes).length > 0 ? snapshot.priorHashes : undefined, }); clearChannelTokens(channel); if (Object.keys(snapshot.priorCreds).length > 0) { diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 63f06a0219..400ab5be4f 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -823,7 +823,6 @@ export async function rebuildSandbox( ...(hasRebuildHermesToolGateways ? { hermesToolGateways: [...rebuildHermesToolGateways] } : {}), - ...(sb.providerCredentialHashes ? { providerCredentialHashes: sb.providerCredentialHashes } : {}), }; if (Object.keys(preservedRegistryFields).length > 0) { registry.updateSandbox(sandboxName, preservedRegistryFields); diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index fabf5b5ff6..85a1153e80 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -18,7 +18,6 @@ export interface SandboxEntry { openshellDriver?: string | null; openshellVersion?: string | null; policies?: string[] | null; - providerCredentialHashes?: Record | null; messagingChannels?: string[] | null; agent?: string | null; dashboardPort?: number | null; diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index cfb9093ef8..affa49e990 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -117,4 +117,3 @@ export function findChannelConflictsFromPlan( ): ConflictMatch[] { return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); } - diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 9a07b5f307..b9c8c651a5 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -666,9 +666,6 @@ describe("MessagingWorkflowPlanner", () => { sandboxEntry: { name: "demo", messagingChannels: ["telegram"], - providerCredentialHashes: { - TELEGRAM_BOT_TOKEN: "sha256:test", - }, }, }); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 853ff39456..adc28b360f 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -166,15 +166,18 @@ export class MessagingWorkflowPlanner { sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, channelIds: readonly MessagingChannelId[], ): MessagingCompilerCredentialAvailability | undefined { - const hashes = sandboxEntry?.providerCredentialHashes; - if (!hashes || Object.keys(hashes).length === 0) return undefined; + const plan = sandboxEntry?.messaging?.plan; + if (!plan) return undefined; const availability: Record = {}; for (const channelId of channelIds) { const manifest = this.registry.get(channelId); if (!manifest) continue; for (const credential of manifest.credentials) { - if (!hashes[credential.providerEnvKey]) continue; + const binding = plan.credentialBindings.find( + (b) => b.channelId === channelId && b.providerEnvKey === credential.providerEnvKey, + ); + if (!binding?.credentialAvailable) continue; availability[credential.sourceInput] = true; availability[`${manifest.id}.${credential.sourceInput}`] = true; availability[credential.id] = true; @@ -191,7 +194,6 @@ export interface MessagingWorkflowPlannerSandboxEntry { readonly agent?: string | null; readonly messagingChannels?: readonly MessagingChannelId[] | null; readonly disabledChannels?: readonly MessagingChannelId[] | null; - readonly providerCredentialHashes?: Readonly> | null; readonly messaging?: { readonly schemaVersion: 1; readonly plan: SandboxMessagingPlan; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e39f4e92a9..634962dd5f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2881,12 +2881,9 @@ async function createSandbox( } if (braveWebSearchEnabled) messagingTokenDefs.push({ name: `${sandboxName}-brave-search`, envKey: webSearch.BRAVE_API_KEY_ENV, token: braveApiKey, providerType: braveProviderProfile.BRAVE_PROVIDER_PROFILE_ID }); const extraPlaceholderKeys: string[] = require("./onboard/extra-placeholder-keys").registerExtraPlaceholderProviders(sandboxName, messagingTokenDefs); - const previousProviderCredentialHashes = - registry.getSandbox(sandboxName)?.providerCredentialHashes ?? {}; const hasMessagingTokens = messagingTokenDefs.some(({ token }) => !!token); const reusableMessagingProviders: string[] = []; const reusableMessagingChannels: string[] = []; - const reusableMessagingEnvKeys = new Set(); if (enabledChannels != null) { for (const { name, envKey, token } of messagingTokenDefs) { if (token) continue; @@ -2895,7 +2892,6 @@ async function createSandbox( if (!channel || !enabledChannels.includes(channel)) continue; if (!providerExistsInGateway(name)) continue; reusableMessagingProviders.push(name); - reusableMessagingEnvKeys.add(envKey); if (!reusableMessagingChannels.includes(channel)) { reusableMessagingChannels.push(channel); } @@ -3684,20 +3680,6 @@ async function createSandbox( hermesDashboardForwarding.resolveStateForPort(actualDashboardPort); hermesDashboardForwarding.ensureForState(finalHermesDashboardState, sandboxName, true); - // Register only after confirmed ready — prevents phantom entries - const providerCredentialHashes: Record = {}; - for (const { envKey, token } of messagingTokenDefs) { - const hash = token ? hashCredential(token) : null; - if (hash) { - providerCredentialHashes[envKey] = hash; - } - } - for (const envKey of reusableMessagingEnvKeys) { - const previousHash = previousProviderCredentialHashes[envKey]; - if (typeof previousHash === "string" && previousHash) { - providerCredentialHashes[envKey] = previousHash; - } - } // openshell tags images with seconds; buildId is ms. Parse actual tag from output. Fixes #2672. const resolvedImageTag = resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); @@ -3712,8 +3694,6 @@ async function createSandbox( ...sandboxRuntimeFields, ...getSandboxAgentRegistryFields(agent, !fromDockerfile), imageTag: resolvedImageTag, - providerCredentialHashes: - Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, policies: initialSandboxPolicy.appliedPresets, // Persist the operator's configured channel set, not the post-disabled-filter // active set. After `channels stop X` + rebuild, activeMessagingChannels drops diff --git a/src/lib/onboard/messaging-credentials.ts b/src/lib/onboard/messaging-credentials.ts index cb03f31dfc..1eaf62eeab 100644 --- a/src/lib/onboard/messaging-credentials.ts +++ b/src/lib/onboard/messaging-credentials.ts @@ -54,17 +54,22 @@ export function getMessagingChannelForEnvKey(envKey: string): string | null { /** * Detect whether any messaging provider credential has been rotated since * the sandbox was created, by comparing SHA-256 hashes of the current - * token values against hashes stored in the sandbox registry. + * token values against hashes stored in the compiled messaging plan. * - * Returns `changed: false` for legacy sandboxes that have no stored hashes - * (conservative — avoids unnecessary rebuilds after upgrade). + * Returns `changed: false` for sandboxes that have no plan (conservative — + * avoids unnecessary rebuilds for sandboxes that pre-date plan storage). */ export function detectMessagingCredentialRotation( sandboxName: string, tokenDefs: MessagingTokenDefinition[], ): { changed: boolean; changedProviders: string[] } { const sb = registry.getSandbox(sandboxName); - const storedHashes = sb?.providerCredentialHashes || {}; + const bindings = sb?.messaging?.plan?.credentialBindings ?? []; + const storedHashes: Record = {}; + for (const b of bindings) { + if (b.credentialHash) storedHashes[b.providerEnvKey] = b.credentialHash; + } + if (Object.keys(storedHashes).length === 0) return { changed: false, changedProviders: [] }; const changedProviders = []; for (const { name, envKey, token } of tokenDefs) { const storedHash = storedHashes[envKey]; diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 8b1680bef5..f7af8637b3 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -60,7 +60,6 @@ export interface SandboxEntry { agent?: string | null; agentVersion?: string | null; imageTag?: string | null; - providerCredentialHashes?: Record; messagingChannels?: string[]; messagingChannelConfig?: MessagingChannelConfig; messaging?: SandboxMessagingState; @@ -257,7 +256,6 @@ export function registerSandbox(entry: SandboxEntry): void { agent: entry.agent || null, agentVersion: entry.agentVersion || null, imageTag: entry.imageTag || null, - providerCredentialHashes: entry.providerCredentialHashes || undefined, messagingChannels: entry.messagingChannels || [], messagingChannelConfig: entry.messagingChannelConfig && Object.keys(entry.messagingChannelConfig).length > 0 diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index b66369d904..b625861b48 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -143,7 +143,6 @@ registry.getSandbox = () => ({ agent: ${JSON.stringify(sandboxAgent)}, messagingChannels: [], disabledChannels: [], - providerCredentialHashes: {}, }); registry.updateSandbox = (name, updates) => { registryUpdates.push({ name, updates }); @@ -934,7 +933,6 @@ registry.getSandbox = () => ({ agent: "openclaw", messagingChannels: ["telegram"], disabledChannels: [], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "prior-hash" }, }); credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; const ctx = module.exports; @@ -980,11 +978,6 @@ process.exit = (code) => { ["telegram"], `re-add failure must keep prior 'telegram' in messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, ); - assert.deepEqual( - lastRegistry.updates.providerCredentialHashes, - { TELEGRAM_BOT_TOKEN: "prior-hash" }, - `re-add failure must restore prior credential hashes; got ${JSON.stringify(payload.registryUpdates)}`, - ); assert.ok( payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), `re-add failure must restore prior credentials via saveCredential; got ${JSON.stringify(payload.savedCredentialKeys)}`, @@ -1011,7 +1004,6 @@ registry.getSandbox = () => ({ agent: "openclaw", messagingChannels: ["telegram"], disabledChannels: [], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "prior-hash" }, }); credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; let upsertCalls = 0; @@ -1060,11 +1052,6 @@ process.exit = (code) => { ["telegram"], `registry restoration must precede gateway re-upsert so an upsert failure cannot orphan the channel; got ${JSON.stringify(payload.registryUpdates)}`, ); - assert.deepEqual( - lastRegistry.updates.providerCredentialHashes, - { TELEGRAM_BOT_TOKEN: "prior-hash" }, - `prior credential hashes must be restored before any gateway side effect; got ${JSON.stringify(payload.registryUpdates)}`, - ); assert.ok( payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), `re-add failure must restore staged environment credentials; got ${JSON.stringify(payload.savedCredentialKeys)}`, @@ -1666,7 +1653,6 @@ registry.getSandbox = () => ({ agent: global.__testAgent || "openclaw", messagingChannels: [], disabledChannels: [], - providerCredentialHashes: {}, }); registry.updateSandbox = () => true; diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts index 1bbb8679fc..50a83c6983 100644 --- a/test/channels-remove-full-teardown.test.ts +++ b/test/channels-remove-full-teardown.test.ts @@ -135,7 +135,6 @@ registry.getSandbox = () => ({ agent: ${JSON.stringify(sandboxAgent)}, messagingChannels: [${JSON.stringify(channelInRegistry)}], disabledChannels: [], - providerCredentialHashes: {}, policies: ${JSON.stringify(presetNamesApplied)}, }); registry.updateSandbox = (name, updates) => { @@ -382,7 +381,6 @@ registryOverride.getSandbox = () => ({ agent: "openclaw", messagingChannels: [], disabledChannels: [], - providerCredentialHashes: {}, policies: [], }); const policiesOverride = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "policy/index.js"))}); diff --git a/test/cli/status-health.test.ts b/test/cli/status-health.test.ts index fa413cabff..0a98c9572b 100644 --- a/test/cli/status-health.test.ts +++ b/test/cli/status-health.test.ts @@ -43,9 +43,6 @@ describe("CLI dispatch", () => { policies: ["npm"], agent: "openclaw", dashboardPort: 18789, - providerCredentialHashes: { - OPENAI_API_KEY: "sk-should-not-render-000000000000", - }, messagingChannels: ["slack"], dashboardUrl: "http://127.0.0.1:18789/?token=dashboard-secret", logs: "Bearer should-not-render xoxb-should-not-render-000000", @@ -125,7 +122,7 @@ describe("CLI dispatch", () => { ], }); expect(r.out).not.toMatch( - /Bearer|nvapi-|sk-|xoxb-|xapp-|password|api[-_]?key|providerCredentialHashes|dashboard-secret|should-not-render/i, + /Bearer|nvapi-|sk-|xoxb-|xapp-|password|api[-_]?key|dashboard-secret|should-not-render/i, ); } finally { fs.rmSync(serviceDir, { recursive: true, force: true }); diff --git a/test/credential-rotation.test.ts b/test/credential-rotation.test.ts index 8c89c7ca2a..a1572525e2 100644 --- a/test/credential-rotation.test.ts +++ b/test/credential-rotation.test.ts @@ -113,12 +113,41 @@ describe("credential rotation detection", () => { }); }); + function makePlanEntry(name: string, bindings: Array<{ providerEnvKey: string; credentialHash?: string }>) { + return { + name, + messaging: { + schemaVersion: 1 as const, + plan: { + schemaVersion: 1 as const, + sandboxName: name, + agent: "openclaw" as const, + workflow: "onboard" as const, + channels: [], + disabledChannels: [], + credentialBindings: bindings.map((b) => ({ + channelId: "telegram" as const, + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: `${name}-telegram-bridge`, + providerEnvKey: b.providerEnvKey, + placeholder: `openshell:resolve:env:${b.providerEnvKey}`, + credentialAvailable: true, + ...(b.credentialHash ? { credentialHash: b.credentialHash } : {}), + })), + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, + }; + } + describe("detectMessagingCredentialRotation", () => { - it("returns changed: false when no hashes are stored (legacy sandbox)", () => { - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "test-sandbox", - // no providerCredentialHashes - }); + it("returns changed: false when no plan is stored (pre-plan sandbox)", () => { + vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "test-sandbox" }); const result = detectMessagingCredentialRotation("test-sandbox", [ { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "new-token" }, @@ -131,10 +160,9 @@ describe("credential rotation detection", () => { it("returns changed: false when hashes match", () => { const tokenHash = hashCredentialOrThrow("same-token"); - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "test-sandbox", - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: tokenHash }, - }); + vi.spyOn(registry, "getSandbox").mockReturnValue( + makePlanEntry("test-sandbox", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: tokenHash }]), + ); const result = detectMessagingCredentialRotation("test-sandbox", [ { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "same-token" }, @@ -147,10 +175,9 @@ describe("credential rotation detection", () => { it("returns changed: true with correct provider names when hashes differ", () => { const oldHash = hashCredentialOrThrow("old-token"); - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "test-sandbox", - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: oldHash }, - }); + vi.spyOn(registry, "getSandbox").mockReturnValue( + makePlanEntry("test-sandbox", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: oldHash }]), + ); const result = detectMessagingCredentialRotation("test-sandbox", [ { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "new-token" }, @@ -164,13 +191,12 @@ describe("credential rotation detection", () => { it("detects rotation across multiple providers", () => { const telegramHash = hashCredentialOrThrow("tg-old"); const discordHash = hashCredentialOrThrow("dc-same"); - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "test-sandbox", - providerCredentialHashes: { - TELEGRAM_BOT_TOKEN: telegramHash, - DISCORD_BOT_TOKEN: discordHash, - }, - }); + vi.spyOn(registry, "getSandbox").mockReturnValue( + makePlanEntry("test-sandbox", [ + { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: telegramHash }, + { providerEnvKey: "DISCORD_BOT_TOKEN", credentialHash: discordHash }, + ]), + ); const result = detectMessagingCredentialRotation("test-sandbox", [ { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: "tg-new" }, @@ -184,10 +210,9 @@ describe("credential rotation detection", () => { it("treats removed tokens as changed providers", () => { const hash = hashCredentialOrThrow("old-token"); - vi.spyOn(registry, "getSandbox").mockReturnValue({ - name: "test-sandbox", - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: hash }, - }); + vi.spyOn(registry, "getSandbox").mockReturnValue( + makePlanEntry("test-sandbox", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: hash }]), + ); const result = detectMessagingCredentialRotation("test-sandbox", [ { name: "test-telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", token: null }, diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 687d874e14..14884fd4e7 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -516,12 +516,6 @@ const registerCalls = []; registry.registerSandbox({ name: "my-assistant", messagingChannels: ["discord", "slack"], - providerCredentialHashes: { - DISCORD_BOT_TOKEN: "hash-discord", - SLACK_BOT_TOKEN: "hash-slack-bot", - SLACK_APP_TOKEN: "hash-slack-app", - TELEGRAM_BOT_TOKEN: "hash-telegram", - }, }); runner.run = (command, opts = {}) => { const normalized = _n(command); @@ -645,11 +639,6 @@ const { createSandbox } = require(${onboardPath}); const channels = JSON.parse(Buffer.from(channelsLine.split("=")[1], "base64").toString()); assert.deepEqual(channels, ["discord", "slack"]); assert.deepEqual(payload.registerCalls[0]?.messagingChannels, ["discord", "slack"]); - assert.deepEqual(payload.registerCalls[0]?.providerCredentialHashes, { - DISCORD_BOT_TOKEN: "hash-discord", - SLACK_BOT_TOKEN: "hash-slack-bot", - SLACK_APP_TOKEN: "hash-slack-app", - }); }, ); @@ -690,7 +679,6 @@ registry.registerSandbox({ name: "my-assistant", messagingChannels: ["telegram"], disabledChannels: ["telegram"], - providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "hash-telegram" }, }); runner.run = (command, opts = {}) => { const normalized = _n(command); diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index e8733ce55b..91adfd835e 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -51,7 +51,6 @@ function createFixture(opts: { agent?: string | null; hermesAuthMethod?: string | null; messagingChannels?: string[] | null; - providerCredentialHashes?: Record; dockerBuildExitCode?: number; providerRegistered?: boolean; }) { @@ -64,7 +63,6 @@ function createFixture(opts: { agent = null, hermesAuthMethod = null, messagingChannels = null, - providerCredentialHashes, dockerBuildExitCode = 0, providerRegistered = true, } = opts; @@ -87,7 +85,6 @@ function createFixture(opts: { policies: [], agent, messagingChannels, - ...(providerCredentialHashes ? { providerCredentialHashes } : {}), }, }, }), @@ -368,7 +365,6 @@ describe("Issue #2273: atomic rebuild", () => { const f = createFixture({ agent: "hermes", messagingChannels: ["discord"], - providerCredentialHashes: { DISCORD_BOT_TOKEN: "hash-discord" }, credentialEnv: "NVIDIA_API_KEY", savedCredential: { key: "NVIDIA_API_KEY", From a2c651096811032ee6960a79caaaa009d20b6da2 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 7 Jun 2026 23:35:03 +0530 Subject: [PATCH 18/25] chore(ci): lower test size budget for channels-add-preset and onboard-messaging Both files shrank after removing providerCredentialHashes fixtures and assertions in the preceding refactor commit. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- ci/test-file-size-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index eb17db48e7..d9c37ae526 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -5,11 +5,11 @@ "nemoclaw/src/commands/migration-state.test.ts": 1566, "src/lib/inference/nim.test.ts": 2079, "src/lib/onboard/preflight.test.ts": 1905, - "test/channels-add-preset.test.ts": 1915, + "test/channels-add-preset.test.ts": 1901, "test/generate-openclaw-config.test.ts": 2106, "test/install-preflight.test.ts": 4397, "test/nemoclaw-start.test.ts": 5310, - "test/onboard-messaging.test.ts": 2122, + "test/onboard-messaging.test.ts": 2110, "test/onboard-selection.test.ts": 7757, "test/onboard.test.ts": 4887, "test/policies.test.ts": 3143 From 34e97626ba0001de54e6e20030af130fbcef5532 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 08:05:44 +0530 Subject: [PATCH 19/25] test(messaging): update token rotation plan hash check --- test/e2e/test-token-rotation.sh | 58 ++++++++++++++++----------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/test/e2e/test-token-rotation.sh b/test/e2e/test-token-rotation.sh index bf22eb4998..8d3f48ad50 100755 --- a/test/e2e/test-token-rotation.sh +++ b/test/e2e/test-token-rotation.sh @@ -163,6 +163,22 @@ is_fake_slack_token() { esac } +registry_has_messaging_credential_hash() { + local env_key="$1" + [ -f "$REGISTRY" ] && node -e " +const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); +const sandbox = (r.sandboxes || {})[process.argv[2]]; +const bindings = sandbox?.messaging?.plan?.credentialBindings; +if (!Array.isArray(bindings)) process.exit(1); +const found = bindings.some((entry) => + entry?.providerEnvKey === process.argv[3] && + typeof entry.credentialHash === 'string' && + entry.credentialHash.length > 0, +); +process.exit(found ? 0 : 1); +" "$REGISTRY" "$SANDBOX_NAME" "$env_key" 2>/dev/null +} + # ── Phase 0: Install NemoClaw with token A ──────────────────────── section "Phase 0: Install NemoClaw and first onboard with token A" @@ -293,45 +309,29 @@ else fail "Provider ${SANDBOX_NAME}-slack-app not found" fi - # Verify credential hashes are stored for this sandbox in the registry - if [ -f "$REGISTRY" ] && node -e " -const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); -const h = (r.sandboxes || {})[process.argv[2]]?.providerCredentialHashes || {}; -process.exit('TELEGRAM_BOT_TOKEN' in h ? 0 : 1); -" "$REGISTRY" "$SANDBOX_NAME" 2>/dev/null; then - pass "Telegram credential hash stored for $SANDBOX_NAME" + # Verify credential hashes are stored in the persisted messaging plan. + if registry_has_messaging_credential_hash "TELEGRAM_BOT_TOKEN"; then + pass "Telegram credential hash stored in messaging plan for $SANDBOX_NAME" else - fail "Telegram credential hash not found for $SANDBOX_NAME in registry" + fail "Telegram credential hash not found in messaging plan for $SANDBOX_NAME" fi - if [ -f "$REGISTRY" ] && node -e " -const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); -const h = (r.sandboxes || {})[process.argv[2]]?.providerCredentialHashes || {}; -process.exit('DISCORD_BOT_TOKEN' in h ? 0 : 1); -" "$REGISTRY" "$SANDBOX_NAME" 2>/dev/null; then - pass "Discord credential hash stored for $SANDBOX_NAME" + if registry_has_messaging_credential_hash "DISCORD_BOT_TOKEN"; then + pass "Discord credential hash stored in messaging plan for $SANDBOX_NAME" else - fail "Discord credential hash not found for $SANDBOX_NAME in registry" + fail "Discord credential hash not found in messaging plan for $SANDBOX_NAME" fi - if [ -f "$REGISTRY" ] && node -e " -const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); -const h = (r.sandboxes || {})[process.argv[2]]?.providerCredentialHashes || {}; -process.exit('SLACK_BOT_TOKEN' in h ? 0 : 1); -" "$REGISTRY" "$SANDBOX_NAME" 2>/dev/null; then - pass "Slack bot credential hash stored for $SANDBOX_NAME" + if registry_has_messaging_credential_hash "SLACK_BOT_TOKEN"; then + pass "Slack bot credential hash stored in messaging plan for $SANDBOX_NAME" else - fail "Slack bot credential hash not found for $SANDBOX_NAME in registry" + fail "Slack bot credential hash not found in messaging plan for $SANDBOX_NAME" fi - if [ -f "$REGISTRY" ] && node -e " -const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); -const h = (r.sandboxes || {})[process.argv[2]]?.providerCredentialHashes || {}; -process.exit('SLACK_APP_TOKEN' in h ? 0 : 1); -" "$REGISTRY" "$SANDBOX_NAME" 2>/dev/null; then - pass "Slack app credential hash stored for $SANDBOX_NAME" + if registry_has_messaging_credential_hash "SLACK_APP_TOKEN"; then + pass "Slack app credential hash stored in messaging plan for $SANDBOX_NAME" else - fail "Slack app credential hash not found for $SANDBOX_NAME in registry" + fail "Slack app credential hash not found in messaging plan for $SANDBOX_NAME" fi # ── Phase 2: Rotate Telegram token only (re-onboard with token B) ─ From 1d66456e3c3ba63c14a270f8422b60f37312f288 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 08:30:45 +0530 Subject: [PATCH 20/25] refactor(messaging): remove conflict adapter --- .../sandbox/policy-channel-conflict.test.ts | 2 +- src/lib/actions/sandbox/policy-channel.ts | 4 +- src/lib/messaging-conflict.ts | 119 ------------------ .../conflict-detection-legacy.test.ts} | 7 +- .../messaging/applier/conflict-detection.ts | 96 +++++++++++++- src/lib/onboard.ts | 2 +- src/lib/status-command-deps.ts | 2 +- 7 files changed, 102 insertions(+), 130 deletions(-) delete mode 100644 src/lib/messaging-conflict.ts rename src/lib/{messaging-conflict.test.ts => messaging/applier/conflict-detection-legacy.test.ts} (98%) diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 378efe90a7..1ea99a0ce2 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -18,7 +18,7 @@ // isNonInteractive is destructured at module load (`const { isNonInteractive } // = require("../../onboard")`), so it cannot be spied after load; it reads // process.env.NEMOCLAW_NON_INTERACTIVE === "1" at call time, which we drive -// directly. The real messaging-conflict, sandbox/channels, and credential-hash +// directly. The real messaging/applier, sandbox/channels, and credential-hash // modules run unmocked so the genuine hash + conflict logic is exercised. import { createRequire } from "node:module"; diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 14bb85e9ce..b07a81d8a4 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -358,7 +358,7 @@ function bridgeProviderName(sandboxName: string, channelName: string, envKey: st return `${sandboxName}-${channelName}-bridge`; } -// Tri-state gateway probe for cross-sandbox messaging-conflict backfill, +// Tri-state gateway probe for cross-sandbox messaging conflict backfill, // mirroring onboard.ts makeConflictProbe(). An upfront liveness check keeps a // transient gateway failure ("error") from being mis-recorded as "no // providers" ("absent"), which would permanently suppress backfill retries. @@ -413,7 +413,7 @@ async function checkChannelAddConflict( if (Object.keys(credentialHashes).length === 0) return true; const { backfillMessagingChannels, findChannelConflicts } = - require("../../messaging-conflict") as typeof import("../../messaging-conflict"); + require("../../messaging/applier") as typeof import("../../messaging/applier"); try { backfillMessagingChannels(registry, makeChannelsConflictProbe()); diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts deleted file mode 100644 index affa49e990..0000000000 --- a/src/lib/messaging-conflict.ts +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Cross-sandbox messaging-channel conflict detection. -// -// Telegram (getUpdates long-polling), Discord (gateway connection), and Slack -// (Socket Mode) all enforce one active consumer per channel credential. Two -// sandboxes sharing the same token silently break both bridges; see issue #1953. -// -// The registry persists which channels each sandbox uses plus a non-secret hash -// of the provider credential when available. This module is a thin public -// adapter over `src/lib/messaging/applier/conflict-detection.ts`, which holds -// all core detection logic and the probe factory. - -import type { SandboxEntry } from "./state/registry"; -import type { SandboxMessagingPlan } from "./messaging/manifest"; -import { - backfillLegacyEntryChannels, - detectAllOverlapsInEntries, - findConflictsInEntries, - planToConflictChannelRequests, - type ConflictMatch, - type ConflictReason, - type MessagingConflictProbe, -} from "./messaging/applier"; -import { BUILT_IN_CHANNEL_MANIFESTS } from "./messaging/channels"; - -const PROVIDER_SUFFIXES: Record = Object.fromEntries( - BUILT_IN_CHANNEL_MANIFESTS.flatMap((m) => { - const suffixes = m.credentials.map((c) => c.providerName.replace("{sandboxName}", "")); - if (suffixes.length === 0) return []; - return [[m.id, suffixes]]; - }), -); - -export { createMessagingConflictProbe } from "./messaging/applier"; - -interface ConflictRegistry { - listSandboxes: () => { sandboxes: SandboxEntry[]; defaultSandbox?: string | null }; - updateSandbox: (name: string, updates: Partial) => boolean; -} - -type ChannelRequest = string | { channel: string; credentialHashes?: Record }; - -function normalizeRequest(request: ChannelRequest) { - if (typeof request === "string") { - return request ? { channel: request, credentialHashes: {} } : null; - } - if (!request || typeof request.channel !== "string" || request.channel.length === 0) return null; - return request; -} - -/** - * For registry entries missing `messagingChannels`, probe OpenShell to infer - * which channels the sandbox was onboarded with, and write the result back to - * the registry. Safe to call repeatedly — entries with the field set are left - * alone. Failures to probe any one sandbox are swallowed so that a flaky - * gateway does not block status or onboarding. - */ -export function backfillMessagingChannels( - registry: ConflictRegistry, - probe: MessagingConflictProbe, -): void { - const { sandboxes } = registry.listSandboxes(); - backfillLegacyEntryChannels(sandboxes, probe, (name, channels) => { - registry.updateSandbox(name, { messagingChannels: channels }); - }, PROVIDER_SUFFIXES); -} - -/** - * Return every (channel, other-sandbox) pair where another sandbox in the - * registry already has one of the requested channels in use with either a - * matching credential hash or insufficient hash metadata to prove it differs. - */ -export function findChannelConflicts( - currentSandbox: string | null, - enabledChannels: ChannelRequest[], - registry: ConflictRegistry, -): ConflictMatch[] { - if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; - const requests = enabledChannels.map(normalizeRequest).filter( - (r): r is NonNullable> => !!r, - ); - if (requests.length === 0) return []; - const { sandboxes } = registry.listSandboxes(); - return findConflictsInEntries(currentSandbox, requests, sandboxes); -} - -/** - * Detect overlaps across every sandbox in the registry, returning each pair at - * most once. Used by `nemoclaw status` to warn users whose sandboxes already - * share a messaging token or whose legacy metadata is too old to verify. - */ -export function findAllOverlaps(registry: ConflictRegistry): Array<{ - channel: string; - sandboxes: [string, string]; - reason: ConflictReason; -}> { - const { sandboxes } = registry.listSandboxes(); - return detectAllOverlapsInEntries(sandboxes); -} - -/** - * Plan-driven variant of `findChannelConflicts`. Derives the channel request - * list from a compiled `SandboxMessagingPlan` instead of requiring the caller - * to build credential hashes from raw channel constants. - * - * Disabled channels and bindings where the credential is unavailable are excluded - * automatically by `planToConflictChannelRequests`. Bindings with `credentialAvailable` - * but no `credentialHash` are included and fall through to conservative - * `"unknown-token"` detection. - */ -export function findChannelConflictsFromPlan( - currentSandbox: string | null, - plan: SandboxMessagingPlan, - registry: ConflictRegistry, -): ConflictMatch[] { - return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); -} diff --git a/src/lib/messaging-conflict.test.ts b/src/lib/messaging/applier/conflict-detection-legacy.test.ts similarity index 98% rename from src/lib/messaging-conflict.test.ts rename to src/lib/messaging/applier/conflict-detection-legacy.test.ts index a9ae8365fd..a4b2a0a9b5 100644 --- a/src/lib/messaging-conflict.test.ts +++ b/src/lib/messaging/applier/conflict-detection-legacy.test.ts @@ -6,13 +6,13 @@ import { describe, expect, it, vi } from "vitest"; -import type { SandboxEntry } from "./state/registry"; +import type { SandboxEntry } from "../../state/registry"; import { backfillMessagingChannels, findAllOverlaps, findChannelConflicts, -} from "./messaging-conflict"; -import { type MessagingConflictProbe } from "./messaging/applier"; + type MessagingConflictProbe, +} from "./conflict-detection"; type ProviderExists = MessagingConflictProbe["providerExists"]; @@ -118,7 +118,6 @@ describe("findAllOverlaps", () => { ]); }); - it("returns empty when channels do not overlap", () => { const registry = makeRegistry([ { name: "alice", messagingChannels: ["telegram"] }, diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index f317842bd6..7ee07a4bfe 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -13,6 +13,14 @@ const CHANNEL_CREDENTIAL_ENV_KEYS: Readonly> = BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]), ); +const PROVIDER_SUFFIXES: Record = Object.fromEntries( + BUILT_IN_CHANNEL_MANIFESTS.flatMap((m) => { + const suffixes = m.credentials.map((c) => c.providerName.replace("{sandboxName}", "")); + if (suffixes.length === 0) return []; + return [[m.id, suffixes]]; + }), +); + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -45,6 +53,10 @@ export interface ConflictMatch { readonly reason: ConflictReason; } +export type ChannelConflictRequest = + | string + | { channel: string; credentialHashes?: Record }; + /** * Minimal shape of a registry entry that conflict detection needs. * Satisfied by `SandboxEntry` from `./state/registry`. @@ -56,6 +68,22 @@ export interface ConflictRegistryEntry { readonly disabledChannels?: readonly string[] | null; } +export interface ConflictRegistry { + listSandboxes: () => { + sandboxes: ConflictRegistryEntry[]; + defaultSandbox?: string | null; + }; + updateSandbox: (name: string, updates: { messagingChannels?: string[] }) => boolean; +} + +function normalizeRequest(request: ChannelConflictRequest): ConflictRequest | null { + if (typeof request === "string") { + return request ? { channel: request, credentialHashes: {} } : null; + } + if (!request || typeof request.channel !== "string" || request.channel.length === 0) return null; + return request; +} + // --------------------------------------------------------------------------- // Probe factory // --------------------------------------------------------------------------- @@ -305,6 +333,36 @@ export function findConflictsInEntries( ); } +/** + * Registry-backed conflict lookup for callers that do not already have a + * compiled plan request list. + */ +export function findChannelConflicts( + currentSandbox: string | null, + enabledChannels: ChannelConflictRequest[], + registry: ConflictRegistry, +): ConflictMatch[] { + if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; + const requests = enabledChannels + .map(normalizeRequest) + .filter((request): request is ConflictRequest => request !== null); + if (requests.length === 0) return []; + const { sandboxes } = registry.listSandboxes(); + return findConflictsInEntries(currentSandbox, requests, sandboxes); +} + +/** + * Plan-driven variant of `findChannelConflicts`. Derives the channel request + * list from a compiled `SandboxMessagingPlan`. + */ +export function findChannelConflictsFromPlan( + currentSandbox: string | null, + plan: SandboxMessagingPlan, + registry: ConflictRegistry, +): ConflictMatch[] { + return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); +} + /** * Detect overlaps across all entries, returning each pair at most once. * Used by `nemoclaw status` to surface sandboxes that already share a token. @@ -346,6 +404,16 @@ export function detectAllOverlapsInEntries( return overlaps; } +/** + * Registry-backed overlap lookup used by status. + */ +export function findAllOverlaps( + registry: ConflictRegistry, +): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { + const { sandboxes } = registry.listSandboxes(); + return detectAllOverlapsInEntries(sandboxes); +} + /** * For entries missing `messagingChannels`, probe OpenShell to infer which * channels the sandbox was onboarded with, and call `updateEntry` for each @@ -372,8 +440,14 @@ export function backfillLegacyEntryChannels( } catch { state = "error"; } - if (state === "present") { channelPresent = true; break; } - if (state === "error") { probeFailed = true; break; } + if (state === "present") { + channelPresent = true; + break; + } + if (state === "error") { + probeFailed = true; + break; + } } if (probeFailed) break; if (channelPresent) discovered.push(channel); @@ -383,3 +457,21 @@ export function backfillLegacyEntryChannels( } } } + +/** + * Backfill legacy registry entries using built-in manifest provider names. + */ +export function backfillMessagingChannels( + registry: ConflictRegistry, + probe: MessagingConflictProbe, +): void { + const { sandboxes } = registry.listSandboxes(); + backfillLegacyEntryChannels( + sandboxes, + probe, + (name, channels) => { + registry.updateSandbox(name, { messagingChannels: channels }); + }, + PROVIDER_SUFFIXES, + ); +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d72aa20b3e..f8aa511a4c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2777,7 +2777,7 @@ async function createSandbox( const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; if (hasPlanCredentials) { const { backfillMessagingChannels, findChannelConflictsFromPlan, createMessagingConflictProbe } = - require("./messaging-conflict") as typeof import("./messaging-conflict"); + require("./messaging/applier") as typeof import("./messaging/applier"); const probe = createMessagingConflictProbe({ checkGatewayLiveness: () => runOpenshell(["sandbox", "list"], { ignoreError: true, suppressOutput: true }).status === 0, diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index dcb20832cf..db1ed6ea42 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import { getNamedGatewayLifecycleState } from "./gateway-runtime-action"; import { getLiveGatewayInference } from "./inference/live"; import type { GatewayHealth, MessagingBridgeHealth, ShowStatusCommandDeps } from "./inventory"; -import { backfillMessagingChannels, findAllOverlaps } from "./messaging-conflict"; +import { backfillMessagingChannels, findAllOverlaps } from "./messaging/applier"; import type { CaptureOpenshellResult } from "./adapters/openshell/client"; import { captureOpenshellCommand, stripAnsi } from "./adapters/openshell/client"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; From 50f459ab4d7de6b8352093edccfc9fec20ac0357 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 08:58:32 +0530 Subject: [PATCH 21/25] fix(messaging): fail closed on channel conflict errors --- .../sandbox/policy-channel-conflict.test.ts | 34 ++++++++++ src/lib/actions/sandbox/policy-channel.ts | 40 +++++++++--- .../applier/conflict-detection-entry.test.ts | 63 +++++++++++++++++++ .../messaging/applier/conflict-detection.ts | 14 +++-- 4 files changed, 138 insertions(+), 13 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 1ea99a0ce2..b76f53c17d 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -499,6 +499,40 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { expect(updateSandboxMock).toHaveBeenCalledWith("alpha", expect.any(Object)); }); + it("non-interactive add aborts when the conflict check throws", async () => { + arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] }); + getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); + listSandboxesMock.mockImplementation(() => { + throw new Error("malformed messaging plan"); + }); + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + + await expect(addSandboxChannel("alpha", { channel: "telegram" })).rejects.toThrow( + "process.exit(1)", + ); + + const text = loggedText(); + expect(text).toContain("Could not verify messaging channel conflicts"); + expect(text).toContain("rerun with --force"); + expect(upsertMock).not.toHaveBeenCalled(); + }); + + it("--force proceeds when the conflict check throws", async () => { + arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] }); + getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); + listSandboxesMock.mockImplementation(() => { + throw new Error("malformed messaging plan"); + }); + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + + await addSandboxChannel("alpha", { channel: "telegram", force: true }); + + const text = loggedText(); + expect(text).toContain("proceeding without a completed messaging channel conflict check"); + expect(exitMock).not.toHaveBeenCalled(); + expect(upsertMock).toHaveBeenCalledTimes(1); + }); + // Scenario 10 it("never prints the raw token value in any conflict output (proceed path)", async () => { arrangeRegistry({ diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index b07a81d8a4..7f39e295e3 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -385,11 +385,8 @@ function makeChannelsConflictProbe() { // Detect whether another sandbox already uses one of this channel's // credentials. Mirrors the onboard.ts conflict check. Returns true if the // caller should PROCEED with the add, false if it should abort. Never logs -// credential values — only the non-secret hashes computed inline are passed -// to findChannelConflicts. Probe/backfill failures are swallowed -// (non-fatal): backfillMessagingChannels already skips sandboxes whose probe -// errors, so a flaky gateway degrades to "no conflict found" rather than -// blocking the add. +// credential values. Backfill probe failures are non-fatal, but core +// conflict-detection errors fail closed unless --force is set. async function checkChannelAddConflict( sandboxName: string, channelName: string, @@ -401,7 +398,9 @@ async function checkChannelAddConflict( // what planToConflictChannelRequests() produces from bindings. QR-only // channels (e.g. WhatsApp) have no manifest credentials → early exit with no // conflict possible. Unknown channelName → also exits early. - const channelManifest = createBuiltInChannelManifestRegistry().list().find((m) => m.id === channelName); + const channelManifest = createBuiltInChannelManifestRegistry() + .list() + .find((m) => m.id === channelName); if (!channelManifest || channelManifest.credentials.length === 0) return true; const credentialHashes: Record = {}; @@ -423,9 +422,32 @@ async function checkChannelAddConflict( let conflicts: ReturnType; try { - conflicts = findChannelConflicts(sandboxName, [{ channel: channelName, credentialHashes }], registry); - } catch { - return true; + conflicts = findChannelConflicts( + sandboxName, + [{ channel: channelName, credentialHashes }], + registry, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(` Could not verify messaging channel conflicts for ${channelName}: ${message}`); + if (force) { + console.log(" --force: proceeding without a completed messaging channel conflict check."); + return true; + } + if (isNonInteractive()) { + console.error( + ` Aborting: rerun with --force to skip the messaging channel conflict check for ${channelName}.`, + ); + process.exit(1); + } + const answer = ( + await askPrompt(" Continue without a completed conflict check? [y/N]: ") + ) + .trim() + .toLowerCase(); + if (answer === "y" || answer === "yes") return true; + console.log(" Aborting channel add."); + return false; } if (conflicts.length === 0) return true; diff --git a/src/lib/messaging/applier/conflict-detection-entry.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts index 1f63f91d1f..c5bb1e1e58 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -67,6 +67,20 @@ function slackChannel() { }; } +function discordChannel() { + return { + channelId: "discord" as const, + displayName: "Discord", + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }; +} + function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { return { channelId: "telegram", @@ -80,6 +94,19 @@ function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][nu }; } +function discordBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { + return { + channelId: "discord", + credentialId: "discordBotToken", + sourceInput: "botToken", + providerName: "sb-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + credentialAvailable: true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + function slackBindings(botHash?: string, appHash?: string) { return [ { @@ -224,6 +251,42 @@ describe("conflictReasonForPair", () => { expect(conflictReasonForPair("telegram", alice, bob)).toBeNull(); }); + it("detects matching-token between two Discord plan-backed entries", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + channels: [discordChannel()], + credentialBindings: [discordBinding("hash-discord")], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { + channels: [discordChannel()], + credentialBindings: [discordBinding("hash-discord")], + }), + ); + expect(conflictReasonForPair("discord", alice, bob)).toBe("matching-token"); + }); + + it("returns null when Discord plan-backed hashes differ", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + channels: [discordChannel()], + credentialBindings: [discordBinding("hash-discord-a")], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { + channels: [discordChannel()], + credentialBindings: [discordBinding("hash-discord-b")], + }), + ); + expect(conflictReasonForPair("discord", alice, bob)).toBeNull(); + }); + it("scopes comparison to the requested channel, ignoring other channels", () => { const alice = planEntry( "alice", diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index 7ee07a4bfe..37bbd782a8 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -185,9 +185,12 @@ export function planToConflictChannelRequests(plan: SandboxMessagingPlan): Confl /** * Return the active (non-disabled) channel IDs for a registry entry. - * Uses `entry.messaging.plan` when available; falls back to the legacy - * `messagingChannels`/`disabledChannels` flat fields for pre-plan entries. - * Returns `null` when the entry has neither. + * Uses `entry.messaging.plan` when available. Pre-plan registry entries are + * supported only for channel presence via the legacy + * `messagingChannels`/`disabledChannels` flat fields; legacy credential hashes + * are deliberately not recovered. Remove this branch when flat pre-plan + * messaging registry fields are no longer supported. Returns `null` when the + * entry has neither shape. */ export function resolveActiveChannelsFromEntry( entry: ConflictRegistryEntry, @@ -459,7 +462,10 @@ export function backfillLegacyEntryChannels( } /** - * Backfill legacy registry entries using built-in manifest provider names. + * Backfill pre-plan registry entries using built-in manifest provider names. + * This infers channel presence only; it must not restore legacy credential + * hashes. Remove with the `messagingChannels`/`disabledChannels` fallback once + * pre-plan registry rows are no longer supported. */ export function backfillMessagingChannels( registry: ConflictRegistry, From b2031d9e3c629f069ffab66e87707d9f8ddc3d6f Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 09:34:33 +0530 Subject: [PATCH 22/25] refactor(messaging): split conflict detection modules --- .../applier/conflict-detection-backfill.ts | 94 ++++ .../applier/conflict-detection-entry.test.ts | 431 ++-------------- .../applier/conflict-detection-entry.ts | 229 +++++++++ .../applier/conflict-detection-legacy.test.ts | 2 +- .../applier/conflict-detection-manifest.ts | 20 + ...onflict-detection-multi-credential.test.ts | 92 ++++ .../conflict-detection-overlap.test.ts | 78 +++ .../applier/conflict-detection-plan.test.ts | 228 ++------- .../applier/conflict-detection-plan.ts | 59 +++ .../applier/conflict-detection-types.ts | 53 ++ .../messaging/applier/conflict-detection.ts | 484 +----------------- test/helpers/messaging-conflict-fixtures.ts | 150 ++++++ 12 files changed, 868 insertions(+), 1052 deletions(-) create mode 100644 src/lib/messaging/applier/conflict-detection-backfill.ts create mode 100644 src/lib/messaging/applier/conflict-detection-entry.ts create mode 100644 src/lib/messaging/applier/conflict-detection-manifest.ts create mode 100644 src/lib/messaging/applier/conflict-detection-multi-credential.test.ts create mode 100644 src/lib/messaging/applier/conflict-detection-overlap.test.ts create mode 100644 src/lib/messaging/applier/conflict-detection-plan.ts create mode 100644 src/lib/messaging/applier/conflict-detection-types.ts create mode 100644 test/helpers/messaging-conflict-fixtures.ts diff --git a/src/lib/messaging/applier/conflict-detection-backfill.ts b/src/lib/messaging/applier/conflict-detection-backfill.ts new file mode 100644 index 0000000000..686269394c --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-backfill.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PROVIDER_SUFFIXES } from "./conflict-detection-manifest"; +import type { + ConflictRegistry, + ConflictRegistryEntry, + MessagingConflictProbe, + MessagingConflictProbeGatewayDeps, + ProbeResult, +} from "./conflict-detection-types"; + +/** + * Build a tri-state `MessagingConflictProbe` from plain openshell runner deps. + * + * The liveness result is cached so `sandbox list` is issued at most once per + * probe instance. A transient gateway failure returns error instead of absent, + * preventing a flaky gateway from being persisted as no providers. + */ +export function createMessagingConflictProbe( + deps: MessagingConflictProbeGatewayDeps, +): MessagingConflictProbe { + let alive: boolean | null = null; + return { + providerExists: (name) => { + if (alive === null) alive = deps.checkGatewayLiveness(); + if (!alive) return "error"; + return deps.providerExists(name) ? "present" : "absent"; + }, + }; +} + +/** + * For entries missing `messagingChannels`, probe OpenShell to infer which + * channels the sandbox was onboarded with. Safe to call repeatedly. Probe + * errors abort the write for that sandbox so future calls can retry. + */ +export function backfillLegacyEntryChannels( + entries: readonly ConflictRegistryEntry[], + probe: MessagingConflictProbe, + updateEntry: (name: string, channels: string[]) => void, + providerSuffixes: Record, +): void { + for (const entry of entries) { + if (Array.isArray(entry.messagingChannels)) continue; + const discovered: string[] = []; + let probeFailed = false; + for (const channel of Object.keys(providerSuffixes)) { + let channelPresent = false; + for (const suffix of providerSuffixes[channel]) { + let state: ProbeResult; + try { + state = probe.providerExists(`${entry.name}${suffix}`); + } catch { + state = "error"; + } + if (state === "present") { + channelPresent = true; + break; + } + if (state === "error") { + probeFailed = true; + break; + } + } + if (probeFailed) break; + if (channelPresent) discovered.push(channel); + } + if (!probeFailed) { + updateEntry(entry.name, discovered); + } + } +} + +/** + * Backfill pre-plan registry entries using built-in manifest provider names. + * This infers channel presence only; it must not restore legacy credential + * hashes. Remove with the `messagingChannels`/`disabledChannels` fallback once + * pre-plan registry rows are no longer supported. + */ +export function backfillMessagingChannels( + registry: ConflictRegistry, + probe: MessagingConflictProbe, +): void { + const { sandboxes } = registry.listSandboxes(); + backfillLegacyEntryChannels( + sandboxes, + probe, + (name, channels) => { + registry.updateSandbox(name, { messagingChannels: channels }); + }, + PROVIDER_SUFFIXES, + ); +} diff --git a/src/lib/messaging/applier/conflict-detection-entry.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts index c5bb1e1e58..8d29958706 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -3,168 +3,47 @@ import { describe, expect, it } from "vitest"; -import type { SandboxMessagingPlan } from "../manifest"; -import type { SandboxMessagingState } from "../../state/registry"; +import { + discordBinding, + discordChannel, + makePlan, + planEntry, + slackBindings, + slackChannel, + tgBinding, + tgChannel, +} from "../../../../test/helpers/messaging-conflict-fixtures"; import { conflictReasonForPair, conflictReasonForRequest, - detectAllOverlapsInEntries, - findConflictsInEntries, hasStoredChannelInEntry, - type ConflictRegistryEntry, } from "./conflict-detection"; -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -function makePlan( - sandboxName: string, - overrides: Partial = {}, -): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName, - agent: "openclaw", - workflow: "onboard", - channels: [], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - ...overrides, - }; -} - -function tgChannel(active = true, disabled = false) { - return { - channelId: "telegram" as const, - displayName: "Telegram", - authMode: "token-paste" as const, - active, - selected: true, - configured: true, - disabled, - inputs: [], - hooks: [], - }; -} - -function slackChannel() { - return { - channelId: "slack" as const, - displayName: "Slack", - authMode: "token-paste" as const, - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }; -} - -function discordChannel() { - return { - channelId: "discord" as const, - displayName: "Discord", - authMode: "token-paste" as const, - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }; -} - -function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { - return { - channelId: "telegram", - credentialId: "telegramBotToken", - sourceInput: "botToken", - providerName: "sb-telegram-bridge", - providerEnvKey: "TELEGRAM_BOT_TOKEN", - placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - credentialAvailable: true, - ...(hash !== undefined ? { credentialHash: hash } : {}), - }; -} - -function discordBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { - return { - channelId: "discord", - credentialId: "discordBotToken", - sourceInput: "botToken", - providerName: "sb-discord-bridge", - providerEnvKey: "DISCORD_BOT_TOKEN", - placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", - credentialAvailable: true, - ...(hash !== undefined ? { credentialHash: hash } : {}), - }; -} - -function slackBindings(botHash?: string, appHash?: string) { - return [ - { - channelId: "slack" as const, - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "sb-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, - ...(botHash ? { credentialHash: botHash } : {}), - }, - { - channelId: "slack" as const, - credentialId: "slackAppToken", - sourceInput: "appToken", - providerName: "sb-slack-app", - providerEnvKey: "SLACK_APP_TOKEN", - placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", - credentialAvailable: true, - ...(appHash ? { credentialHash: appHash } : {}), - }, - ]; -} - -function planEntry(name: string, plan: SandboxMessagingPlan): ConflictRegistryEntry { - const state: SandboxMessagingState = { schemaVersion: 1, plan }; - return { name, messaging: state }; -} - -// --------------------------------------------------------------------------- -// hasStoredChannelInEntry -// --------------------------------------------------------------------------- - describe("hasStoredChannelInEntry", () => { it("returns true for an active channel in a plan-backed entry", () => { const entry = planEntry("sb", makePlan("sb", { channels: [tgChannel()] })); expect(hasStoredChannelInEntry(entry, "telegram")).toBe(true); }); - it("returns false when channel is in plan.disabledChannels", () => { - const entry = planEntry( - "sb", - makePlan("sb", { disabledChannels: ["telegram"], channels: [tgChannel(true, true)] }), - ); - expect(hasStoredChannelInEntry(entry, "telegram")).toBe(false); - }); - - it("returns false when channel.active is false", () => { - const entry = planEntry("sb", makePlan("sb", { channels: [tgChannel(false, false)] })); - expect(hasStoredChannelInEntry(entry, "telegram")).toBe(false); + it("returns false for disabled or inactive plan channels", () => { + expect( + hasStoredChannelInEntry( + planEntry( + "sb", + makePlan("sb", { disabledChannels: ["telegram"], channels: [tgChannel(true, true)] }), + ), + "telegram", + ), + ).toBe(false); + expect( + hasStoredChannelInEntry( + planEntry("sb", makePlan("sb", { channels: [tgChannel(false, false)] })), + "telegram", + ), + ).toBe(false); }); }); -// --------------------------------------------------------------------------- -// conflictReasonForRequest -// --------------------------------------------------------------------------- - describe("conflictReasonForRequest", () => { it("detects matching-token when same channel hash matches", () => { const entry = planEntry( @@ -222,10 +101,6 @@ describe("conflictReasonForRequest", () => { }); }); -// --------------------------------------------------------------------------- -// conflictReasonForPair -// --------------------------------------------------------------------------- - describe("conflictReasonForPair", () => { it("detects matching-token between two plan-backed entries", () => { const alice = planEntry( @@ -251,43 +126,34 @@ describe("conflictReasonForPair", () => { expect(conflictReasonForPair("telegram", alice, bob)).toBeNull(); }); - it("detects matching-token between two Discord plan-backed entries", () => { - const alice = planEntry( + it("covers Discord matching and distinct plan-backed hashes", () => { + const matchingAlice = planEntry( "alice", makePlan("alice", { channels: [discordChannel()], credentialBindings: [discordBinding("hash-discord")], }), ); - const bob = planEntry( + const matchingBob = planEntry( "bob", makePlan("bob", { channels: [discordChannel()], credentialBindings: [discordBinding("hash-discord")], }), ); - expect(conflictReasonForPair("discord", alice, bob)).toBe("matching-token"); - }); + expect(conflictReasonForPair("discord", matchingAlice, matchingBob)).toBe("matching-token"); - it("returns null when Discord plan-backed hashes differ", () => { - const alice = planEntry( - "alice", - makePlan("alice", { - channels: [discordChannel()], - credentialBindings: [discordBinding("hash-discord-a")], - }), - ); - const bob = planEntry( + const distinctBob = planEntry( "bob", makePlan("bob", { channels: [discordChannel()], credentialBindings: [discordBinding("hash-discord-b")], }), ); - expect(conflictReasonForPair("discord", alice, bob)).toBeNull(); + expect(conflictReasonForPair("discord", matchingAlice, distinctBob)).toBeNull(); }); - it("scopes comparison to the requested channel, ignoring other channels", () => { + it("scopes comparison to the requested channel", () => { const alice = planEntry( "alice", makePlan("alice", { @@ -306,234 +172,3 @@ describe("conflictReasonForPair", () => { expect(conflictReasonForPair("slack", alice, bob)).toBe("matching-token"); }); }); - -// --------------------------------------------------------------------------- -// findConflictsInEntries -// --------------------------------------------------------------------------- - -describe("findConflictsInEntries", () => { - it("detects matching-token against a plan-only entry", () => { - const alice = planEntry( - "alice", - makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), - ); - expect( - findConflictsInEntries( - "bob", - [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], - [alice], - ), - ).toEqual([{ channel: "telegram", sandbox: "alice", reason: "matching-token" }]); - }); - - it("ignores a disabled channel in a plan-backed entry", () => { - const alice = planEntry( - "alice", - makePlan("alice", { - disabledChannels: ["telegram"], - channels: [tgChannel(true, true)], - credentialBindings: [tgBinding("hash-a")], - }), - ); - expect( - findConflictsInEntries( - "bob", - [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], - [alice], - ), - ).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// detectAllOverlapsInEntries -// --------------------------------------------------------------------------- - -describe("detectAllOverlapsInEntries", () => { - it("reports matching-token overlap between two plan-backed entries", () => { - const alice = planEntry( - "alice", - makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), - ); - const bob = planEntry( - "bob", - makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), - ); - expect(detectAllOverlapsInEntries([alice, bob])).toEqual([ - { channel: "telegram", sandboxes: ["alice", "bob"], reason: "matching-token" }, - ]); - }); - - it("does not report overlap when shared channel is disabled in one plan", () => { - const alice = planEntry( - "alice", - makePlan("alice", { - disabledChannels: ["telegram"], - channels: [tgChannel(true, true)], - credentialBindings: [tgBinding("hash-a")], - }), - ); - const bob = planEntry( - "bob", - makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), - ); - expect(detectAllOverlapsInEntries([alice, bob])).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// Multi-credential channel partial-hash suppression (Slack SLACK_BOT_TOKEN + -// SLACK_APP_TOKEN). Both manifest keys are required; a differing bot token -// with a missing app token must NOT return null — it must return unknown-token. -// --------------------------------------------------------------------------- - -describe("multi-credential channel partial hash suppression", () => { - it("conflictReasonForRequest — returns unknown-token when Slack bot tokens differ but app token is missing from stored plan", () => { - const entry = planEntry( - "alice", - makePlan("alice", { - channels: [slackChannel()], - credentialBindings: [ - { - channelId: "slack", - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "alice-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, - credentialHash: "hash-bot-a", - }, - // No SLACK_APP_TOKEN binding - ], - }), - ); - expect( - conflictReasonForRequest(entry, { - channel: "slack", - credentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-x" }, - }), - ).toBe("unknown-token"); // bot tokens differ but app token unknown → conservative - }); - - it("conflictReasonForRequest — returns null when both Slack tokens are present and both differ", () => { - const entry = planEntry( - "alice", - makePlan("alice", { - channels: [slackChannel()], - credentialBindings: [ - { - channelId: "slack", - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "alice-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, - credentialHash: "hash-bot-a", - }, - { - channelId: "slack", - credentialId: "slackAppToken", - sourceInput: "appToken", - providerName: "alice-slack-app", - providerEnvKey: "SLACK_APP_TOKEN", - placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", - credentialAvailable: true, - credentialHash: "hash-app-a", - }, - ], - }), - ); - expect( - conflictReasonForRequest(entry, { - channel: "slack", - credentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-b" }, - }), - ).toBeNull(); - }); - - it("conflictReasonForPair — returns unknown-token when Slack bot tokens differ but app token is absent from both plans", () => { - const alice = planEntry( - "alice", - makePlan("alice", { - channels: [slackChannel()], - credentialBindings: [ - { - channelId: "slack", - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "alice-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, - credentialHash: "hash-bot-a", - }, - ], - }), - ); - const bob = planEntry( - "bob", - makePlan("bob", { - channels: [slackChannel()], - credentialBindings: [ - { - channelId: "slack", - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "bob-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, - credentialHash: "hash-bot-b", - }, - ], - }), - ); - expect(conflictReasonForPair("slack", alice, bob)).toBe("unknown-token"); - }); - - it("conflictReasonForPair — returns null when both Slack tokens are present and both differ", () => { - const alice = planEntry( - "alice", - makePlan("alice", { - channels: [slackChannel()], - credentialBindings: [ - { - channelId: "slack", credentialId: "slackBotToken", sourceInput: "botToken", - providerName: "alice-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, credentialHash: "hash-bot-a", - }, - { - channelId: "slack", credentialId: "slackAppToken", sourceInput: "appToken", - providerName: "alice-slack-app", providerEnvKey: "SLACK_APP_TOKEN", - placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", - credentialAvailable: true, credentialHash: "hash-app-a", - }, - ], - }), - ); - const bob = planEntry( - "bob", - makePlan("bob", { - channels: [slackChannel()], - credentialBindings: [ - { - channelId: "slack", credentialId: "slackBotToken", sourceInput: "botToken", - providerName: "bob-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, credentialHash: "hash-bot-b", - }, - { - channelId: "slack", credentialId: "slackAppToken", sourceInput: "appToken", - providerName: "bob-slack-app", providerEnvKey: "SLACK_APP_TOKEN", - placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", - credentialAvailable: true, credentialHash: "hash-app-b", - }, - ], - }), - ); - expect(conflictReasonForPair("slack", alice, bob)).toBeNull(); - }); -}); diff --git a/src/lib/messaging/applier/conflict-detection-entry.ts b/src/lib/messaging/applier/conflict-detection-entry.ts new file mode 100644 index 0000000000..e506a2dc78 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-entry.ts @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../manifest"; +import { CHANNEL_CREDENTIAL_ENV_KEYS } from "./conflict-detection-manifest"; +import { + getActiveChannelIdsFromPlan, + getCredentialHashesFromPlan, + planToConflictChannelRequests, +} from "./conflict-detection-plan"; +import type { + ChannelConflictRequest, + ConflictMatch, + ConflictReason, + ConflictRegistry, + ConflictRegistryEntry, + ConflictRequest, +} from "./conflict-detection-types"; + +function normalizeRequest(request: ChannelConflictRequest): ConflictRequest | null { + if (typeof request === "string") { + return request ? { channel: request, credentialHashes: {} } : null; + } + if (!request || typeof request.channel !== "string" || request.channel.length === 0) return null; + return request; +} + +/** + * Return the active channel IDs for a registry entry. + * + * Uses `entry.messaging.plan` when available. Pre-plan registry entries are + * supported only for channel presence via the legacy + * `messagingChannels`/`disabledChannels` flat fields; legacy credential hashes + * are deliberately not recovered. Remove this branch when flat pre-plan + * messaging registry fields are no longer supported. Returns `null` when the + * entry has neither shape. + */ +export function resolveActiveChannelsFromEntry( + entry: ConflictRegistryEntry, +): string[] | null { + if (entry.messaging?.plan) { + return getActiveChannelIdsFromPlan(entry.messaging.plan); + } + if (!Array.isArray(entry.messagingChannels)) return null; + const disabled = new Set(Array.isArray(entry.disabledChannels) ? entry.disabledChannels : []); + return (entry.messagingChannels as string[]).filter((c) => !disabled.has(c)); +} + +function resolveChannelHashesFromEntry( + entry: ConflictRegistryEntry, + channelId: string, +): Record { + if (entry.messaging?.plan) { + return getCredentialHashesFromPlan(entry.messaging.plan, channelId); + } + return {}; +} + +/** + * True when `channel` is active in `entry`. + * Disabled channels must not block another sandbox from claiming the same + * token because the bridge is paused. + */ +export function hasStoredChannelInEntry( + entry: ConflictRegistryEntry, + channel: string, +): boolean { + return resolveActiveChannelsFromEntry(entry)?.includes(channel) ?? false; +} + +function comparisonKeys( + channel: string, + storedHashes: Record, + requestedHashes: Record, +): string[] { + const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[channel]; + if (manifestKeys && manifestKeys.length > 0) return [...manifestKeys]; + if (Object.keys(storedHashes).length > 0) return Object.keys(storedHashes); + return Object.keys(requestedHashes); +} + +/** + * Determine the conflict reason between stored entry state and a new channel + * request, or `null` if there is no conflict. + */ +export function conflictReasonForRequest( + entry: ConflictRegistryEntry, + request: ConflictRequest, +): ConflictReason | null { + if (!hasStoredChannelInEntry(entry, request.channel)) return null; + const requestedHashes = request.credentialHashes ?? {}; + const storedHashes = resolveChannelHashesFromEntry(entry, request.channel); + const keys = comparisonKeys(request.channel, storedHashes, requestedHashes); + if (keys.length === 0) return "unknown-token"; + + let sawUnknown = false; + for (const key of keys) { + const rh = (requestedHashes[key] as string | null | undefined) ?? null; + const sh = storedHashes[key] ?? null; + if (rh && sh) { + if (rh === sh) return "matching-token"; + continue; + } + sawUnknown = true; + } + return sawUnknown ? "unknown-token" : null; +} + +/** + * Determine the conflict reason between two registry entries sharing a channel, + * or `null` if there is no conflict. + */ +export function conflictReasonForPair( + channel: string, + left: ConflictRegistryEntry, + right: ConflictRegistryEntry, +): ConflictReason | null { + if (!hasStoredChannelInEntry(left, channel) || !hasStoredChannelInEntry(right, channel)) { + return null; + } + const lh = resolveChannelHashesFromEntry(left, channel); + const rh = resolveChannelHashesFromEntry(right, channel); + const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[channel]; + const keys = + manifestKeys && manifestKeys.length > 0 + ? [...manifestKeys] + : [...new Set([...Object.keys(lh), ...Object.keys(rh)])]; + if (keys.length === 0) return "unknown-token"; + + let sawUnknown = false; + for (const key of keys) { + const l = lh[key] ?? null; + const r = rh[key] ?? null; + if (l && r) { + if (l === r) return "matching-token"; + continue; + } + sawUnknown = true; + } + return sawUnknown ? "unknown-token" : null; +} + +/** + * Return every requested channel where another sandbox already has a matching + * credential hash or insufficient hash metadata to prove it differs. + */ +export function findConflictsInEntries( + currentSandbox: string | null, + requests: readonly ConflictRequest[], + entries: readonly ConflictRegistryEntry[], +): ConflictMatch[] { + const others = entries.filter( + (e) => + e.name !== currentSandbox && + (Array.isArray(e.messagingChannels) || e.messaging?.plan != null), + ); + return requests.flatMap((request) => + others.flatMap((entry) => { + const reason = conflictReasonForRequest(entry, request); + return reason ? [{ channel: request.channel, sandbox: entry.name, reason }] : []; + }), + ); +} + +export function findChannelConflicts( + currentSandbox: string | null, + enabledChannels: ChannelConflictRequest[], + registry: ConflictRegistry, +): ConflictMatch[] { + if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; + const requests = enabledChannels + .map(normalizeRequest) + .filter((request): request is ConflictRequest => request !== null); + if (requests.length === 0) return []; + const { sandboxes } = registry.listSandboxes(); + return findConflictsInEntries(currentSandbox, requests, sandboxes); +} + +export function findChannelConflictsFromPlan( + currentSandbox: string | null, + plan: SandboxMessagingPlan, + registry: ConflictRegistry, +): ConflictMatch[] { + return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); +} + +export function detectAllOverlapsInEntries( + entries: readonly ConflictRegistryEntry[], +): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { + const byChannel = new Map(); + for (const entry of entries) { + const activeChannels = resolveActiveChannelsFromEntry(entry); + if (!activeChannels) continue; + for (const channel of activeChannels) { + const list = byChannel.get(channel) ?? []; + list.push(entry); + byChannel.set(channel, list); + } + } + + const overlaps: Array<{ + channel: string; + sandboxes: [string, string]; + reason: ConflictReason; + }> = []; + for (const [channel, channelEntries] of byChannel) { + if (channelEntries.length < 2) continue; + for (let i = 0; i < channelEntries.length; i += 1) { + for (let j = i + 1; j < channelEntries.length; j += 1) { + const reason = conflictReasonForPair(channel, channelEntries[i], channelEntries[j]); + if (reason) { + overlaps.push({ + channel, + sandboxes: [channelEntries[i].name, channelEntries[j].name], + reason, + }); + } + } + } + } + return overlaps; +} + +export function findAllOverlaps( + registry: ConflictRegistry, +): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { + const { sandboxes } = registry.listSandboxes(); + return detectAllOverlapsInEntries(sandboxes); +} diff --git a/src/lib/messaging/applier/conflict-detection-legacy.test.ts b/src/lib/messaging/applier/conflict-detection-legacy.test.ts index a4b2a0a9b5..ecda958397 100644 --- a/src/lib/messaging/applier/conflict-detection-legacy.test.ts +++ b/src/lib/messaging/applier/conflict-detection-legacy.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // Legacy-field (messagingChannels / disabledChannels) conflict tests. -// Hash-precise (plan-backed) tests live in src/lib/messaging/applier/conflict-detection-entry.test.ts +// Hash-precise plan-backed tests are split across conflict-detection-entry, conflict-detection-overlap, and conflict-detection-multi-credential tests import { describe, expect, it, vi } from "vitest"; diff --git a/src/lib/messaging/applier/conflict-detection-manifest.ts b/src/lib/messaging/applier/conflict-detection-manifest.ts new file mode 100644 index 0000000000..572059551b --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-manifest.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BUILT_IN_CHANNEL_MANIFESTS } from "../channels"; + +// Map channelId to providerEnvKey values declared in built-in manifests. +// The comparison layer uses this as the primary key set so a missing hash for +// one required credential conservatively reports unknown-token. +export const CHANNEL_CREDENTIAL_ENV_KEYS: Readonly> = + Object.fromEntries( + BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]), + ); + +export const PROVIDER_SUFFIXES: Record = Object.fromEntries( + BUILT_IN_CHANNEL_MANIFESTS.flatMap((m) => { + const suffixes = m.credentials.map((c) => c.providerName.replace("{sandboxName}", "")); + if (suffixes.length === 0) return []; + return [[m.id, suffixes]]; + }), +); diff --git a/src/lib/messaging/applier/conflict-detection-multi-credential.test.ts b/src/lib/messaging/applier/conflict-detection-multi-credential.test.ts new file mode 100644 index 0000000000..0857d8fb09 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-multi-credential.test.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + makePlan, + planEntry, + slackAppBinding, + slackBotBinding, + slackChannel, +} from "../../../../test/helpers/messaging-conflict-fixtures"; +import { conflictReasonForPair, conflictReasonForRequest } from "./conflict-detection"; + +describe("multi-credential channel partial hash suppression", () => { + it("request comparison returns unknown-token when Slack app token is missing", () => { + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [slackBotBinding("hash-bot-a", "alice")], + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "slack", + credentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-x" }, + }), + ).toBe("unknown-token"); + }); + + it("request comparison returns null when both Slack token hashes differ", () => { + const entry = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [ + slackBotBinding("hash-bot-a", "alice"), + slackAppBinding("hash-app-a", "alice"), + ], + }), + ); + expect( + conflictReasonForRequest(entry, { + channel: "slack", + credentialHashes: { SLACK_BOT_TOKEN: "hash-bot-b", SLACK_APP_TOKEN: "hash-app-b" }, + }), + ).toBeNull(); + }); + + it("pair comparison returns unknown-token when Slack app token is absent from both plans", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [slackBotBinding("hash-bot-a", "alice")], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { + channels: [slackChannel()], + credentialBindings: [slackBotBinding("hash-bot-b", "bob")], + }), + ); + expect(conflictReasonForPair("slack", alice, bob)).toBe("unknown-token"); + }); + + it("pair comparison returns null when both Slack token hashes differ", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: [ + slackBotBinding("hash-bot-a", "alice"), + slackAppBinding("hash-app-a", "alice"), + ], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { + channels: [slackChannel()], + credentialBindings: [ + slackBotBinding("hash-bot-b", "bob"), + slackAppBinding("hash-app-b", "bob"), + ], + }), + ); + expect(conflictReasonForPair("slack", alice, bob)).toBeNull(); + }); +}); diff --git a/src/lib/messaging/applier/conflict-detection-overlap.test.ts b/src/lib/messaging/applier/conflict-detection-overlap.test.ts new file mode 100644 index 0000000000..d7631a5991 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-overlap.test.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + makePlan, + planEntry, + tgBinding, + tgChannel, +} from "../../../../test/helpers/messaging-conflict-fixtures"; +import { detectAllOverlapsInEntries, findConflictsInEntries } from "./conflict-detection"; + +describe("findConflictsInEntries", () => { + it("detects matching-token against a plan-only entry", () => { + const alice = planEntry( + "alice", + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + expect( + findConflictsInEntries( + "bob", + [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], + [alice], + ), + ).toEqual([{ channel: "telegram", sandbox: "alice", reason: "matching-token" }]); + }); + + it("ignores a disabled channel in a plan-backed entry", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + disabledChannels: ["telegram"], + channels: [tgChannel(true, true)], + credentialBindings: [tgBinding("hash-a")], + }), + ); + expect( + findConflictsInEntries( + "bob", + [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], + [alice], + ), + ).toEqual([]); + }); +}); + +describe("detectAllOverlapsInEntries", () => { + it("reports matching-token overlap between two plan-backed entries", () => { + const alice = planEntry( + "alice", + makePlan("alice", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + expect(detectAllOverlapsInEntries([alice, bob])).toEqual([ + { channel: "telegram", sandboxes: ["alice", "bob"], reason: "matching-token" }, + ]); + }); + + it("does not report overlap when shared channel is disabled in one plan", () => { + const alice = planEntry( + "alice", + makePlan("alice", { + disabledChannels: ["telegram"], + channels: [tgChannel(true, true)], + credentialBindings: [tgBinding("hash-a")], + }), + ); + const bob = planEntry( + "bob", + makePlan("bob", { channels: [tgChannel()], credentialBindings: [tgBinding("hash-a")] }), + ); + expect(detectAllOverlapsInEntries([alice, bob])).toEqual([]); + }); +}); diff --git a/src/lib/messaging/applier/conflict-detection-plan.test.ts b/src/lib/messaging/applier/conflict-detection-plan.test.ts index 0bbc1c06b2..94a9c47273 100644 --- a/src/lib/messaging/applier/conflict-detection-plan.test.ts +++ b/src/lib/messaging/applier/conflict-detection-plan.test.ts @@ -3,137 +3,37 @@ import { describe, expect, it } from "vitest"; -import type { SandboxMessagingPlan } from "../manifest"; +import { + makePlan, + slackBindings, + slackChannel, + tgBinding, + tgChannel, + whatsappChannel, +} from "../../../../test/helpers/messaging-conflict-fixtures"; import { getActiveChannelIdsFromPlan, getCredentialHashesFromPlan, planToConflictChannelRequests, } from "./conflict-detection"; -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -function makePlan( - sandboxName: string, - overrides: Partial = {}, -): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName, - agent: "openclaw", - workflow: "onboard", - channels: [], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - ...overrides, - }; -} - -function tgChannel(active = true, disabled = false) { - return { - channelId: "telegram" as const, - displayName: "Telegram", - authMode: "token-paste" as const, - active, - selected: true, - configured: true, - disabled, - inputs: [], - hooks: [], - }; -} - -function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { - return { - channelId: "telegram", - credentialId: "telegramBotToken", - sourceInput: "botToken", - providerName: "sb-telegram-bridge", - providerEnvKey: "TELEGRAM_BOT_TOKEN", - placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - credentialAvailable: true, - ...(hash !== undefined ? { credentialHash: hash } : {}), - }; -} - -function slackChannel() { - return { - channelId: "slack" as const, - displayName: "Slack", - authMode: "token-paste" as const, - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }; -} - -function slackBindings(botHash?: string, appHash?: string) { - return [ - { - channelId: "slack" as const, - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "sb-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", - credentialAvailable: true, - ...(botHash ? { credentialHash: botHash } : {}), - }, - { - channelId: "slack" as const, - credentialId: "slackAppToken", - sourceInput: "appToken", - providerName: "sb-slack-app", - providerEnvKey: "SLACK_APP_TOKEN", - placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", - credentialAvailable: true, - ...(appHash ? { credentialHash: appHash } : {}), - }, - ]; -} - -// --------------------------------------------------------------------------- -// getActiveChannelIdsFromPlan -// --------------------------------------------------------------------------- - describe("getActiveChannelIdsFromPlan", () => { it("returns active channel ids", () => { const plan = makePlan("sb", { channels: [tgChannel(true, false)] }); expect(getActiveChannelIdsFromPlan(plan)).toEqual(["telegram"]); }); - it("excludes channels in disabledChannels", () => { - const plan = makePlan("sb", { - disabledChannels: ["telegram"], - channels: [tgChannel(true, false)], - }); - expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); - }); - - it("excludes channels where channel.disabled is true", () => { - const plan = makePlan("sb", { channels: [tgChannel(true, true)] }); - expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); - }); - - it("excludes channels where channel.active is false", () => { - const plan = makePlan("sb", { channels: [tgChannel(false, false)] }); - expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + it("excludes disabled and inactive channels", () => { + expect( + getActiveChannelIdsFromPlan( + makePlan("sb", { disabledChannels: ["telegram"], channels: [tgChannel(true, false)] }), + ), + ).toEqual([]); + expect(getActiveChannelIdsFromPlan(makePlan("sb", { channels: [tgChannel(true, true)] }))).toEqual([]); + expect(getActiveChannelIdsFromPlan(makePlan("sb", { channels: [tgChannel(false, false)] }))).toEqual([]); }); }); -// --------------------------------------------------------------------------- -// getCredentialHashesFromPlan -// --------------------------------------------------------------------------- - describe("getCredentialHashesFromPlan", () => { it("returns hashes keyed by providerEnvKey", () => { const plan = makePlan("sb", { credentialBindings: [tgBinding("hash-x")] }); @@ -159,10 +59,6 @@ describe("getCredentialHashesFromPlan", () => { }); }); -// --------------------------------------------------------------------------- -// planToConflictChannelRequests -// --------------------------------------------------------------------------- - describe("planToConflictChannelRequests", () => { it("returns one request per active channel that has a credential available", () => { const plan = makePlan("sb", { @@ -174,71 +70,57 @@ describe("planToConflictChannelRequests", () => { ]); }); - it("includes a channel with credentialAvailable=true but no hash (unknown-token fallback)", () => { - const plan = makePlan("sb", { channels: [tgChannel()], credentialBindings: [tgBinding()] }); - const requests = planToConflictChannelRequests(plan); - expect(requests).toHaveLength(1); - expect(requests[0].channel).toBe("telegram"); - expect(requests[0].credentialHashes).toEqual({}); + it("includes available credentials without a hash for unknown-token fallback", () => { + const requests = planToConflictChannelRequests( + makePlan("sb", { channels: [tgChannel()], credentialBindings: [tgBinding()] }), + ); + expect(requests).toEqual([{ channel: "telegram", credentialHashes: {} }]); }); - it("groups multiple bindings for the same channel (Slack bot + app tokens)", () => { + it("groups multiple bindings for the same channel", () => { const plan = makePlan("sb", { channels: [slackChannel()], credentialBindings: slackBindings("hash-bot", "hash-app"), }); expect(planToConflictChannelRequests(plan)).toEqual([ - { channel: "slack", credentialHashes: { SLACK_BOT_TOKEN: "hash-bot", SLACK_APP_TOKEN: "hash-app" } }, + { + channel: "slack", + credentialHashes: { SLACK_BOT_TOKEN: "hash-bot", SLACK_APP_TOKEN: "hash-app" }, + }, ]); }); - it("skips bindings where credentialAvailable is false", () => { - const plan = makePlan("sb", { - channels: [tgChannel()], - credentialBindings: [{ ...tgBinding("hash-tg"), credentialAvailable: false }], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); - - it("skips channels in disabledChannels", () => { - const plan = makePlan("sb", { - disabledChannels: ["telegram"], - channels: [tgChannel(true, true)], - credentialBindings: [tgBinding("hash-tg")], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); - - it("skips credentialAvailable bindings whose channel is absent from plan.channels", () => { - const plan = makePlan("sb", { credentialBindings: [tgBinding("hash-tg")] }); - expect(planToConflictChannelRequests(plan)).toEqual([]); + it("skips inactive, disabled, unavailable, and absent channel bindings", () => { + expect( + planToConflictChannelRequests( + makePlan("sb", { + channels: [tgChannel()], + credentialBindings: [{ ...tgBinding("hash-tg"), credentialAvailable: false }], + }), + ), + ).toEqual([]); + expect( + planToConflictChannelRequests( + makePlan("sb", { + disabledChannels: ["telegram"], + channels: [tgChannel(true, true)], + credentialBindings: [tgBinding("hash-tg")], + }), + ), + ).toEqual([]); + expect(planToConflictChannelRequests(makePlan("sb", { credentialBindings: [tgBinding("hash-tg")] }))).toEqual([]); + expect( + planToConflictChannelRequests( + makePlan("sb", { + channels: [tgChannel(false, false)], + credentialBindings: [tgBinding("hash-tg")], + }), + ), + ).toEqual([]); }); - it("skips credentialAvailable bindings whose channel.active is false", () => { - const plan = makePlan("sb", { - channels: [tgChannel(false, false)], - credentialBindings: [tgBinding("hash-tg")], - }); - expect(planToConflictChannelRequests(plan)).toEqual([]); - }); - - it("WhatsApp — no-op: empty credentials produce no conflict requests", () => { - const plan = makePlan("sb", { - channels: [ - { - channelId: "whatsapp", - displayName: "WhatsApp", - authMode: "in-sandbox-qr", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [], - }); + it("WhatsApp no-op: empty credentials produce no conflict requests", () => { + const plan = makePlan("sb", { channels: [whatsappChannel()], credentialBindings: [] }); expect(planToConflictChannelRequests(plan)).toEqual([]); }); }); diff --git a/src/lib/messaging/applier/conflict-detection-plan.ts b/src/lib/messaging/applier/conflict-detection-plan.ts new file mode 100644 index 0000000000..7e4b5ce173 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-plan.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../manifest"; +import type { ConflictRequest } from "./conflict-detection-types"; + +/** + * Return the channel IDs that are active (not disabled) in a compiled plan. + * Aligns with `enabledPlanChannels()` in plan-filter.ts: a channel is active + * only when `channel.active && !channel.disabled` and it is not in + * `plan.disabledChannels`. + */ +export function getActiveChannelIdsFromPlan(plan: SandboxMessagingPlan): string[] { + const disabled = new Set(plan.disabledChannels); + return plan.channels + .filter((c) => c.active && !c.disabled && !disabled.has(c.channelId)) + .map((c) => c.channelId); +} + +/** + * Return credential hashes keyed by providerEnvKey from a compiled plan, + * optionally scoped to a single channel. + */ +export function getCredentialHashesFromPlan( + plan: SandboxMessagingPlan, + channelId?: string, +): Record { + const hashes: Record = {}; + for (const b of plan.credentialBindings) { + if (channelId !== undefined && b.channelId !== channelId) continue; + if (b.credentialHash) hashes[b.providerEnvKey] = b.credentialHash; + } + return hashes; +} + +/** + * Build conflict requests from plan credential bindings. + * + * Bindings are grouped by channelId, disabled channels are skipped, and + * credentialAvailable=false bindings are omitted. A binding with no + * credentialHash still produces a channel request so later comparison can use + * conservative unknown-token behavior instead of dropping the channel. + */ +export function planToConflictChannelRequests(plan: SandboxMessagingPlan): ConflictRequest[] { + const activeChannelIds = new Set(getActiveChannelIdsFromPlan(plan)); + const byChannel = new Map>(); + + for (const binding of plan.credentialBindings) { + if (!activeChannelIds.has(binding.channelId) || !binding.credentialAvailable) continue; + const hashes = byChannel.get(binding.channelId) ?? {}; + if (binding.credentialHash) hashes[binding.providerEnvKey] = binding.credentialHash; + byChannel.set(binding.channelId, hashes); + } + + return Array.from(byChannel.entries()).map(([channel, credentialHashes]) => ({ + channel, + credentialHashes, + })); +} diff --git a/src/lib/messaging/applier/conflict-detection-types.ts b/src/lib/messaging/applier/conflict-detection-types.ts new file mode 100644 index 0000000000..e050c30b49 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection-types.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../manifest"; + +export type ProbeResult = "present" | "absent" | "error"; +export type ConflictReason = "matching-token" | "unknown-token"; + +export interface MessagingConflictProbe { + // Tri-state keeps transient gateway errors distinct from missing providers. + providerExists: (name: string) => ProbeResult; +} + +export interface MessagingConflictProbeGatewayDeps { + /** Run `openshell sandbox list`; return true if the gateway answered. */ + checkGatewayLiveness: () => boolean; + /** Check if the named OpenShell provider exists; assumes gateway is alive. */ + providerExists: (name: string) => boolean; +} + +export interface ConflictRequest { + readonly channel: string; + readonly credentialHashes?: Record; +} + +export interface ConflictMatch { + readonly channel: string; + readonly sandbox: string; + readonly reason: ConflictReason; +} + +export type ChannelConflictRequest = + | string + | { channel: string; credentialHashes?: Record }; + +/** + * Minimal shape of a registry entry that conflict detection needs. + * Satisfied by `SandboxEntry` from `./state/registry`. + */ +export interface ConflictRegistryEntry { + readonly name: string; + readonly messaging?: { readonly plan: SandboxMessagingPlan } | null; + readonly messagingChannels?: readonly string[] | null; + readonly disabledChannels?: readonly string[] | null; +} + +export interface ConflictRegistry { + listSandboxes: () => { + sandboxes: ConflictRegistryEntry[]; + defaultSandbox?: string | null; + }; + updateSandbox: (name: string, updates: { messagingChannels?: string[] }) => boolean; +} diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts index 37bbd782a8..115f772641 100644 --- a/src/lib/messaging/applier/conflict-detection.ts +++ b/src/lib/messaging/applier/conflict-detection.ts @@ -1,483 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../manifest"; -import { BUILT_IN_CHANNEL_MANIFESTS } from "../channels"; - -// Map channelId → providerEnvKey values declared in built-in manifests. -// Used as the primary key set for hash comparison so a missing credential for -// one of a channel's required credentials conservatively marks the comparison -// as unknown-token rather than silently returning null. -const CHANNEL_CREDENTIAL_ENV_KEYS: Readonly> = - Object.fromEntries( - BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]), - ); - -const PROVIDER_SUFFIXES: Record = Object.fromEntries( - BUILT_IN_CHANNEL_MANIFESTS.flatMap((m) => { - const suffixes = m.credentials.map((c) => c.providerName.replace("{sandboxName}", "")); - if (suffixes.length === 0) return []; - return [[m.id, suffixes]]; - }), -); - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type ProbeResult = "present" | "absent" | "error"; -export type ConflictReason = "matching-token" | "unknown-token"; - -export interface MessagingConflictProbe { - // Tri-state — "error" is distinct from "absent" so a transient gateway - // failure does not get collapsed into "provider not attached" and then - // persisted as a bogus empty messagingChannels. - providerExists: (name: string) => ProbeResult; -} - -export interface MessagingConflictProbeGatewayDeps { - /** Run `openshell sandbox list`; return true if the gateway answered. */ - checkGatewayLiveness: () => boolean; - /** Check if the named OpenShell provider exists; assumes gateway is alive. */ - providerExists: (name: string) => boolean; -} - -export interface ConflictRequest { - readonly channel: string; - readonly credentialHashes?: Record; -} - -export interface ConflictMatch { - readonly channel: string; - readonly sandbox: string; - readonly reason: ConflictReason; -} - -export type ChannelConflictRequest = - | string - | { channel: string; credentialHashes?: Record }; - -/** - * Minimal shape of a registry entry that conflict detection needs. - * Satisfied by `SandboxEntry` from `./state/registry`. - */ -export interface ConflictRegistryEntry { - readonly name: string; - readonly messaging?: { readonly plan: SandboxMessagingPlan } | null; - readonly messagingChannels?: readonly string[] | null; - readonly disabledChannels?: readonly string[] | null; -} - -export interface ConflictRegistry { - listSandboxes: () => { - sandboxes: ConflictRegistryEntry[]; - defaultSandbox?: string | null; - }; - updateSandbox: (name: string, updates: { messagingChannels?: string[] }) => boolean; -} - -function normalizeRequest(request: ChannelConflictRequest): ConflictRequest | null { - if (typeof request === "string") { - return request ? { channel: request, credentialHashes: {} } : null; - } - if (!request || typeof request.channel !== "string" || request.channel.length === 0) return null; - return request; -} - -// --------------------------------------------------------------------------- -// Probe factory -// --------------------------------------------------------------------------- - -/** - * Build a tri-state `MessagingConflictProbe` from plain openshell runner deps. - * - * The liveness result is cached so the `sandbox list` call is issued at most - * once per probe instance. A transient gateway failure (`checkGatewayLiveness` - * returns false) causes all subsequent `providerExists` calls to return "error" - * rather than "absent", preventing a flaky gateway from being mis-recorded as - * "no providers" and permanently suppressing future backfill retries. - */ -export function createMessagingConflictProbe( - deps: MessagingConflictProbeGatewayDeps, -): MessagingConflictProbe { - let alive: boolean | null = null; - return { - providerExists: (name) => { - if (alive === null) alive = deps.checkGatewayLiveness(); - if (!alive) return "error"; - return deps.providerExists(name) ? "present" : "absent"; - }, - }; -} - -// --------------------------------------------------------------------------- -// Plan-to-request helpers -// --------------------------------------------------------------------------- - -/** - * Return the channel IDs that are active (not disabled) in a compiled plan. - * Aligns with `enabledPlanChannels()` in plan-filter.ts: a channel is active - * only when `channel.active && !channel.disabled` AND it is not in - * `plan.disabledChannels`. - */ -export function getActiveChannelIdsFromPlan(plan: SandboxMessagingPlan): string[] { - const disabled = new Set(plan.disabledChannels); - return plan.channels - .filter((c) => c.active && !c.disabled && !disabled.has(c.channelId)) - .map((c) => c.channelId); -} - -/** - * Return credential hashes keyed by providerEnvKey from a compiled plan, - * optionally scoped to a single channel. - * - * Only bindings that carry a `credentialHash` are included. When `channelId` - * is provided only that channel's bindings are returned, which prevents - * hashes from other channels in the same sandbox from contaminating - * single-channel conflict comparisons. - */ -export function getCredentialHashesFromPlan( - plan: SandboxMessagingPlan, - channelId?: string, -): Record { - const hashes: Record = {}; - for (const b of plan.credentialBindings) { - if (channelId !== undefined && b.channelId !== channelId) continue; - if (b.credentialHash) hashes[b.providerEnvKey] = b.credentialHash; - } - return hashes; -} - -/** - * Build a `ConflictRequest[]` from a compiled plan's credential bindings. - * - * Groups bindings by channelId (e.g. Slack has SLACK_BOT_TOKEN and - * SLACK_APP_TOKEN) and excludes: - * - channels in `plan.disabledChannels` (bridge is paused, not in use) - * - bindings where the credential is not available (`credentialAvailable` - * false) — e.g. WhatsApp, which has no host-side token provider - * - * When a binding has no `credentialHash` (e.g. a registry-only resume that - * did not re-run the compiler), the channel is still included with an empty - * `credentialHashes` map, which falls through to `"unknown-token"` conservative - * detection. - */ -export function planToConflictChannelRequests(plan: SandboxMessagingPlan): ConflictRequest[] { - const activeChannelIds = new Set(getActiveChannelIdsFromPlan(plan)); - const byChannel = new Map>(); - - for (const binding of plan.credentialBindings) { - if (!activeChannelIds.has(binding.channelId) || !binding.credentialAvailable) continue; - const hashes = byChannel.get(binding.channelId) ?? {}; - if (binding.credentialHash) hashes[binding.providerEnvKey] = binding.credentialHash; - byChannel.set(binding.channelId, hashes); - } - - return Array.from(byChannel.entries()).map(([channel, credentialHashes]) => ({ - channel, - credentialHashes, - })); -} - -// --------------------------------------------------------------------------- -// Entry resolution -// --------------------------------------------------------------------------- - -/** - * Return the active (non-disabled) channel IDs for a registry entry. - * Uses `entry.messaging.plan` when available. Pre-plan registry entries are - * supported only for channel presence via the legacy - * `messagingChannels`/`disabledChannels` flat fields; legacy credential hashes - * are deliberately not recovered. Remove this branch when flat pre-plan - * messaging registry fields are no longer supported. Returns `null` when the - * entry has neither shape. - */ -export function resolveActiveChannelsFromEntry( - entry: ConflictRegistryEntry, -): string[] | null { - if (entry.messaging?.plan) { - return getActiveChannelIdsFromPlan(entry.messaging.plan); - } - if (!Array.isArray(entry.messagingChannels)) return null; - const disabled = new Set(Array.isArray(entry.disabledChannels) ? entry.disabledChannels : []); - return (entry.messagingChannels as string[]).filter((c) => !disabled.has(c)); -} - -/** - * Return credential hashes scoped to `channelId` for a registry entry. - * Plan-backed entries return channel-scoped hashes from `getCredentialHashesFromPlan`. - * Legacy entries without a plan return an empty map, which falls through to - * conservative `"unknown-token"` detection in the callers. - */ -function resolveChannelHashesFromEntry( - entry: ConflictRegistryEntry, - channelId: string, -): Record { - if (entry.messaging?.plan) { - return getCredentialHashesFromPlan(entry.messaging.plan, channelId); - } - return {}; -} - -// --------------------------------------------------------------------------- -// Detection — pure functions operating on ConflictRegistryEntry -// --------------------------------------------------------------------------- - -/** - * True when `channel` is active (present and not disabled) in `entry`. - * Disabled channels must not block another sandbox from claiming the same - * token — the bridge is paused so the credential is not in use. - */ -export function hasStoredChannelInEntry( - entry: ConflictRegistryEntry, - channel: string, -): boolean { - return resolveActiveChannelsFromEntry(entry)?.includes(channel) ?? false; -} - -/** - * Determine the conflict reason between `entry`'s stored state and a new - * channel request, or `null` if there is no conflict. - * - * Comparison keys are taken from manifest-declared credentials for the channel - * so that a missing hash for one of multiple required credentials (e.g. Slack's - * SLACK_APP_TOKEN when only SLACK_BOT_TOKEN differs) conservatively marks the - * result as "unknown-token" rather than silently returning null. Falls back to - * the union of present stored/requested keys for channels not in the manifest. - */ -export function conflictReasonForRequest( - entry: ConflictRegistryEntry, - request: ConflictRequest, -): ConflictReason | null { - if (!hasStoredChannelInEntry(entry, request.channel)) return null; - const requestedHashes = request.credentialHashes ?? {}; - const storedHashes = resolveChannelHashesFromEntry(entry, request.channel); - const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[request.channel]; - const keys = - manifestKeys && manifestKeys.length > 0 - ? [...manifestKeys] - : Object.keys(storedHashes).length > 0 - ? Object.keys(storedHashes) - : Object.keys(requestedHashes); - if (keys.length === 0) return "unknown-token"; - - let sawUnknown = false; - for (const key of keys) { - const rh = (requestedHashes[key] as string | null | undefined) ?? null; - const sh = storedHashes[key] ?? null; - if (rh && sh) { - if (rh === sh) return "matching-token"; - continue; - } - sawUnknown = true; - } - return sawUnknown ? "unknown-token" : null; -} - -/** - * Determine the conflict reason between two registry entries sharing `channel`, - * or `null` if there is no conflict. Returns each pair at most once (the - * caller is responsible for ordered iteration). - * - * Comparison keys are taken from manifest-declared credentials for the channel - * so that a missing hash on either side conservatively produces "unknown-token" - * rather than null for multi-credential channels like Slack. - */ -export function conflictReasonForPair( - channel: string, - left: ConflictRegistryEntry, - right: ConflictRegistryEntry, -): ConflictReason | null { - if (!hasStoredChannelInEntry(left, channel) || !hasStoredChannelInEntry(right, channel)) { - return null; - } - const lh = resolveChannelHashesFromEntry(left, channel); - const rh = resolveChannelHashesFromEntry(right, channel); - const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[channel]; - const keys = - manifestKeys && manifestKeys.length > 0 - ? [...manifestKeys] - : [...new Set([...Object.keys(lh), ...Object.keys(rh)])]; - if (keys.length === 0) return "unknown-token"; - - let sawUnknown = false; - for (const key of keys) { - const l = lh[key] ?? null; - const r = rh[key] ?? null; - if (l && r) { - if (l === r) return "matching-token"; - continue; - } - sawUnknown = true; - } - return sawUnknown ? "unknown-token" : null; -} - -/** - * Return every (channel, other-sandbox) pair where another entry already has - * one of the requested channels in use with either a matching credential hash - * or insufficient hash metadata to prove it differs. - */ -export function findConflictsInEntries( - currentSandbox: string | null, - requests: readonly ConflictRequest[], - entries: readonly ConflictRegistryEntry[], -): ConflictMatch[] { - const others = entries.filter( - (e) => - e.name !== currentSandbox && - (Array.isArray(e.messagingChannels) || e.messaging?.plan != null), - ); - return requests.flatMap((request) => - others.flatMap((entry) => { - const reason = conflictReasonForRequest(entry, request); - return reason ? [{ channel: request.channel, sandbox: entry.name, reason }] : []; - }), - ); -} - -/** - * Registry-backed conflict lookup for callers that do not already have a - * compiled plan request list. - */ -export function findChannelConflicts( - currentSandbox: string | null, - enabledChannels: ChannelConflictRequest[], - registry: ConflictRegistry, -): ConflictMatch[] { - if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; - const requests = enabledChannels - .map(normalizeRequest) - .filter((request): request is ConflictRequest => request !== null); - if (requests.length === 0) return []; - const { sandboxes } = registry.listSandboxes(); - return findConflictsInEntries(currentSandbox, requests, sandboxes); -} - -/** - * Plan-driven variant of `findChannelConflicts`. Derives the channel request - * list from a compiled `SandboxMessagingPlan`. - */ -export function findChannelConflictsFromPlan( - currentSandbox: string | null, - plan: SandboxMessagingPlan, - registry: ConflictRegistry, -): ConflictMatch[] { - return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); -} - -/** - * Detect overlaps across all entries, returning each pair at most once. - * Used by `nemoclaw status` to surface sandboxes that already share a token. - */ -export function detectAllOverlapsInEntries( - entries: readonly ConflictRegistryEntry[], -): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { - const byChannel = new Map(); - for (const entry of entries) { - const activeChannels = resolveActiveChannelsFromEntry(entry); - if (!activeChannels) continue; - for (const channel of activeChannels) { - const list = byChannel.get(channel) ?? []; - list.push(entry); - byChannel.set(channel, list); - } - } - - const overlaps: Array<{ - channel: string; - sandboxes: [string, string]; - reason: ConflictReason; - }> = []; - for (const [channel, channelEntries] of byChannel) { - if (channelEntries.length < 2) continue; - for (let i = 0; i < channelEntries.length; i += 1) { - for (let j = i + 1; j < channelEntries.length; j += 1) { - const reason = conflictReasonForPair(channel, channelEntries[i], channelEntries[j]); - if (reason) { - overlaps.push({ - channel, - sandboxes: [channelEntries[i].name, channelEntries[j].name], - reason, - }); - } - } - } - } - return overlaps; -} - -/** - * Registry-backed overlap lookup used by status. - */ -export function findAllOverlaps( - registry: ConflictRegistry, -): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { - const { sandboxes } = registry.listSandboxes(); - return detectAllOverlapsInEntries(sandboxes); -} - -/** - * For entries missing `messagingChannels`, probe OpenShell to infer which - * channels the sandbox was onboarded with, and call `updateEntry` for each - * resolved sandbox. Safe to call repeatedly — entries with `messagingChannels` - * already set are skipped. Probe errors abort the write for that sandbox so a - * flaky gateway does not permanently hide real overlaps. - */ -export function backfillLegacyEntryChannels( - entries: readonly ConflictRegistryEntry[], - probe: MessagingConflictProbe, - updateEntry: (name: string, channels: string[]) => void, - providerSuffixes: Record, -): void { - for (const entry of entries) { - if (Array.isArray(entry.messagingChannels)) continue; - const discovered: string[] = []; - let probeFailed = false; - for (const channel of Object.keys(providerSuffixes)) { - let channelPresent = false; - for (const suffix of providerSuffixes[channel]) { - let state: ProbeResult; - try { - state = probe.providerExists(`${entry.name}${suffix}`); - } catch { - state = "error"; - } - if (state === "present") { - channelPresent = true; - break; - } - if (state === "error") { - probeFailed = true; - break; - } - } - if (probeFailed) break; - if (channelPresent) discovered.push(channel); - } - if (!probeFailed) { - updateEntry(entry.name, discovered); - } - } -} - -/** - * Backfill pre-plan registry entries using built-in manifest provider names. - * This infers channel presence only; it must not restore legacy credential - * hashes. Remove with the `messagingChannels`/`disabledChannels` fallback once - * pre-plan registry rows are no longer supported. - */ -export function backfillMessagingChannels( - registry: ConflictRegistry, - probe: MessagingConflictProbe, -): void { - const { sandboxes } = registry.listSandboxes(); - backfillLegacyEntryChannels( - sandboxes, - probe, - (name, channels) => { - registry.updateSandbox(name, { messagingChannels: channels }); - }, - PROVIDER_SUFFIXES, - ); -} +export * from "./conflict-detection-backfill"; +export * from "./conflict-detection-entry"; +export * from "./conflict-detection-plan"; +export * from "./conflict-detection-types"; diff --git a/test/helpers/messaging-conflict-fixtures.ts b/test/helpers/messaging-conflict-fixtures.ts new file mode 100644 index 0000000000..4c2ff3b5cd --- /dev/null +++ b/test/helpers/messaging-conflict-fixtures.ts @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../../src/lib/messaging/manifest"; +import type { SandboxMessagingState } from "../../src/lib/state/registry"; +import type { ConflictRegistryEntry } from "../../src/lib/messaging/applier/conflict-detection"; + +export function makePlan( + sandboxName: string, + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +export function tgChannel(active = true, disabled = false) { + return { + channelId: "telegram" as const, + displayName: "Telegram", + authMode: "token-paste" as const, + active, + selected: true, + configured: true, + disabled, + inputs: [], + hooks: [], + }; +} + +export function slackChannel() { + return { + channelId: "slack" as const, + displayName: "Slack", + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }; +} + +export function discordChannel() { + return { + channelId: "discord" as const, + displayName: "Discord", + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }; +} + +export function whatsappChannel() { + return { + channelId: "whatsapp" as const, + displayName: "WhatsApp", + authMode: "in-sandbox-qr" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }; +} + +export function tgBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { + return { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "sb-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + +export function discordBinding(hash?: string): SandboxMessagingPlan["credentialBindings"][number] { + return { + channelId: "discord", + credentialId: "discordBotToken", + sourceInput: "botToken", + providerName: "sb-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + credentialAvailable: true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + +export function slackBotBinding( + hash?: string, + sandboxName = "sb", +): SandboxMessagingPlan["credentialBindings"][number] { + return { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: `${sandboxName}-slack-bridge`, + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + +export function slackAppBinding( + hash?: string, + sandboxName = "sb", +): SandboxMessagingPlan["credentialBindings"][number] { + return { + channelId: "slack", + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: `${sandboxName}-slack-app`, + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", + credentialAvailable: true, + ...(hash !== undefined ? { credentialHash: hash } : {}), + }; +} + +export function slackBindings(botHash?: string, appHash?: string, sandboxName = "sb") { + return [slackBotBinding(botHash, sandboxName), slackAppBinding(appHash, sandboxName)]; +} + +export function planEntry(name: string, plan: SandboxMessagingPlan): ConflictRegistryEntry { + const state: SandboxMessagingState = { schemaVersion: 1, plan }; + return { name, messaging: state }; +} From fab8c690e503105cea76c81a3499ec4d98c33f33 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 09:42:56 +0530 Subject: [PATCH 23/25] fix(messaging): keep legacy backfill pre-plan only --- .../applier/conflict-detection-backfill.ts | 9 +++++---- .../applier/conflict-detection-legacy.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/lib/messaging/applier/conflict-detection-backfill.ts b/src/lib/messaging/applier/conflict-detection-backfill.ts index 686269394c..cef9afb7f8 100644 --- a/src/lib/messaging/applier/conflict-detection-backfill.ts +++ b/src/lib/messaging/applier/conflict-detection-backfill.ts @@ -31,9 +31,10 @@ export function createMessagingConflictProbe( } /** - * For entries missing `messagingChannels`, probe OpenShell to infer which - * channels the sandbox was onboarded with. Safe to call repeatedly. Probe - * errors abort the write for that sandbox so future calls can retry. + * For pre-plan entries missing `messagingChannels`, probe OpenShell to infer + * which channels the sandbox was onboarded with. Plan-backed entries are + * skipped even when the flat legacy field is absent. Probe errors abort the + * write for that sandbox so future calls can retry. */ export function backfillLegacyEntryChannels( entries: readonly ConflictRegistryEntry[], @@ -42,7 +43,7 @@ export function backfillLegacyEntryChannels( providerSuffixes: Record, ): void { for (const entry of entries) { - if (Array.isArray(entry.messagingChannels)) continue; + if (entry.messaging?.plan || Array.isArray(entry.messagingChannels)) continue; const discovered: string[] = []; let probeFailed = false; for (const channel of Object.keys(providerSuffixes)) { diff --git a/src/lib/messaging/applier/conflict-detection-legacy.test.ts b/src/lib/messaging/applier/conflict-detection-legacy.test.ts index ecda958397..ff80b16d04 100644 --- a/src/lib/messaging/applier/conflict-detection-legacy.test.ts +++ b/src/lib/messaging/applier/conflict-detection-legacy.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import type { SandboxEntry } from "../../state/registry"; +import { makePlan } from "../../../../test/helpers/messaging-conflict-fixtures"; import { backfillMessagingChannels, findAllOverlaps, @@ -190,6 +191,21 @@ describe("backfillMessagingChannels", () => { expect(probe.providerExists).not.toHaveBeenCalled(); }); + it("skips plan-backed entries without legacy messagingChannels", () => { + const registry = makeRegistry([ + { + name: "alice", + messaging: { schemaVersion: 1, plan: makePlan("alice") }, + } as unknown as SandboxEntry, + ]); + const probe: MessagingConflictProbe = { + providerExists: vi.fn(() => "present"), + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).not.toHaveBeenCalled(); + expect(probe.providerExists).not.toHaveBeenCalled(); + }); + it("writes an empty array when all probes return absent", () => { const registry = makeRegistry([{ name: "alice" }]); const probe: MessagingConflictProbe = { From bd847fc6e09cb678ae0433fed0ded7478a247fa2 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 13:14:39 +0700 Subject: [PATCH 24/25] fix(messaging): ignore credentialless conflict comparisons --- .../applier/conflict-detection-entry.test.ts | 17 +++++++++++++++++ .../applier/conflict-detection-entry.ts | 4 ++-- .../applier/conflict-detection-overlap.test.ts | 7 +++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/lib/messaging/applier/conflict-detection-entry.test.ts b/src/lib/messaging/applier/conflict-detection-entry.test.ts index 8d29958706..94c0fddac7 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.test.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.test.ts @@ -12,6 +12,7 @@ import { slackChannel, tgBinding, tgChannel, + whatsappChannel, } from "../../../../test/helpers/messaging-conflict-fixtures"; import { conflictReasonForPair, @@ -99,6 +100,16 @@ describe("conflictReasonForRequest", () => { }), ).toBe("unknown-token"); }); + + it("returns null for credential-less channels with no comparison keys", () => { + const entry = planEntry("alice", makePlan("alice", { channels: [whatsappChannel()] })); + expect( + conflictReasonForRequest(entry, { + channel: "whatsapp", + credentialHashes: {}, + }), + ).toBeNull(); + }); }); describe("conflictReasonForPair", () => { @@ -171,4 +182,10 @@ describe("conflictReasonForPair", () => { expect(conflictReasonForPair("telegram", alice, bob)).toBeNull(); expect(conflictReasonForPair("slack", alice, bob)).toBe("matching-token"); }); + + it("returns null for credential-less channel pairs with no comparison keys", () => { + const alice = planEntry("alice", makePlan("alice", { channels: [whatsappChannel()] })); + const bob = planEntry("bob", makePlan("bob", { channels: [whatsappChannel()] })); + expect(conflictReasonForPair("whatsapp", alice, bob)).toBeNull(); + }); }); diff --git a/src/lib/messaging/applier/conflict-detection-entry.ts b/src/lib/messaging/applier/conflict-detection-entry.ts index e506a2dc78..26b6324585 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.ts +++ b/src/lib/messaging/applier/conflict-detection-entry.ts @@ -91,7 +91,7 @@ export function conflictReasonForRequest( const requestedHashes = request.credentialHashes ?? {}; const storedHashes = resolveChannelHashesFromEntry(entry, request.channel); const keys = comparisonKeys(request.channel, storedHashes, requestedHashes); - if (keys.length === 0) return "unknown-token"; + if (keys.length === 0) return null; let sawUnknown = false; for (const key of keys) { @@ -125,7 +125,7 @@ export function conflictReasonForPair( manifestKeys && manifestKeys.length > 0 ? [...manifestKeys] : [...new Set([...Object.keys(lh), ...Object.keys(rh)])]; - if (keys.length === 0) return "unknown-token"; + if (keys.length === 0) return null; let sawUnknown = false; for (const key of keys) { diff --git a/src/lib/messaging/applier/conflict-detection-overlap.test.ts b/src/lib/messaging/applier/conflict-detection-overlap.test.ts index d7631a5991..57bb5661e1 100644 --- a/src/lib/messaging/applier/conflict-detection-overlap.test.ts +++ b/src/lib/messaging/applier/conflict-detection-overlap.test.ts @@ -8,6 +8,7 @@ import { planEntry, tgBinding, tgChannel, + whatsappChannel, } from "../../../../test/helpers/messaging-conflict-fixtures"; import { detectAllOverlapsInEntries, findConflictsInEntries } from "./conflict-detection"; @@ -75,4 +76,10 @@ describe("detectAllOverlapsInEntries", () => { ); expect(detectAllOverlapsInEntries([alice, bob])).toEqual([]); }); + + it("does not report overlap for credential-less channels", () => { + const alice = planEntry("alice", makePlan("alice", { channels: [whatsappChannel()] })); + const bob = planEntry("bob", makePlan("bob", { channels: [whatsappChannel()] })); + expect(detectAllOverlapsInEntries([alice, bob])).toEqual([]); + }); }); From f5839b28859d25315be423ab9a5affc297eef908 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 18:00:35 +0700 Subject: [PATCH 25/25] refactor(messaging): split conflict detection modules Signed-off-by: San Dang --- .../applier/conflict-detection-legacy.test.ts | 3 +- .../messaging/applier/conflict-detection.ts | 7 -- .../backfill.ts} | 25 +--- .../entries.ts} | 110 +++++++----------- .../applier/conflict-detection/index.ts | 9 ++ .../manifest-metadata.ts} | 7 +- .../plan.ts} | 27 +++-- .../applier/conflict-detection/probe.ts | 26 +++++ .../applier/conflict-detection/registry.ts | 61 ++++++++++ .../types.ts} | 6 +- 10 files changed, 167 insertions(+), 114 deletions(-) delete mode 100644 src/lib/messaging/applier/conflict-detection.ts rename src/lib/messaging/applier/{conflict-detection-backfill.ts => conflict-detection/backfill.ts} (73%) rename src/lib/messaging/applier/{conflict-detection-entry.ts => conflict-detection/entries.ts} (65%) create mode 100644 src/lib/messaging/applier/conflict-detection/index.ts rename src/lib/messaging/applier/{conflict-detection-manifest.ts => conflict-detection/manifest-metadata.ts} (72%) rename src/lib/messaging/applier/{conflict-detection-plan.ts => conflict-detection/plan.ts} (62%) create mode 100644 src/lib/messaging/applier/conflict-detection/probe.ts create mode 100644 src/lib/messaging/applier/conflict-detection/registry.ts rename src/lib/messaging/applier/{conflict-detection-types.ts => conflict-detection/types.ts} (86%) diff --git a/src/lib/messaging/applier/conflict-detection-legacy.test.ts b/src/lib/messaging/applier/conflict-detection-legacy.test.ts index ff80b16d04..07ed0ccfc3 100644 --- a/src/lib/messaging/applier/conflict-detection-legacy.test.ts +++ b/src/lib/messaging/applier/conflict-detection-legacy.test.ts @@ -5,9 +5,8 @@ // Hash-precise plan-backed tests are split across conflict-detection-entry, conflict-detection-overlap, and conflict-detection-multi-credential tests import { describe, expect, it, vi } from "vitest"; - -import type { SandboxEntry } from "../../state/registry"; import { makePlan } from "../../../../test/helpers/messaging-conflict-fixtures"; +import type { SandboxEntry } from "../../state/registry"; import { backfillMessagingChannels, findAllOverlaps, diff --git a/src/lib/messaging/applier/conflict-detection.ts b/src/lib/messaging/applier/conflict-detection.ts deleted file mode 100644 index 115f772641..0000000000 --- a/src/lib/messaging/applier/conflict-detection.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export * from "./conflict-detection-backfill"; -export * from "./conflict-detection-entry"; -export * from "./conflict-detection-plan"; -export * from "./conflict-detection-types"; diff --git a/src/lib/messaging/applier/conflict-detection-backfill.ts b/src/lib/messaging/applier/conflict-detection/backfill.ts similarity index 73% rename from src/lib/messaging/applier/conflict-detection-backfill.ts rename to src/lib/messaging/applier/conflict-detection/backfill.ts index cef9afb7f8..b07e78846e 100644 --- a/src/lib/messaging/applier/conflict-detection-backfill.ts +++ b/src/lib/messaging/applier/conflict-detection/backfill.ts @@ -1,34 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { PROVIDER_SUFFIXES } from "./conflict-detection-manifest"; +import { PROVIDER_SUFFIXES } from "./manifest-metadata"; import type { ConflictRegistry, ConflictRegistryEntry, MessagingConflictProbe, - MessagingConflictProbeGatewayDeps, ProbeResult, -} from "./conflict-detection-types"; - -/** - * Build a tri-state `MessagingConflictProbe` from plain openshell runner deps. - * - * The liveness result is cached so `sandbox list` is issued at most once per - * probe instance. A transient gateway failure returns error instead of absent, - * preventing a flaky gateway from being persisted as no providers. - */ -export function createMessagingConflictProbe( - deps: MessagingConflictProbeGatewayDeps, -): MessagingConflictProbe { - let alive: boolean | null = null; - return { - providerExists: (name) => { - if (alive === null) alive = deps.checkGatewayLiveness(); - if (!alive) return "error"; - return deps.providerExists(name) ? "present" : "absent"; - }, - }; -} +} from "./types"; /** * For pre-plan entries missing `messagingChannels`, probe OpenShell to infer diff --git a/src/lib/messaging/applier/conflict-detection-entry.ts b/src/lib/messaging/applier/conflict-detection/entries.ts similarity index 65% rename from src/lib/messaging/applier/conflict-detection-entry.ts rename to src/lib/messaging/applier/conflict-detection/entries.ts index 26b6324585..cbb241db5d 100644 --- a/src/lib/messaging/applier/conflict-detection-entry.ts +++ b/src/lib/messaging/applier/conflict-detection/entries.ts @@ -1,33 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../manifest"; -import { CHANNEL_CREDENTIAL_ENV_KEYS } from "./conflict-detection-manifest"; -import { - getActiveChannelIdsFromPlan, - getCredentialHashesFromPlan, - planToConflictChannelRequests, -} from "./conflict-detection-plan"; +import { CHANNEL_CREDENTIAL_ENV_KEYS } from "./manifest-metadata"; +import { getActiveChannelIdsFromPlan, getCredentialHashesFromPlan } from "./plan"; import type { - ChannelConflictRequest, ConflictMatch, ConflictReason, - ConflictRegistry, ConflictRegistryEntry, ConflictRequest, -} from "./conflict-detection-types"; - -function normalizeRequest(request: ChannelConflictRequest): ConflictRequest | null { - if (typeof request === "string") { - return request ? { channel: request, credentialHashes: {} } : null; - } - if (!request || typeof request.channel !== "string" || request.channel.length === 0) return null; - return request; -} +} from "./types"; /** - * Return the active channel IDs for a registry entry. - * + * Return the active (non-disabled) channel IDs for a registry entry. * Uses `entry.messaging.plan` when available. Pre-plan registry entries are * supported only for channel presence via the legacy * `messagingChannels`/`disabledChannels` flat fields; legacy credential hashes @@ -46,6 +30,12 @@ export function resolveActiveChannelsFromEntry( return (entry.messagingChannels as string[]).filter((c) => !disabled.has(c)); } +/** + * Return credential hashes scoped to `channelId` for a registry entry. + * Plan-backed entries return channel-scoped hashes from `getCredentialHashesFromPlan`. + * Legacy entries without a plan return an empty map, which falls through to + * conservative `"unknown-token"` detection in the callers. + */ function resolveChannelHashesFromEntry( entry: ConflictRegistryEntry, channelId: string, @@ -57,9 +47,9 @@ function resolveChannelHashesFromEntry( } /** - * True when `channel` is active in `entry`. + * True when `channel` is active (present and not disabled) in `entry`. * Disabled channels must not block another sandbox from claiming the same - * token because the bridge is paused. + * token: the bridge is paused so the credential is not in use. */ export function hasStoredChannelInEntry( entry: ConflictRegistryEntry, @@ -68,20 +58,15 @@ export function hasStoredChannelInEntry( return resolveActiveChannelsFromEntry(entry)?.includes(channel) ?? false; } -function comparisonKeys( - channel: string, - storedHashes: Record, - requestedHashes: Record, -): string[] { - const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[channel]; - if (manifestKeys && manifestKeys.length > 0) return [...manifestKeys]; - if (Object.keys(storedHashes).length > 0) return Object.keys(storedHashes); - return Object.keys(requestedHashes); -} - /** - * Determine the conflict reason between stored entry state and a new channel - * request, or `null` if there is no conflict. + * Determine the conflict reason between `entry`'s stored state and a new + * channel request, or `null` if there is no conflict. + * + * Comparison keys are taken from manifest-declared credentials for the channel + * so that a missing hash for one of multiple required credentials (e.g. Slack's + * SLACK_APP_TOKEN when only SLACK_BOT_TOKEN differs) conservatively marks the + * result as "unknown-token" rather than silently returning null. Falls back to + * the union of present stored/requested keys for channels not in the manifest. */ export function conflictReasonForRequest( entry: ConflictRegistryEntry, @@ -90,7 +75,13 @@ export function conflictReasonForRequest( if (!hasStoredChannelInEntry(entry, request.channel)) return null; const requestedHashes = request.credentialHashes ?? {}; const storedHashes = resolveChannelHashesFromEntry(entry, request.channel); - const keys = comparisonKeys(request.channel, storedHashes, requestedHashes); + const manifestKeys = CHANNEL_CREDENTIAL_ENV_KEYS[request.channel]; + const keys = + manifestKeys && manifestKeys.length > 0 + ? [...manifestKeys] + : Object.keys(storedHashes).length > 0 + ? Object.keys(storedHashes) + : Object.keys(requestedHashes); if (keys.length === 0) return null; let sawUnknown = false; @@ -107,8 +98,13 @@ export function conflictReasonForRequest( } /** - * Determine the conflict reason between two registry entries sharing a channel, - * or `null` if there is no conflict. + * Determine the conflict reason between two registry entries sharing `channel`, + * or `null` if there is no conflict. Returns each pair at most once (the + * caller is responsible for ordered iteration). + * + * Comparison keys are taken from manifest-declared credentials for the channel + * so that a missing hash on either side conservatively produces "unknown-token" + * rather than null for multi-credential channels like Slack. */ export function conflictReasonForPair( channel: string, @@ -141,8 +137,9 @@ export function conflictReasonForPair( } /** - * Return every requested channel where another sandbox already has a matching - * credential hash or insufficient hash metadata to prove it differs. + * Return every (channel, other-sandbox) pair where another entry already has + * one of the requested channels in use with either a matching credential hash + * or insufficient hash metadata to prove it differs. */ export function findConflictsInEntries( currentSandbox: string | null, @@ -162,28 +159,10 @@ export function findConflictsInEntries( ); } -export function findChannelConflicts( - currentSandbox: string | null, - enabledChannels: ChannelConflictRequest[], - registry: ConflictRegistry, -): ConflictMatch[] { - if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; - const requests = enabledChannels - .map(normalizeRequest) - .filter((request): request is ConflictRequest => request !== null); - if (requests.length === 0) return []; - const { sandboxes } = registry.listSandboxes(); - return findConflictsInEntries(currentSandbox, requests, sandboxes); -} - -export function findChannelConflictsFromPlan( - currentSandbox: string | null, - plan: SandboxMessagingPlan, - registry: ConflictRegistry, -): ConflictMatch[] { - return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); -} - +/** + * Detect overlaps across all entries, returning each pair at most once. + * Used by `nemoclaw status` to surface sandboxes that already share a token. + */ export function detectAllOverlapsInEntries( entries: readonly ConflictRegistryEntry[], ): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { @@ -220,10 +199,3 @@ export function detectAllOverlapsInEntries( } return overlaps; } - -export function findAllOverlaps( - registry: ConflictRegistry, -): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { - const { sandboxes } = registry.listSandboxes(); - return detectAllOverlapsInEntries(sandboxes); -} diff --git a/src/lib/messaging/applier/conflict-detection/index.ts b/src/lib/messaging/applier/conflict-detection/index.ts new file mode 100644 index 0000000000..2622b19fcf --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection/index.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./backfill"; +export * from "./entries"; +export * from "./plan"; +export * from "./probe"; +export * from "./registry"; +export type * from "./types"; diff --git a/src/lib/messaging/applier/conflict-detection-manifest.ts b/src/lib/messaging/applier/conflict-detection/manifest-metadata.ts similarity index 72% rename from src/lib/messaging/applier/conflict-detection-manifest.ts rename to src/lib/messaging/applier/conflict-detection/manifest-metadata.ts index 572059551b..d793d97db6 100644 --- a/src/lib/messaging/applier/conflict-detection-manifest.ts +++ b/src/lib/messaging/applier/conflict-detection/manifest-metadata.ts @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { BUILT_IN_CHANNEL_MANIFESTS } from "../channels"; +import { BUILT_IN_CHANNEL_MANIFESTS } from "../../channels"; // Map channelId to providerEnvKey values declared in built-in manifests. -// The comparison layer uses this as the primary key set so a missing hash for -// one required credential conservatively reports unknown-token. +// This is the primary key set for hash comparison so a missing credential for +// one of a channel's required credentials conservatively marks the comparison +// as unknown-token rather than silently returning null. export const CHANNEL_CREDENTIAL_ENV_KEYS: Readonly> = Object.fromEntries( BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]), diff --git a/src/lib/messaging/applier/conflict-detection-plan.ts b/src/lib/messaging/applier/conflict-detection/plan.ts similarity index 62% rename from src/lib/messaging/applier/conflict-detection-plan.ts rename to src/lib/messaging/applier/conflict-detection/plan.ts index 7e4b5ce173..f6ef92aa93 100644 --- a/src/lib/messaging/applier/conflict-detection-plan.ts +++ b/src/lib/messaging/applier/conflict-detection/plan.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../manifest"; -import type { ConflictRequest } from "./conflict-detection-types"; +import type { SandboxMessagingPlan } from "../../manifest"; +import type { ConflictRequest } from "./types"; /** * Return the channel IDs that are active (not disabled) in a compiled plan. * Aligns with `enabledPlanChannels()` in plan-filter.ts: a channel is active - * only when `channel.active && !channel.disabled` and it is not in + * only when `channel.active && !channel.disabled` AND it is not in * `plan.disabledChannels`. */ export function getActiveChannelIdsFromPlan(plan: SandboxMessagingPlan): string[] { @@ -20,6 +20,11 @@ export function getActiveChannelIdsFromPlan(plan: SandboxMessagingPlan): string[ /** * Return credential hashes keyed by providerEnvKey from a compiled plan, * optionally scoped to a single channel. + * + * Only bindings that carry a `credentialHash` are included. When `channelId` + * is provided only that channel's bindings are returned, which prevents + * hashes from other channels in the same sandbox from contaminating + * single-channel conflict comparisons. */ export function getCredentialHashesFromPlan( plan: SandboxMessagingPlan, @@ -34,12 +39,18 @@ export function getCredentialHashesFromPlan( } /** - * Build conflict requests from plan credential bindings. + * Build a `ConflictRequest[]` from a compiled plan's credential bindings. + * + * Groups bindings by channelId (e.g. Slack has SLACK_BOT_TOKEN and + * SLACK_APP_TOKEN) and excludes: + * - channels in `plan.disabledChannels` (bridge is paused, not in use) + * - bindings where the credential is not available (`credentialAvailable` + * false) - e.g. WhatsApp, which has no host-side token provider * - * Bindings are grouped by channelId, disabled channels are skipped, and - * credentialAvailable=false bindings are omitted. A binding with no - * credentialHash still produces a channel request so later comparison can use - * conservative unknown-token behavior instead of dropping the channel. + * When a binding has no `credentialHash` (e.g. a registry-only resume that + * did not re-run the compiler), the channel is still included with an empty + * `credentialHashes` map, which falls through to `"unknown-token"` conservative + * detection. */ export function planToConflictChannelRequests(plan: SandboxMessagingPlan): ConflictRequest[] { const activeChannelIds = new Set(getActiveChannelIdsFromPlan(plan)); diff --git a/src/lib/messaging/applier/conflict-detection/probe.ts b/src/lib/messaging/applier/conflict-detection/probe.ts new file mode 100644 index 0000000000..96f2325628 --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection/probe.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingConflictProbe, MessagingConflictProbeGatewayDeps } from "./types"; + +/** + * Build a tri-state `MessagingConflictProbe` from plain openshell runner deps. + * + * The liveness result is cached so the `sandbox list` call is issued at most + * once per probe instance. A transient gateway failure (`checkGatewayLiveness` + * returns false) causes all subsequent `providerExists` calls to return "error" + * rather than "absent", preventing a flaky gateway from being mis-recorded as + * "no providers" and permanently suppressing future backfill retries. + */ +export function createMessagingConflictProbe( + deps: MessagingConflictProbeGatewayDeps, +): MessagingConflictProbe { + let alive: boolean | null = null; + return { + providerExists: (name) => { + if (alive === null) alive = deps.checkGatewayLiveness(); + if (!alive) return "error"; + return deps.providerExists(name) ? "present" : "absent"; + }, + }; +} diff --git a/src/lib/messaging/applier/conflict-detection/registry.ts b/src/lib/messaging/applier/conflict-detection/registry.ts new file mode 100644 index 0000000000..6b506b2d3a --- /dev/null +++ b/src/lib/messaging/applier/conflict-detection/registry.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../../manifest"; +import { detectAllOverlapsInEntries, findConflictsInEntries } from "./entries"; +import { planToConflictChannelRequests } from "./plan"; +import type { + ChannelConflictRequest, + ConflictMatch, + ConflictReason, + ConflictRegistry, + ConflictRequest, +} from "./types"; + +function normalizeRequest(request: ChannelConflictRequest): ConflictRequest | null { + if (typeof request === "string") { + return request ? { channel: request, credentialHashes: {} } : null; + } + if (!request || typeof request.channel !== "string" || request.channel.length === 0) return null; + return request; +} + +/** + * Registry-backed conflict lookup for callers that do not already have a + * compiled plan request list. + */ +export function findChannelConflicts( + currentSandbox: string | null, + enabledChannels: ChannelConflictRequest[], + registry: ConflictRegistry, +): ConflictMatch[] { + if (!Array.isArray(enabledChannels) || enabledChannels.length === 0) return []; + const requests = enabledChannels + .map(normalizeRequest) + .filter((request): request is ConflictRequest => request !== null); + if (requests.length === 0) return []; + const { sandboxes } = registry.listSandboxes(); + return findConflictsInEntries(currentSandbox, requests, sandboxes); +} + +/** + * Plan-driven variant of `findChannelConflicts`. Derives the channel request + * list from a compiled `SandboxMessagingPlan`. + */ +export function findChannelConflictsFromPlan( + currentSandbox: string | null, + plan: SandboxMessagingPlan, + registry: ConflictRegistry, +): ConflictMatch[] { + return findChannelConflicts(currentSandbox, planToConflictChannelRequests(plan), registry); +} + +/** + * Registry-backed overlap lookup used by status. + */ +export function findAllOverlaps( + registry: ConflictRegistry, +): Array<{ channel: string; sandboxes: [string, string]; reason: ConflictReason }> { + const { sandboxes } = registry.listSandboxes(); + return detectAllOverlapsInEntries(sandboxes); +} diff --git a/src/lib/messaging/applier/conflict-detection-types.ts b/src/lib/messaging/applier/conflict-detection/types.ts similarity index 86% rename from src/lib/messaging/applier/conflict-detection-types.ts rename to src/lib/messaging/applier/conflict-detection/types.ts index e050c30b49..fc33eaaefa 100644 --- a/src/lib/messaging/applier/conflict-detection-types.ts +++ b/src/lib/messaging/applier/conflict-detection/types.ts @@ -1,13 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../manifest"; +import type { SandboxMessagingPlan } from "../../manifest"; export type ProbeResult = "present" | "absent" | "error"; export type ConflictReason = "matching-token" | "unknown-token"; export interface MessagingConflictProbe { - // Tri-state keeps transient gateway errors distinct from missing providers. + // Tri-state: "error" is distinct from "absent" so a transient gateway + // failure does not get collapsed into "provider not attached" and then + // persisted as bogus empty messagingChannels. providerExists: (name: string) => ProbeResult; }