diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 19d55bd71a..2e79d0058c 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -5,13 +5,13 @@ "nemoclaw/src/commands/migration-state.test.ts": 1566, "src/lib/inference/nim.test.ts": 2068, "src/lib/onboard/preflight.test.ts": 1905, - "test/channels-add-preset.test.ts": 1872, + "test/channels-add-preset.test.ts": 1871, "test/generate-openclaw-config.test.ts": 1990, "test/install-preflight.test.ts": 4207, "test/nemoclaw-start.test.ts": 5231, - "test/onboard-messaging.test.ts": 2094, + "test/onboard-messaging.test.ts": 2063, "test/onboard-selection.test.ts": 6891, - "test/onboard.test.ts": 4775, + "test/onboard.test.ts": 4774, "test/policies.test.ts": 2763 } } diff --git a/src/lib/actions/inference-route-api.test.ts b/src/lib/actions/inference-route-api.test.ts index 06aad2a99d..8ecc3e3f0e 100644 --- a/src/lib/actions/inference-route-api.test.ts +++ b/src/lib/actions/inference-route-api.test.ts @@ -39,9 +39,7 @@ function session(overrides: Partial = {}): Session { routerCredentialHash: null, webSearchConfig: null, policyPresets: null, - messagingChannels: null, - messagingChannelConfig: null, - disabledChannels: null, + messagingPlan: null, migratedLegacyValueHashes: null, hermesToolGateways: null, gpuPassthrough: false, diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index b42d2afb2a..e8fb8ce4c4 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -77,9 +77,7 @@ function baseSession(overrides: Partial = {}): Session { routerCredentialHash: null, webSearchConfig: null, policyPresets: null, - messagingChannels: null, - messagingChannelConfig: null, - disabledChannels: null, + messagingPlan: null, migratedLegacyValueHashes: null, hermesToolGateways: null, gpuPassthrough: false, diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index d079c29546..bc431720c5 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -15,6 +15,18 @@ vi.mock("../../policy", () => ({ vi.mock("../../state/registry", () => ({ getSandbox: vi.fn(), + getConfiguredMessagingChannelsFromEntry: vi.fn((entry) => { + const channels = entry?.messaging?.plan?.channels; + return Array.isArray(channels) + ? channels + .filter((channel) => channel?.configured === true) + .map((channel) => channel.channelId) + : []; + }), + getDisabledMessagingChannelsFromEntry: vi.fn((entry) => { + const disabled = entry?.messaging?.plan?.disabledChannels; + return Array.isArray(disabled) ? [...disabled] : []; + }), })); vi.mock("../../agent/defs", () => ({ @@ -108,11 +120,37 @@ function entry( messagingChannels: string[] = ["whatsapp"], disabledChannels: string[] = [], ): SandboxEntry { + const disabled = new Set(disabledChannels); return { name: "alpha", agent: "openclaw", - messagingChannels, - disabledChannels, + messaging: { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + channels: messagingChannels.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, } as SandboxEntry; } diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 2e80cb3273..1eda2d43fc 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -353,7 +353,9 @@ function buildWhatsappProbeInput( } const entry = deps.getSandbox(sandboxName); - const channelEnabledInRegistry = (entry?.messagingChannels ?? []).includes("whatsapp"); + const channelEnabledInRegistry = registry + .getConfiguredMessagingChannelsFromEntry(entry) + .includes("whatsapp"); const appliedPresets = deps.getAppliedPresets(sandboxName); const presetInRegistry = appliedPresets.includes("whatsapp"); @@ -440,8 +442,8 @@ function buildBasicChannelReport( deps: Required, ): ChannelStatusReport { const entry = deps.getSandbox(sandboxName); - const enabled = (entry?.messagingChannels ?? []).includes(channelName); - const disabled = (entry?.disabledChannels ?? []).includes(channelName); + const enabled = registry.getConfiguredMessagingChannelsFromEntry(entry).includes(channelName); + const disabled = registry.getDisabledMessagingChannelsFromEntry(entry).includes(channelName); const appliedPresets = deps.getAppliedPresets(sandboxName); const presetInRegistry = appliedPresets.includes(channelName); const signals: DiagnosticSignal[] = []; @@ -523,11 +525,12 @@ export async function showSandboxChannelStatus( let channelName = channelArg; if (!channelName) { - const enabled = (entry.messagingChannels ?? []).filter((name: string) => name === "whatsapp"); + const configuredChannels = registry.getConfiguredMessagingChannelsFromEntry(entry); + const enabled = configuredChannels.filter((name: string) => name === "whatsapp"); if (enabled.length > 0) { channelName = "whatsapp"; - } else if ((entry.messagingChannels ?? []).length > 0) { - channelName = entry.messagingChannels?.[0]; + } else if (configuredChannels.length > 0) { + channelName = configuredChannels[0]; } else { channelName = "whatsapp"; } @@ -551,7 +554,7 @@ export async function showSandboxChannelStatus( const agent = deps.loadAgent(entry.agent || "openclaw"); - const disabledChannels = new Set(entry.disabledChannels ?? []); + const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(entry)); const channelIsPaused = disabledChannels.has(channelName); let report: ChannelStatusReport; diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 13ee084e20..de4547b7bd 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -423,8 +423,8 @@ function channelRuntimeDoctorCheck( } function messagingDoctorCheck(sandboxName: string, sb: SandboxEntry): DoctorCheck { - const registeredChannels = Array.isArray(sb.messagingChannels) ? sb.messagingChannels : []; - const disabledChannels = new Set(Array.isArray(sb.disabledChannels) ? sb.disabledChannels : []); + const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb); + const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); const channels = registeredChannels.filter((channel: string) => !disabledChannels.has(channel)); const pausedChannels = registeredChannels.filter((channel: string) => disabledChannels.has(channel), @@ -783,10 +783,8 @@ export async function runSandboxDoctor( // #4156: bridge the gap between "configured" and "runtime-visible" — the // existing messaging check above probes provider attachment, not whether // OpenClaw's runtime config actually surfaces each enabled channel. - const registeredChannels = Array.isArray(sb.messagingChannels) ? sb.messagingChannels : []; - const disabledChannelsSet = new Set( - Array.isArray(sb.disabledChannels) ? sb.disabledChannels : [], - ); + const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb); + const disabledChannelsSet = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); const enabledChannels = registeredChannels.filter( (channel: string) => !disabledChannelsSet.has(channel), ); diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index f14bc60078..45eb6fa40f 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -104,6 +104,10 @@ function makePlanEntry( } as unknown as SandboxEntry; } +function makeEmptyEntry(name: string): SandboxEntry { + return { name } as SandboxEntry; +} + let spies: MockInstance[]; let logSpy: MockInstance; let errSpy: MockInstance; @@ -246,7 +250,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 1 it("interactive matching-token conflict: warns, user continues, add proceeds", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -268,7 +272,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 2 it("interactive matching-token conflict: user aborts, nothing is mutated", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -288,7 +292,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { it("interactive matching-token conflict: empty answer (default N) aborts", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -307,7 +311,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 3 it("non-interactive matching-token conflict: aborts with exit(1) and guidance", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -335,7 +339,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 4 it("--force bypasses the conflict even in non-interactive mode", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -359,8 +363,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 5a 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 plan — legacy entry, unknown-token + current: makeEmptyEntry("alpha"), + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN" }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("y"); @@ -375,7 +379,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 5b it("different hash on the other sandbox is NOT a conflict (no warning, add proceeds)", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { @@ -421,7 +425,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 7 it("--dry-run never runs the conflict check or touches credentials", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -447,7 +451,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { const wechatToken = "wx-secret-token-abc"; const wechatHash = hashCredential(wechatToken) as string; arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "wechat", [ { providerEnvKey: "WECHAT_BOT_TOKEN", credentialHash: wechatHash }, @@ -473,8 +477,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // acquired and skips the credential conflict check entirely. it("in-sandbox-qr whatsapp skips the credential conflict check", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, - others: [{ name: "bob", messagingChannels: ["whatsapp"] }], + current: makeEmptyEntry("alpha"), + others: [makePlanEntry("bob", "whatsapp", [])], }); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -487,35 +491,39 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { expect(promptMock).not.toHaveBeenCalled(); }); + it("in-sandbox-qr whatsapp aborts when plan persistence fails", async () => { + arrangeRegistry({ + current: makeEmptyEntry("alpha"), + others: [], + }); + updateSandboxMock.mockReturnValue(false); + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + + await expect(addSandboxChannel("alpha", { channel: "whatsapp" })).rejects.toThrow( + "process.exit(1)", + ); + + const text = loggedText(); + expect(text).toContain("Could not persist messaging plan for 'alpha'"); + expect(text).not.toContain("Enabled whatsapp channel"); + expect(exitMock).toHaveBeenCalledWith(1); + expect(promptMock).not.toHaveBeenCalled(); + }); + // Scenario 9 - it("probe + backfill failure is swallowed; a pre-recorded matching hash still warns", async () => { + it("entries without messaging plans are ignored while plan-backed conflicts still warn", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ 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 - // exercise the try/catch around backfillMessagingChannels. - { name: "legacy" }, + makeEmptyEntry("legacy"), ], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); - // Gateway alive (status 0) + every provider absent, so backfill reaches the - // updateSandbox("legacy") write — which throws below. - runOpenshellMock.mockReturnValue({ status: 0, stdout: "", stderr: "" }); - updateSandboxMock.mockImplementation((name: string, _updates: Partial) => { - if (name === "legacy") throw new Error("backfill boom"); - return true; - }); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; - // bob already has messagingChannels + a matching hash, so the conflict is - // still found -> non-interactive abort. Key guarantee: a throw inside - // backfillMessagingChannels is swallowed; the only exit is the conflict - // exit(1), not an unhandled exception. await expect(addSandboxChannel("alpha", { channel: "telegram" })).rejects.toThrow( "process.exit(1)", ); @@ -523,13 +531,12 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { expect(loggedText()).toContain("same telegram credential"); }); - it("probe + backfill failure with no pre-recorded conflict lets the add proceed", async () => { + it("entries without messaging plans do not block an add", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, - others: [], // no other sandbox -> no conflict resolvable + current: makeEmptyEntry("alpha"), + others: [makeEmptyEntry("legacy")], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); - runOpenshellMock.mockReturnValue({ status: 1, stdout: "", stderr: "down" }); await addSandboxChannel("alpha", { channel: "telegram" }); @@ -539,7 +546,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { }); it("non-interactive add aborts when the conflict check throws", async () => { - arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] }); + arrangeRegistry({ current: makeEmptyEntry("alpha"), others: [] }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); listSandboxesMock.mockImplementation(() => { throw new Error("malformed messaging plan"); @@ -557,7 +564,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { }); it("--force proceeds when the conflict check throws", async () => { - arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] }); + arrangeRegistry({ current: makeEmptyEntry("alpha"), others: [] }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); listSandboxesMock.mockImplementation(() => { throw new Error("malformed messaging plan"); @@ -575,7 +582,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { // Scenario 10 it("never prints the raw token value in any conflict output (proceed path)", async () => { arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -595,7 +602,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: [] }, + current: makeEmptyEntry("alpha"), others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -618,7 +625,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { const slackApp = "xapp-test-slack-app-token"; const slackBotHash = hashCredential(slackBot) as string; arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, + current: makeEmptyEntry("alpha"), // only bot token stored — app token unknown → conservative unknown-token OR // matching-token if bot token matches; test verifies the conflict is surfaced. others: [ @@ -648,7 +655,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { const slackBot = "xoxb-alpha-bot-token"; const slackApp = "xapp-alpha-app-token"; arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] } as SandboxEntry, + current: { name: "alpha" } as SandboxEntry, // bob holds Slack on the default gateway with entirely different tokens — // the credential axis would NOT flag this, but the gateway axis must. others: [ @@ -688,7 +695,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { const slackBot = "xoxb-shared-bot-token"; const slackApp = "xapp-shared-app-token"; arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] } as SandboxEntry, + current: { name: "alpha" } as SandboxEntry, others: [ makePlanEntry("bob", "slack", [ { providerEnvKey: "SLACK_BOT_TOKEN", credentialHash: hashCredential(slackBot) as string }, @@ -726,7 +733,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { ]); (bob as { gatewayName?: string }).gatewayName = "nemoclaw-9090"; arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] } as SandboxEntry, + current: { name: "alpha" } as SandboxEntry, others: [bob], }); getCredentialMock.mockImplementation((key: string) => @@ -747,7 +754,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { }); it("slack: a gateway conflict-detection failure is fail-soft, not a crash (#4953)", async () => { - arrangeRegistry({ current: { name: "alpha", messagingChannels: [] } as SandboxEntry }); + arrangeRegistry({ current: { name: "alpha" } as SandboxEntry }); // Simulate a malformed registry read: listSandboxes throws. The Slack // gateway lookup must swallow it (best-effort warning) rather than crash // the add or bypass the downstream guarded credential check. diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 1288e94bde..026be1b456 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -21,13 +21,6 @@ import { type SandboxMessagingPlan, toMessagingAgentId, } from "../../messaging"; -import { - type MessagingChannelConfig, - mergeMessagingChannelConfigs, - normalizeMessagingChannelConfigValue, - resolveMessagingChannelConfigEnvValue, - sanitizeMessagingChannelConfig, -} from "../../messaging-channel-config"; import { hashCredential } from "../../security/credential-hash"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; @@ -359,35 +352,10 @@ function bridgeProviderName(sandboxName: string, channelName: string, envKey: st return `${sandboxName}-${channelName}-bridge`; } -// 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. -function makeChannelsConflictProbe() { - let gatewayAlive: boolean | null = null; - const isGatewayAlive = (): boolean => { - if (gatewayAlive === null) { - const result = runOpenshell(["sandbox", "list"], { - ignoreError: true, - stdio: ["ignore", "ignore", "ignore"], - }); - gatewayAlive = result.status === 0; - } - return gatewayAlive; - }; - return { - providerExists: (name: string): "present" | "absent" | "error" => { - if (!isGatewayAlive()) return "error"; - return onboardProviders.providerExistsInGateway(name, runOpenshell) ? "present" : "absent"; - }, - }; -} - // 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. Backfill probe failures are non-fatal, but core -// conflict-detection errors fail closed unless --force is set. +// credential values. Conflict-detection errors fail closed unless --force is set. async function checkChannelAddConflict( sandboxName: string, channelName: string, @@ -412,15 +380,9 @@ async function checkChannelAddConflict( } if (Object.keys(credentialHashes).length === 0) return true; - const { backfillMessagingChannels, findChannelConflicts } = + const { findChannelConflicts } = require("../../messaging/applier") as typeof import("../../messaging/applier"); - try { - backfillMessagingChannels(registry, makeChannelsConflictProbe()); - } catch { - // Non-fatal: a backfill blow-up must not block adding a channel. - } - let conflicts: ReturnType; try { conflicts = findChannelConflicts( @@ -535,12 +497,8 @@ async function checkSlackSocketModeGatewayConflict( return false; } -// Push channel tokens to the OpenShell gateway and add the channel to the -// sandbox registry's messagingChannels list. Done eagerly at `channels -// add` time (not deferred to rebuild) because the host-side credential -// helpers are env-only after the fix — without an immediate gateway -// upsert plus registry update, a "rebuild later" answer would drop the -// queued change since process.env disappears when the CLI exits. +// Push channel tokens to the OpenShell gateway. Durable channel state is +// written separately as a compiled messaging plan. async function applyChannelAddToGatewayAndRegistry( sandboxName: string, channelName: string, @@ -565,24 +523,10 @@ async function applyChannelAddToGatewayAndRegistry( // failure, so reaching the next line means every entry is registered. onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell); } - - // Persist the enabled-channels list in the registry so a deferred - // `nemoclaw rebuild` knows the channel set without needing - // tokens on disk. - const entry = registry.getSandbox(sandboxName); - if (entry) { - const enabled = new Set(entry.messagingChannels || []); - enabled.add(channelName); - const disabled = (entry.disabledChannels || []).filter((c: string) => c !== channelName); - registry.updateSandbox(sandboxName, { - messagingChannels: Array.from(enabled).sort(), - disabledChannels: disabled, - }); - } } // Remove a channel's bridge providers from the gateway and drop it from the -// registry's messagingChannels list. Mirrors applyChannelAddToGatewayAndRegistry. +// compiled messaging plan. Mirrors applyChannelAddToGatewayAndRegistry. async function applyChannelRemoveToGatewayAndRegistry( sandboxName: string, channelName: string, @@ -684,12 +628,6 @@ async function applyChannelRemoveToGatewayAndRegistry( } } - const entry = registry.getSandbox(sandboxName); - if (entry) { - const enabled = (entry.messagingChannels || []).filter((c: string) => c !== channelName); - registry.updateSandbox(sandboxName, { messagingChannels: enabled }); - } - return { ok: residual.length === 0, residual }; } @@ -865,9 +803,9 @@ async function persistManifestChannelDisabledPlan( sandboxName: string, channelId: string, disabled: boolean, -): Promise { +): Promise { const entry = registry.getSandbox(sandboxName); - if (!entry) return; + if (!entry?.messaging?.plan) return false; const agent = resolveAgentForSandbox(sandboxName); const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); const context = { @@ -880,15 +818,15 @@ async function persistManifestChannelDisabledPlan( const plan = disabled ? await planner.buildChannelStopPlanFromSandboxEntry(context) : await planner.buildChannelStartPlanFromSandboxEntry(context); - if (plan) MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); + return plan ? MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan) : false; } async function persistManifestChannelRemovePlan( sandboxName: string, channelId: string, -): Promise { +): Promise { const entry = registry.getSandbox(sandboxName); - if (!entry) return; + if (!entry) return false; const agent = resolveAgentForSandbox(sandboxName); const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); const plan = await planner.buildChannelRemovePlanFromSandboxEntry({ @@ -898,7 +836,8 @@ async function persistManifestChannelRemovePlan( sandboxEntry: entry, supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), }); - if (plan) MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); + if (!plan) return !entry.messaging?.plan; + return MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); } function buildCredentialAvailability(channelIds: readonly string[]): Record { @@ -988,49 +927,9 @@ function hydrateAddChannelEnvFromSession(sandboxName: string, channelId: string) } function persistManifestAddState(sandboxName: string, manifest: ChannelManifest): void { - persistManifestMessagingConfig(sandboxName, manifest); if (manifest.id === "wechat") persistWechatConfigFromEnv(sandboxName); } -function persistManifestMessagingConfig(sandboxName: string, manifest: ChannelManifest): void { - const config = readManifestMessagingConfigFromEnv(manifest); - if (!config) return; - - const entry = registry.getSandbox(sandboxName); - const mergedRegistryConfig = mergeMessagingChannelConfigs(entry?.messagingChannelConfig, config); - if (entry && mergedRegistryConfig) { - registry.updateSandbox(sandboxName, { messagingChannelConfig: mergedRegistryConfig }); - } - - const session = safeLoadOnboardSession(); - if (session?.sandboxName !== sandboxName) return; - const mergedSessionConfig = mergeMessagingChannelConfigs(session.messagingChannelConfig, config); - if (!mergedSessionConfig) return; - try { - onboardSession.updateSession((current) => { - current.messagingChannelConfig = mergedSessionConfig; - return current; - }); - } catch { - // Best-effort: registry state still carries the config when available. - } -} - -function readManifestMessagingConfigFromEnv( - manifest: ChannelManifest, -): MessagingChannelConfig | null { - const result: MessagingChannelConfig = {}; - for (const input of manifest.inputs) { - if (input.kind !== "config" || !input.envKey) continue; - const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); - const normalized = - resolved.value ?? - normalizeMessagingChannelConfigValue(input.envKey, process.env[input.envKey]); - if (normalized) result[input.envKey] = normalized; - } - return sanitizeMessagingChannelConfig(result); -} - function persistWechatConfigFromEnv(sandboxName: string): void { const captured = { accountId: normalizeEnvValue(process.env.WECHAT_ACCOUNT_ID), @@ -1140,7 +1039,10 @@ export async function addSandboxChannel( } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); persistManifestAddState(sandboxName, manifest); - MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); + if (!MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan)) { + console.error(` ${YW}⚠${R} Could not persist messaging plan for '${sandboxName}'.`); + process.exit(1); + } console.log(""); const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; if (help) console.log(` ${help}`); @@ -1164,10 +1066,9 @@ export async function addSandboxChannel( process.exit(1); } const priorEntry = registry.getSandbox(sandboxName); - const priorMessagingChannels: string[] = priorEntry?.messagingChannels - ? [...priorEntry.messagingChannels] - : []; - const wasAlreadyEnabled = priorMessagingChannels.includes(canonical); + const wasAlreadyEnabled = registry + .getConfiguredMessagingChannelsFromEntry(priorEntry) + .includes(canonical); const channelTokenKeys = getChannelTokenKeys(channelDef); const priorCreds: Record = {}; for (const key of channelTokenKeys) { @@ -1186,14 +1087,19 @@ export async function addSandboxChannel( if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { await rollbackChannelAdd(sandboxName, channelDef, canonical, { wasAlreadyEnabled, - priorMessagingChannels, priorCreds, }); process.exit(1); } persistManifestAddState(sandboxName, manifest); - MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); + if (!MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan)) { + console.error(` ${YW}⚠${R} Could not persist messaging plan for '${sandboxName}'.`); + console.error( + " Earlier gateway or policy side effects may already have run, but durable channel state was not saved.", + ); + process.exit(1); + } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); @@ -1205,7 +1111,6 @@ async function rollbackChannelAdd( canonical: string, snapshot: { wasAlreadyEnabled: boolean; - priorMessagingChannels: string[]; priorCreds: Record; }, ): Promise<{ ok: boolean; residual: string[] }> { @@ -1213,9 +1118,6 @@ async function rollbackChannelAdd( console.error( ` ${YW}⚠${R} Restoring prior '${canonical}' configuration; new token rotation aborted.`, ); - registry.updateSandbox(sandboxName, { - messagingChannels: snapshot.priorMessagingChannels, - }); clearChannelTokens(channel); if (Object.keys(snapshot.priorCreds).length > 0) { persistChannelTokens(snapshot.priorCreds); @@ -1246,7 +1148,7 @@ async function rollbackChannelAdd( } console.error( - ` ${YW}⚠${R} Rolling back '${canonical}' bridge registration to keep messagingChannels and policy state aligned.`, + ` ${YW}⚠${R} Rolling back '${canonical}' bridge registration to keep messaging plan and policy state aligned.`, ); clearChannelTokens(channel); const result = await applyChannelRemoveToGatewayAndRegistry( @@ -1468,7 +1370,7 @@ export async function removeSandboxChannel( ? sessionForSandbox.policyPresets : []; const hasChannelResidue = - (registryEntry?.messagingChannels || []).includes(canonical) || + registry.getConfiguredMessagingChannelsFromEntry(registryEntry).includes(canonical) || (registryEntry?.policies || []).includes(canonical) || sessionPolicyPresets.includes(canonical) || policies.getAppliedPresets(sandboxName).includes(canonical); @@ -1503,7 +1405,10 @@ export async function removeSandboxChannel( } removeChannelPresetIfPresent(sandboxName, canonical); - await persistManifestChannelRemovePlan(sandboxName, canonical); + if (!(await persistManifestChannelRemovePlan(sandboxName, canonical))) { + console.error(` ${YW}⚠${R} Could not persist messaging plan for '${sandboxName}'.`); + process.exit(1); + } // Token-based channels: best-effort tidy of any leftover dir. Token // revocation already prevents the bot from authenticating, so a @@ -1536,12 +1441,18 @@ async function sandboxChannelsSetEnabled( process.exit(1); } - if (!registry.getSandbox(sandboxName)) { + const registryEntry = registry.getSandbox(sandboxName); + if (!registryEntry) { console.error(` Sandbox '${sandboxName}' not found in the registry.`); process.exit(1); } const normalized = channelArg.trim().toLowerCase(); + const configuredChannels = registry.getConfiguredMessagingChannelsFromEntry(registryEntry); + if (!configuredChannels.includes(normalized)) { + console.error(` Channel '${normalized}' is not configured for '${sandboxName}'.`); + process.exit(1); + } const alreadyDisabled = registry.getDisabledChannels(sandboxName).includes(normalized); if (alreadyDisabled === disabled) { console.log( @@ -1555,11 +1466,10 @@ async function sandboxChannelsSetEnabled( return; } - if (!registry.setChannelDisabled(sandboxName, normalized, disabled)) { - console.error(` Sandbox '${sandboxName}' not found in the registry.`); + if (!(await persistManifestChannelDisabledPlan(sandboxName, normalized, disabled))) { + console.error(` Could not persist messaging plan for '${sandboxName}'.`); process.exit(1); } - await persistManifestChannelDisabledPlan(sandboxName, normalized, disabled); const state = disabled ? "disabled" : "enabled"; console.log(` ${G}✓${R} Marked ${normalized} ${state} for '${sandboxName}'.`); await promptAndRebuild(sandboxName, `${verb} '${normalized}'`); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index e53c1dc426..e2c1daaeff 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -55,7 +55,6 @@ import { MessagingWorkflowPlanner, toMessagingAgentId, } from "../../messaging"; -import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -200,19 +199,22 @@ async function stageMessagingManifestPlanForRebuild( ): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); - hydrateMessagingChannelConfig(sandboxEntry.messagingChannelConfig); const plan = await planner.buildRebuildPlanFromSandboxEntry({ sandboxName, agent: toMessagingAgentId(agent), sandboxEntry, supportedChannelIds: agent.messagingPlatforms, }); - if (!plan || plan.channels.length === 0) { + if (!plan) { MessagingSetupApplier.clearPlanEnv(); log("Messaging manifest rebuild plan: no configured channels"); return null; } MessagingSetupApplier.writePlanToEnv(plan); + if (plan.channels.length === 0) { + log("Messaging manifest rebuild plan staged: no configured channels"); + return plan; + } log( `Messaging manifest rebuild plan staged: ${plan.channels .map((channel) => channel.channelId) @@ -830,21 +832,6 @@ export async function rebuildSandbox( // Mark session resumable and point at this sandbox; set env var as fallback. const sessionBefore = onboardSession.loadSession(); const sessionMatchesSandbox = sessionBefore?.sandboxName === sandboxName; - const registryMessagingChannels = Array.isArray(sb.messagingChannels) - ? sb.messagingChannels.filter((value: unknown): value is string => typeof value === "string") - : null; - const sessionMessagingChannels = - sessionMatchesSandbox && Array.isArray(sessionBefore?.messagingChannels) - ? sessionBefore.messagingChannels.filter( - (value: unknown): value is string => typeof value === "string", - ) - : null; - const rebuildMessagingChannels = registryMessagingChannels ?? sessionMessagingChannels ?? []; - const sessionMessagingChannelConfig = sessionMatchesSandbox - ? (sessionBefore?.messagingChannelConfig ?? null) - : null; - const rebuildMessagingChannelConfig = - sb.messagingChannelConfig ?? sessionMessagingChannelConfig ?? null; const rebuildsHermesSandbox = rebuildAgent === "hermes"; let registryHermesToolGateways: string[] | null = null; if (rebuildsHermesSandbox && Array.isArray(sb.hermesToolGateways)) { @@ -866,23 +853,6 @@ export async function rebuildSandbox( const hasRebuildHermesToolGateways = rebuildsHermesSandbox && (registryHermesToolGateways !== null || sessionHermesToolGateways !== null); - const hasRebuildMessagingChannels = - registryMessagingChannels !== null || sessionMessagingChannels !== null; - // Snapshot the operator's paused channel set BEFORE `removeSandboxRegistryEntry` - // wipes the registry entry. Otherwise the `disabledChannels` filter inside - // `createSandbox` (onboard.ts) reads back `[]` from the freshly-empty registry - // and the stopped channel comes back live in the rebuilt image. The session - // mirror is the only place this list can survive the destroy/recreate window. - // - // Always re-stash from `sb` — do NOT fall back to a prior session value. - // `sb` is loaded fresh from the registry at the top of rebuildSandbox, so it - // already reflects the latest `channels stop|start` write. The session mirror - // is downstream of the registry; re-stashing on every rebuild keeps a stale - // ["telegram"] from a prior stop/rebuild cycle from leaking into the next - // start/rebuild and filtering the channel back out. - const rebuildDisabledChannels = Array.isArray(sb.disabledChannels) - ? sb.disabledChannels.filter((value: unknown): value is string => typeof value === "string") - : []; log( `Session before update: sandboxName=${sessionBefore?.sandboxName}, status=${sessionBefore?.status}, resumable=${sessionBefore?.resumable}, provider=${sessionBefore?.provider}, model=${sessionBefore?.model}, sessionMatch=${sessionMatchesSandbox}`, ); @@ -896,9 +866,6 @@ export async function rebuildSandbox( s.resumable = true; s.status = "in_progress"; s.agent = rebuildAgent; - 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 @@ -1077,9 +1044,6 @@ export async function rebuildSandbox( } const preservedRegistryFields = { - ...(hasRebuildMessagingChannels ? { messagingChannels: [...rebuildMessagingChannels] } : {}), - disabledChannels: - rebuildDisabledChannels.length > 0 ? [...rebuildDisabledChannels] : undefined, ...(hasRebuildHermesToolGateways ? { hermesToolGateways: [...rebuildHermesToolGateways] } : {}), @@ -1129,6 +1093,7 @@ export async function rebuildSandbox( const registryPolicyPresets = Array.isArray(sb.policies) ? sb.policies.filter((value: unknown): value is string => typeof value === "string") : []; + const rebuildDisabledChannels = [...(rebuildMessagingPlan?.disabledChannels ?? [])]; const savedPresets = pruneDisabledMessagingPolicyPresets( backupManifest?.policyPresets ?? registryPolicyPresets, rebuildDisabledChannels, diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index 1a119adca8..148b9de957 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -7,9 +7,46 @@ import { getSandboxInventory, getStatusReport, listSandboxesCommand, + type SandboxEntry, showStatusCommand, } from "./index"; +type MessagingState = NonNullable; +type MessagingChannelId = MessagingState["plan"]["channels"][number]["channelId"]; + +function messagingState( + sandboxName: string, + channels: readonly MessagingChannelId[], +): MessagingState { + return { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: channels.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }; +} + describe("inventory commands", () => { it("returns structured empty inventory for JSON consumers", async () => { const getLiveInference = vi.fn().mockReturnValue(null); @@ -356,7 +393,7 @@ describe("inventory commands", () => { { name: "alpha", model: "m", - messagingChannels: ["telegram"], + messaging: messagingState("alpha", ["telegram"]), }, ], defaultSandbox: "alpha", @@ -391,9 +428,9 @@ describe("inventory commands", () => { expect(lines.some((l) => l.includes("degraded"))).toBe(false); }); - it("prints a cross-sandbox overlap warning when backfillAndFindOverlaps reports overlaps", () => { + it("prints a cross-sandbox overlap warning when findMessagingOverlaps reports overlaps", () => { const lines: string[] = []; - const backfillAndFindOverlaps = vi + const findMessagingOverlaps = vi .fn() .mockReturnValue([ { channel: "telegram", sandboxes: ["alice", "bob"], reason: "matching-token" }, @@ -401,18 +438,18 @@ describe("inventory commands", () => { showStatusCommand({ listSandboxes: () => ({ sandboxes: [ - { name: "alice", model: "m", messagingChannels: ["telegram"] }, - { name: "bob", model: "m", messagingChannels: ["telegram"] }, + { name: "alice", model: "m", messaging: messagingState("alice", ["telegram"]) }, + { name: "bob", model: "m", messaging: messagingState("bob", ["telegram"]) }, ], defaultSandbox: "alice", }), getLiveInference: () => null, showServiceStatus: vi.fn(), - backfillAndFindOverlaps, + findMessagingOverlaps, log: (message = "") => lines.push(message), }); - expect(backfillAndFindOverlaps).toHaveBeenCalled(); + expect(findMessagingOverlaps).toHaveBeenCalled(); expect( lines.some((l) => l.includes("'alice' and 'bob' share the same telegram credential")), ).toBe(true); @@ -420,20 +457,20 @@ describe("inventory commands", () => { it("defaults missing overlap reason to the conservative warning", () => { const lines: string[] = []; - const backfillAndFindOverlaps = vi + const findMessagingOverlaps = vi .fn() .mockReturnValue([{ channel: "telegram", sandboxes: ["alice", "bob"] }]); showStatusCommand({ listSandboxes: () => ({ sandboxes: [ - { name: "alice", model: "m", messagingChannels: ["telegram"] }, - { name: "bob", model: "m", messagingChannels: ["telegram"] }, + { name: "alice", model: "m", messaging: messagingState("alice", ["telegram"]) }, + { name: "bob", model: "m", messaging: messagingState("bob", ["telegram"]) }, ], defaultSandbox: "alice", }), getLiveInference: () => null, showServiceStatus: vi.fn(), - backfillAndFindOverlaps, + findMessagingOverlaps, log: (message = "") => lines.push(message), }); @@ -448,7 +485,7 @@ describe("inventory commands", () => { it("marks a shared-gateway Slack Socket Mode overlap as conflicted (#4953)", () => { const lines: string[] = []; - const backfillAndFindOverlaps = vi + const findMessagingOverlaps = vi .fn() .mockReturnValue([ { channel: "slack", sandboxes: ["alice", "bob"], reason: "slack-socket-mode-gateway" }, @@ -456,14 +493,14 @@ describe("inventory commands", () => { showStatusCommand({ listSandboxes: () => ({ sandboxes: [ - { name: "alice", model: "m", messagingChannels: ["slack"] }, - { name: "bob", model: "m", messagingChannels: ["slack"] }, + { name: "alice", model: "m", messaging: messagingState("alice", ["slack"]) }, + { name: "bob", model: "m", messaging: messagingState("bob", ["slack"]) }, ], defaultSandbox: "alice", }), getLiveInference: () => null, showServiceStatus: vi.fn(), - backfillAndFindOverlaps, + findMessagingOverlaps, log: (message = "") => lines.push(message), }); @@ -491,7 +528,7 @@ describe("inventory commands", () => { { name: "alpha", model: "m", - messagingChannels: ["telegram"], + messaging: messagingState("alpha", ["telegram"]), agent: "hermes", }, ], @@ -521,7 +558,7 @@ describe("inventory commands", () => { { name: "alpha", model: "m", - messagingChannels: ["telegram"], + messaging: messagingState("alpha", ["telegram"]), }, ], defaultSandbox: "alpha", diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 43df3f6d13..74bb7d51b4 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -3,7 +3,9 @@ import { CLI_NAME } from "../cli/branding"; import type { GatewayInference } from "../inference/config"; +import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation"; import { redactFull } from "../security/redact"; +import type { SandboxMessagingState } from "../state/registry"; import { resolveDefaultSandboxName } from "../tunnel/service-command"; export interface SandboxEntry { @@ -18,7 +20,7 @@ export interface SandboxEntry { openshellDriver?: string | null; openshellVersion?: string | null; policies?: string[] | null; - messagingChannels?: string[] | null; + messaging?: SandboxMessagingState | null; agent?: string | null; dashboardPort?: number | null; } @@ -118,7 +120,7 @@ export interface ShowStatusCommandDeps { */ getGatewayHealth?: () => GatewayHealth; checkMessagingBridgeHealth?: (sandboxName: string, channels: string[]) => MessagingBridgeHealth[]; - backfillAndFindOverlaps?: () => MessagingOverlap[]; + findMessagingOverlaps?: () => MessagingOverlap[]; readGatewayLog?: (sandboxName: string) => string | null; log?: (message?: string) => void; } @@ -472,8 +474,8 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { deps.showServiceStatus({ sandboxName: resolvedDefault || undefined }); - if (deps.backfillAndFindOverlaps) { - const overlaps = deps.backfillAndFindOverlaps(); + if (deps.findMessagingOverlaps) { + const overlaps = deps.findMessagingOverlaps(); if (overlaps.length > 0) { log(""); for (const { channel, sandboxes: pair, reason } of overlaps) { @@ -498,13 +500,10 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { } if (deps.checkMessagingBridgeHealth && resolvedDefault) { - // Re-fetch: backfillAndFindOverlaps above may have populated - // messagingChannels for the default sandbox on first run after upgrade, - // and the original `sandboxes` snapshot is stale. const refreshed = deps.listSandboxes().sandboxes; const defaultEntry = refreshed.find((sb) => sb.name === resolvedDefault); - const channels = defaultEntry?.messagingChannels; - if (Array.isArray(channels) && channels.length > 0) { + const channels = getActiveChannelIdsFromPlan(defaultEntry?.messaging?.plan); + if (channels.length > 0) { const degraded = deps.checkMessagingBridgeHealth(resolvedDefault, channels); if (degraded.length > 0) { log(""); diff --git a/src/lib/messaging/applier/conflict-detection-legacy.test.ts b/src/lib/messaging/applier/conflict-detection-legacy.test.ts deleted file mode 100644 index 07ed0ccfc3..0000000000 --- a/src/lib/messaging/applier/conflict-detection-legacy.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Legacy-field (messagingChannels / disabledChannels) conflict tests. -// 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 { makePlan } from "../../../../test/helpers/messaging-conflict-fixtures"; -import type { SandboxEntry } from "../../state/registry"; -import { - backfillMessagingChannels, - findAllOverlaps, - findChannelConflicts, - type MessagingConflictProbe, -} from "./conflict-detection"; - -type ProviderExists = MessagingConflictProbe["providerExists"]; - -function makeRegistry(sandboxes: SandboxEntry[]) { - const store = new Map(sandboxes.map((s) => [s.name, { ...s }])); - return { - listSandboxes: () => ({ - sandboxes: Array.from(store.values()), - defaultSandbox: sandboxes[0]?.name ?? null, - }), - updateSandbox: vi.fn((name: string, updates: Partial) => { - const entry = store.get(name); - if (!entry) return false; - Object.assign(entry, updates); - return true; - }), - }; -} - -describe("findChannelConflicts", () => { - it("returns unknown conflicts when another sandbox has the channel without hashes", () => { - const registry = makeRegistry([ - { name: "alice", messagingChannels: ["telegram"] }, - { name: "bob", messagingChannels: [] }, - ]); - expect(findChannelConflicts("bob", ["telegram"], registry)).toEqual([ - { channel: "telegram", sandbox: "alice", reason: "unknown-token" }, - ]); - }); - - it("returns unknown-token for any legacy entry sharing the channel (no hash data)", () => { - const registry = makeRegistry([ - { name: "alice", messagingChannels: ["telegram"] }, - { name: "carol", messagingChannels: ["telegram"] }, - ]); - expect( - findChannelConflicts( - "bob", - [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], - registry, - ), - ).toEqual([ - { channel: "telegram", sandbox: "alice", reason: "unknown-token" }, - { channel: "telegram", sandbox: "carol", reason: "unknown-token" }, - ]); - }); - - it("excludes the current sandbox from its own conflicts", () => { - const registry = makeRegistry([{ name: "alice", messagingChannels: ["telegram"] }]); - expect(findChannelConflicts("alice", ["telegram"], registry)).toEqual([]); - }); - - it("skips entries with no messagingChannels field (pre-backfill)", () => { - const registry = makeRegistry([{ name: "alice" }, { name: "bob", messagingChannels: [] }]); - expect(findChannelConflicts("bob", ["telegram"], registry)).toEqual([]); - }); - - it("returns empty when no channels are enabled", () => { - const registry = makeRegistry([{ name: "alice", messagingChannels: ["telegram"] }]); - expect(findChannelConflicts("bob", [], registry)).toEqual([]); - }); - - it("ignores a stopped (disabled) channel — its credential is not in use (#3381)", () => { - const registry = makeRegistry([ - { - name: "alice", - messagingChannels: ["telegram"], - disabledChannels: ["telegram"], - }, - ]); - expect( - findChannelConflicts( - "bob", - [{ channel: "telegram", credentialHashes: { TELEGRAM_BOT_TOKEN: "hash-a" } }], - registry, - ), - ).toEqual([]); - }); -}); - -describe("findAllOverlaps", () => { - it("reports each overlapping pair once", () => { - const registry = makeRegistry([ - { name: "alice", messagingChannels: ["telegram"] }, - { name: "bob", messagingChannels: ["telegram"] }, - { name: "carol", messagingChannels: ["discord"] }, - ]); - expect(findAllOverlaps(registry)).toEqual([ - { channel: "telegram", sandboxes: ["alice", "bob"], reason: "unknown-token" }, - ]); - }); - - it("reports all unknown pairs when three sandboxes share a channel without hashes", () => { - const registry = makeRegistry([ - { name: "a", messagingChannels: ["telegram"] }, - { name: "b", messagingChannels: ["telegram"] }, - { name: "c", messagingChannels: ["telegram"] }, - ]); - expect(findAllOverlaps(registry)).toEqual([ - { channel: "telegram", sandboxes: ["a", "b"], reason: "unknown-token" }, - { channel: "telegram", sandboxes: ["a", "c"], reason: "unknown-token" }, - { channel: "telegram", sandboxes: ["b", "c"], reason: "unknown-token" }, - ]); - }); - - it("returns empty when channels do not overlap", () => { - const registry = makeRegistry([ - { name: "alice", messagingChannels: ["telegram"] }, - { name: "bob", messagingChannels: ["discord"] }, - ]); - expect(findAllOverlaps(registry)).toEqual([]); - }); - - it("ignores stopped (disabled) channels so nemoclaw status does not report phantom overlaps (#3381)", () => { - const registry = makeRegistry([ - { - name: "alice", - messagingChannels: ["telegram"], - disabledChannels: ["telegram"], - }, - { name: "bob", messagingChannels: ["telegram"] }, - ]); - expect(findAllOverlaps(registry)).toEqual([]); - }); -}); - -describe("backfillMessagingChannels", () => { - it("fills in missing messagingChannels by probing OpenShell", () => { - const registry = makeRegistry([{ name: "alice" }]); - const probe: MessagingConflictProbe = { - providerExists: vi.fn((name) => - name === "alice-telegram-bridge" ? "present" : "absent", - ), - }; - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { - messagingChannels: ["telegram"], - }); - expect(probe.providerExists).toHaveBeenCalledWith("alice-telegram-bridge"); - expect(probe.providerExists).toHaveBeenCalledWith("alice-discord-bridge"); - expect(probe.providerExists).toHaveBeenCalledWith("alice-slack-bridge"); - expect(probe.providerExists).toHaveBeenCalledWith("alice-wechat-bridge"); - }); - - it("backfills wechat when only the wechat bridge provider is present", () => { - const registry = makeRegistry([{ name: "alice" }]); - const probe: MessagingConflictProbe = { - providerExists: vi.fn((name) => - name === "alice-wechat-bridge" ? "present" : "absent", - ), - }; - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { - messagingChannels: ["wechat"], - }); - }); - - it("surfaces a wechat conflict when two sandboxes share the channel without hashes", () => { - const registry = makeRegistry([ - { name: "alice", messagingChannels: ["wechat"] }, - { name: "bob", messagingChannels: [] }, - ]); - expect(findChannelConflicts("bob", ["wechat"], registry)).toEqual([ - { channel: "wechat", sandbox: "alice", reason: "unknown-token" }, - ]); - }); - - it("leaves entries with existing messagingChannels alone", () => { - const registry = makeRegistry([{ name: "alice", messagingChannels: ["telegram"] }]); - const probe: MessagingConflictProbe = { - providerExists: vi.fn(() => "present"), - }; - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).not.toHaveBeenCalled(); - 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 = { - providerExists: vi.fn(() => "absent"), - }; - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { messagingChannels: [] }); - }); - - it("does NOT persist when a probe returns error (retry on next call)", () => { - const registry = makeRegistry([{ name: "alice" }]); - const probe: MessagingConflictProbe = { - providerExists: vi.fn((name) => { - if (name.endsWith("-telegram-bridge")) return "error"; - return name.endsWith("-discord-bridge") ? "present" : "absent"; - }), - }; - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).not.toHaveBeenCalled(); - }); - - it("also treats a thrown probe as error (defensive; callers should return 'error' instead)", () => { - const registry = makeRegistry([{ name: "alice" }]); - const probe: MessagingConflictProbe = { - providerExists: vi.fn(() => { - throw new Error("unexpected"); - }), - }; - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).not.toHaveBeenCalled(); - }); - - it("re-attempts backfill on a subsequent call after a prior error", () => { - const registry = makeRegistry([{ name: "alice" }]); - let firstPass = true; - const probe: MessagingConflictProbe = { - providerExists: vi.fn((name) => { - if (name.endsWith("-telegram-bridge") && firstPass) { - firstPass = false; - return "error"; - } - return name === "alice-telegram-bridge" ? "present" : "absent"; - }), - }; - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).not.toHaveBeenCalled(); - backfillMessagingChannels(registry, probe); - expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { - messagingChannels: ["telegram"], - }); - }); -}); diff --git a/src/lib/messaging/applier/conflict-detection/backfill.ts b/src/lib/messaging/applier/conflict-detection/backfill.ts deleted file mode 100644 index b07e78846e..0000000000 --- a/src/lib/messaging/applier/conflict-detection/backfill.ts +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { PROVIDER_SUFFIXES } from "./manifest-metadata"; -import type { - ConflictRegistry, - ConflictRegistryEntry, - MessagingConflictProbe, - ProbeResult, -} from "./types"; - -/** - * 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[], - probe: MessagingConflictProbe, - updateEntry: (name: string, channels: string[]) => void, - providerSuffixes: Record, -): void { - for (const entry of entries) { - if (entry.messaging?.plan || 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/entries.ts b/src/lib/messaging/applier/conflict-detection/entries.ts index aef991aa35..bcfa81eabf 100644 --- a/src/lib/messaging/applier/conflict-detection/entries.ts +++ b/src/lib/messaging/applier/conflict-detection/entries.ts @@ -12,27 +12,18 @@ import type { /** * 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. + * `entry.messaging.plan` is the only supported persisted messaging state. + * Entries without a plan have no supported messaging state. */ 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 null; } /** * 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, @@ -141,11 +132,7 @@ export function findConflictsInEntries( requests: readonly ConflictRequest[], entries: readonly ConflictRegistryEntry[], ): ConflictMatch[] { - const others = entries.filter( - (e) => - e.name !== currentSandbox && - (Array.isArray(e.messagingChannels) || e.messaging?.plan != null), - ); + const others = entries.filter((e) => e.name !== currentSandbox && e.messaging?.plan != null); return requests.flatMap((request) => others.flatMap((entry) => { const reason = conflictReasonForRequest(entry, request); diff --git a/src/lib/messaging/applier/conflict-detection/index.ts b/src/lib/messaging/applier/conflict-detection/index.ts index 59ea640afc..af9f7a22e6 100644 --- a/src/lib/messaging/applier/conflict-detection/index.ts +++ b/src/lib/messaging/applier/conflict-detection/index.ts @@ -1,10 +1,8 @@ // 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 * from "./slack-socket-mode"; export type * from "./types"; diff --git a/src/lib/messaging/applier/conflict-detection/probe.ts b/src/lib/messaging/applier/conflict-detection/probe.ts deleted file mode 100644 index 96f2325628..0000000000 --- a/src/lib/messaging/applier/conflict-detection/probe.ts +++ /dev/null @@ -1,26 +0,0 @@ -// 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/types.ts b/src/lib/messaging/applier/conflict-detection/types.ts index 822d7ca748..b1809658c9 100644 --- a/src/lib/messaging/applier/conflict-detection/types.ts +++ b/src/lib/messaging/applier/conflict-detection/types.ts @@ -3,23 +3,8 @@ import type { SandboxMessagingPlan } from "../../manifest"; -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 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; @@ -42,8 +27,6 @@ export type ChannelConflictRequest = export interface ConflictRegistryEntry { readonly name: string; readonly messaging?: { readonly plan: SandboxMessagingPlan } | null; - readonly messagingChannels?: readonly string[] | null; - readonly disabledChannels?: readonly string[] | null; // OpenShell gateway registration name this sandbox is bound to. Used by the // gateway-scoped Slack Socket Mode conflict detection (#4953). A missing name // normalizes to the default `nemoclaw` gateway (see slack-socket-mode.ts). @@ -55,5 +38,4 @@ export interface ConflictRegistry { sandboxes: ConflictRegistryEntry[]; defaultSandbox?: string | null; }; - updateSandbox: (name: string, updates: { messagingChannels?: string[] }) => boolean; } diff --git a/src/lib/messaging/applier/host-state-applier.test.ts b/src/lib/messaging/applier/host-state-applier.test.ts index 4ff9b4149c..b438efa498 100644 --- a/src/lib/messaging/applier/host-state-applier.test.ts +++ b/src/lib/messaging/applier/host-state-applier.test.ts @@ -53,8 +53,6 @@ describe("MessagingHostStateApplier", () => { it("stores only the new messaging state on an existing sandbox entry", () => { registryMock.__setSandbox("demo", { name: "demo", - messagingChannels: ["telegram"], - disabledChannels: ["discord"], }); const plan = makePlan(["telegram"]); @@ -68,8 +66,6 @@ describe("MessagingHostStateApplier", () => { }, }); expect(registryMock.__getSandbox("demo")).toMatchObject({ - messagingChannels: ["telegram"], - disabledChannels: ["discord"], messaging: { schemaVersion: 1, plan, diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 467ca1bb5f..e18c69ad6d 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -651,7 +651,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", sandboxEntry: { name: "demo", - messagingChannels: ["telegram"], messaging: { schemaVersion: 1, plan: existingPlan, @@ -677,74 +676,6 @@ describe("MessagingWorkflowPlanner", () => { ); }); - it("rebuilds legacy registry entries from messaging channels and provider credential hashes", async () => { - await withEnv( - { - DISCORD_BOT_TOKEN: undefined, - DISCORD_SERVER_ID: "1491590992753590594", - DISCORD_REQUIRE_MENTION: "0", - DISCORD_USER_ID: "1005536447329222676", - }, - async () => { - const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ - sandboxName: "demo", - agent: "hermes", - sandboxEntry: { - name: "demo", - messagingChannels: ["discord"], - providerCredentialHashes: { - DISCORD_BOT_TOKEN: "sha256-test-discord-token", - }, - }, - }); - - const discordChannel = rebuilt?.channels.find((channel) => channel.channelId === "discord"); - const discordEnv = rebuilt?.agentRender.find( - (render) => render.channelId === "discord" && render.kind === "env-lines", - ); - const discordConfig = rebuilt?.agentRender.find( - (render) => - render.channelId === "discord" && - render.kind === "json-fragment" && - render.path === "discord", - ); - - expect(rebuilt?.workflow).toBe("rebuild"); - expect(discordChannel).toMatchObject({ - active: true, - disabled: false, - configured: true, - }); - expect( - rebuilt?.credentialBindings.find( - (binding) => - binding.channelId === "discord" && binding.providerEnvKey === "DISCORD_BOT_TOKEN", - ), - ).toMatchObject({ credentialAvailable: true }); - expect(discordEnv).toMatchObject({ - lines: [ - "DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", - "NEMOCLAW_DISCORD_GUILD_IDS=1491590992753590594", - "DISCORD_ALLOWED_USERS=1005536447329222676", - ], - }); - expect(discordConfig).toMatchObject({ - value: { - require_mention: false, - }, - }); - expect( - rebuilt?.agentRender.find( - (render) => - render.channelId === "discord" && - render.kind === "json-fragment" && - render.path === "platforms.discord", - ), - ).toMatchObject({ value: { enabled: true } }); - }, - ); - }); - it("does not compile a rebuild plan when the sandbox entry has no stored plan or channels", async () => { const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ sandboxName: "demo", diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 3957f5ebda..acb0f01b87 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -107,26 +107,7 @@ export class MessagingWorkflowPlanner { "rebuild", ); } - - const configuredChannels = uniqueChannels(context.sandboxEntry?.messagingChannels); - if (configuredChannels.length === 0) return null; - - return this.buildPlan({ - sandboxName: context.sandboxName, - agent: context.agent, - workflow: "rebuild", - isInteractive: false, - configuredChannels, - disabledChannels: disabledChannelsFromSandboxEntry(context.sandboxEntry, null), - supportedChannelIds: context.supportedChannelIds, - credentialAvailability: mergeAvailability( - this.credentialAvailabilityFromProviderCredentialHashes( - context.sandboxEntry, - configuredChannels, - ), - context.credentialAvailability, - ), - }); + return null; } private assertSupportedChannels( @@ -194,37 +175,11 @@ export class MessagingWorkflowPlanner { } return Object.keys(availability).length > 0 ? availability : undefined; } - - private credentialAvailabilityFromProviderCredentialHashes( - sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, - channelIds: readonly MessagingChannelId[], - ): MessagingCompilerCredentialAvailability | undefined { - const hashes = sandboxEntry?.providerCredentialHashes; - if (!hashes) 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; - availability[credential.sourceInput] = true; - availability[manifest.id + "." + credential.sourceInput] = true; - availability[credential.id] = true; - availability[manifest.id + "." + credential.id] = true; - availability[credential.providerEnvKey] = true; - } - } - return Object.keys(availability).length > 0 ? availability : undefined; - } } export interface MessagingWorkflowPlannerSandboxEntry { readonly name: string; 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; @@ -282,14 +237,10 @@ function readSandboxEntryPlan( } function disabledChannelsFromSandboxEntry( - sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, + _sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, fallbackPlan: SandboxMessagingPlan | null, ): MessagingChannelId[] { - return uniqueChannels( - Array.isArray(sandboxEntry?.disabledChannels) - ? sandboxEntry.disabledChannels - : (fallbackPlan?.disabledChannels ?? []), - ); + return uniqueChannels(fallbackPlan?.disabledChannels ?? []); } function clonePlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts new file mode 100644 index 0000000000..c7a9d49078 --- /dev/null +++ b/src/lib/messaging/plan-validation.test.ts @@ -0,0 +1,98 @@ +// 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, + getConfiguredChannelIdsFromPlan, + getDisabledChannelIdsFromPlan, + getMessagingChannelConfigFromPlan, + parseSandboxMessagingPlan, +} from "./plan-validation"; + +function makePlan(overrides: Partial = {}): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "sb", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "telegram", + inputId: "allowedIds", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_ALLOWED_IDS", + value: "123", + }, + ], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +describe("parseSandboxMessagingPlan", () => { + it("returns a cloned plan when the schema and optional selectors match", () => { + const source = makePlan(); + const parsed = parseSandboxMessagingPlan(source, { + sandboxName: "sb", + agent: "openclaw", + supportedChannelIds: ["telegram"], + }); + + expect(parsed).toEqual(source); + expect(parsed).not.toBe(source); + }); + + it("rejects mismatched selectors, duplicate channels, and unsupported channels", () => { + expect(parseSandboxMessagingPlan(makePlan(), { sandboxName: "other" })).toBeNull(); + expect(parseSandboxMessagingPlan(makePlan(), { agent: "hermes" })).toBeNull(); + expect(parseSandboxMessagingPlan(makePlan(), { supportedChannelIds: ["discord"] })).toBeNull(); + expect( + parseSandboxMessagingPlan( + makePlan({ channels: [makePlan().channels[0], makePlan().channels[0]] }), + ), + ).toBeNull(); + }); + + it("rejects malformed channel arrays without throwing", () => { + const plan = makePlan() as unknown as { channels: unknown[] }; + plan.channels = [null]; + + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); +}); + +describe("plan channel derivation", () => { + it("derives configured, active, disabled, and config values from a plan", () => { + const plan = makePlan({ + disabledChannels: ["telegram"], + channels: [{ ...makePlan().channels[0], disabled: true, active: false }], + }); + + expect(getConfiguredChannelIdsFromPlan(plan)).toEqual(["telegram"]); + expect(getActiveChannelIdsFromPlan(plan)).toEqual([]); + expect(getDisabledChannelIdsFromPlan(plan)).toEqual(["telegram"]); + expect(getMessagingChannelConfigFromPlan(plan)).toEqual({ TELEGRAM_ALLOWED_IDS: "123" }); + }); +}); diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts new file mode 100644 index 0000000000..a10c114132 --- /dev/null +++ b/src/lib/messaging/plan-validation.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingChannelConfig } from "../messaging-channel-config"; +import type { MessagingAgentId, MessagingChannelId, SandboxMessagingPlan } from "./manifest"; + +export interface SandboxMessagingPlanParseOptions { + sandboxName?: string | null; + agent?: MessagingAgentId | string | null; + supportedChannelIds?: readonly MessagingChannelId[] | readonly string[] | null; +} + +export function parseSandboxMessagingPlan( + value: unknown, + options: SandboxMessagingPlanParseOptions = {}, +): 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; + } + + if (options.sandboxName && value.sandboxName !== options.sandboxName) return null; + if (options.agent && value.agent !== options.agent) return null; + + const supported = + options.supportedChannelIds && options.supportedChannelIds.length > 0 + ? new Set(options.supportedChannelIds) + : null; + for (const [index, channel] of value.channels.entries()) { + if (!isObject(channel) || typeof channel.channelId !== "string") return null; + if (typeof channel.configured !== "boolean") return null; + if (typeof channel.active !== "boolean") return null; + if (typeof channel.disabled !== "boolean") return null; + if (!Array.isArray(channel.inputs)) return null; + if (supported && !supported.has(channel.channelId)) return null; + if ( + value.channels.findIndex( + (candidate) => isObject(candidate) && candidate.channelId === channel.channelId, + ) !== index + ) { + return null; + } + } + if (!value.disabledChannels.every((channelId) => typeof channelId === "string")) return null; + + return cloneSandboxMessagingPlan(value as unknown as SandboxMessagingPlan); +} + +export function cloneSandboxMessagingPlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { + return JSON.parse(JSON.stringify(plan)) as SandboxMessagingPlan; +} + +export function getConfiguredChannelIdsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] { + if (!plan) return []; + return plan.channels.filter((channel) => channel.configured).map((channel) => channel.channelId); +} + +export function getActiveChannelIdsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] { + if (!plan) return []; + const disabled = new Set(plan.disabledChannels); + return plan.channels + .filter((channel) => channel.active && !channel.disabled && !disabled.has(channel.channelId)) + .map((channel) => channel.channelId); +} + +export function getDisabledChannelIdsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] { + return plan ? [...plan.disabledChannels] : []; +} + +export function getMessagingChannelConfigFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): MessagingChannelConfig | null { + if (!plan) return null; + const config: MessagingChannelConfig = {}; + for (const channel of plan.channels) { + for (const input of channel.inputs) { + if (input.kind !== "config" || !input.sourceEnv || input.value == null) continue; + config[input.sourceEnv] = String(input.value); + } + } + return Object.keys(config).length > 0 ? config : null; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e92f0900dd..debbb8a6cd 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -403,8 +403,10 @@ const { computeTelegramRequireMention, getStoredMessagingChannelConfig, messagingChannelConfigsEqual, - persistMessagingChannelConfigToSession, } = messagingConfig; +const messagingPlanSession: typeof import("./onboard/messaging-plan-session") = + require("./onboard/messaging-plan-session"); +const { getChannelsFromPlan } = messagingPlanSession; const messagingPrep: typeof import("./onboard/messaging-prep") = require("./onboard/messaging-prep"); const sandboxAgent: typeof import("./onboard/sandbox-agent") = require("./onboard/sandbox-agent"); const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require("./onboard/sandbox-lifecycle"); @@ -545,7 +547,6 @@ import type { WebSearchConfig } from "./inference/web-search"; import { hydrateMessagingChannelConfig, type MessagingChannelConfig, - readMessagingChannelConfigFromEnv, } from "./messaging-channel-config"; import { finalizationHandlerDeps } from "./onboard/finalization-deps"; import { streamGatewayStart } from "./onboard/gateway"; @@ -2605,9 +2606,6 @@ async function createSandbox( currentPlan, currentSandboxDisabledChannels: disabledChannels, registry, - checkGatewayLiveness: () => - runOpenshell(["sandbox", "list"], { ignoreError: true, suppressOutput: true }).status === 0, - providerExists: (name) => providerExistsInGateway(name), isNonInteractive, promptContinue: () => promptYesNoOrDefault(" Continue anyway?", null, false), cliName, @@ -3047,13 +3045,15 @@ async function createSandbox( } console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - const messagingChannelConfig = readMessagingChannelConfigFromEnv(); - // Telegram mention-only mode — parity with Discord's requireMention. - // Off by default so existing sandboxes behave the same; opt-in via - // TELEGRAM_REQUIRE_MENTION=1 or the interactive prompt. See #1737. + const envMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); + const plannedMessagingState = + envMessagingState?.plan.sandboxName === sandboxName ? envMessagingState : undefined; + const plannedMessagingPlan = plannedMessagingState?.plan; + // Telegram mention-only mode; off unless enabled by TELEGRAM_REQUIRE_MENTION or prompt. const telegramConfig: { requireMention?: boolean } = {}; const configuredMessagingChannels = - enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels; + getChannelsFromPlan(plannedMessagingPlan) ?? + (enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels); if (configuredMessagingChannels.includes("telegram")) { const telegramRequireMention = computeTelegramRequireMention(); if (telegramRequireMention !== null) { @@ -3071,7 +3071,6 @@ async function createSandbox( ? { requireMention: telegramConfig.requireMention as boolean } : null; current.wechatConfig = toSessionWechatConfig(wechatConfig); - current.messagingChannelConfig = messagingChannelConfig; return current; }); // Pull the base image and resolve its digest so the Dockerfile is pinned to @@ -3391,16 +3390,7 @@ async function createSandbox( imageTag: resolvedImageTag, providerCredentialHashes, appliedPolicies: initialSandboxPolicy.appliedPresets, - // Persist the operator's configured channel set, not the post-disabled-filter - // active set. After `channels stop X` + rebuild, activeMessagingChannels drops - // X, but X is still configured — losing it here means a later `channels start - // X` has nothing to re-enable (the next rebuild sees an empty channel set and - // never reattaches the gateway bridge). See #3381. - configuredMessagingChannels, - activeMessagingChannels, - messagingChannelConfig, - plannedMessagingState: MessagingHostStateApplier.readPlanStateFromEnv(), - disabledChannels, + plannedMessagingState, hermesToolGateways, hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, @@ -4773,7 +4763,7 @@ function getRecordedMessagingChannelsForResume( ): string[] | null { return getRecordedMessagingChannelsForResumeFromState({ resume, - sessionMessagingChannels: session?.messagingChannels, + sessionMessagingChannels: getChannelsFromPlan(session?.messagingPlan), sandboxName, channels: MESSAGING_CHANNELS, getCredential, @@ -5533,7 +5523,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { getStoredMessagingChannelConfig, hydrateMessagingChannelConfig, messagingChannelConfigsEqual, - persistMessagingChannelConfigToSession, getSandboxReuseState, computeTelegramRequireMention, hasSandboxGpuDrift, @@ -5548,9 +5537,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { configureWebSearch, startRecordedStep, getRecordedMessagingChannelsForResume, - getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, setupMessagingChannels, - readMessagingChannelConfigFromEnv, readMessagingPlanFromEnv, writePlanToEnv, getRegistrySandboxMessagingPlan, diff --git a/src/lib/onboard/channel-state.test.ts b/src/lib/onboard/channel-state.test.ts index c00072f536..f32e6efcb3 100644 --- a/src/lib/onboard/channel-state.test.ts +++ b/src/lib/onboard/channel-state.test.ts @@ -4,14 +4,48 @@ import { describe, expect, it, vi } from "vitest"; import { resolveDisabledChannels } from "./channel-state"; +import { MessagingSetupApplier } from "../messaging"; +import type { Session } from "../state/onboard-session"; + +function sessionWithPlan( + sandboxName: string, + disabledChannels: readonly string[], +): Pick { + return { + sandboxName, + messagingPlan: { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: [], + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }; +} describe("onboard channel state helpers", () => { + it("prefers the staged env messaging plan for default callers", () => { + MessagingSetupApplier.writePlanToEnv(sessionWithPlan("alpha", ["slack"]).messagingPlan!); + try { + expect(resolveDisabledChannels("alpha")).toEqual(["slack"]); + } finally { + MessagingSetupApplier.clearPlanEnv(); + } + }); + it("prefers disabledChannels from the onboard session mirror", () => { const getRegistryDisabledChannels = vi.fn(() => ["discord"]); expect( resolveDisabledChannels("alpha", { - loadSession: () => ({ disabledChannels: ["telegram"] }), + loadSession: () => sessionWithPlan("alpha", ["telegram"]), getRegistryDisabledChannels, }), ).toEqual(["telegram"]); @@ -21,7 +55,7 @@ describe("onboard channel state helpers", () => { it("falls back to the registry when the session has no mirror", () => { expect( resolveDisabledChannels("alpha", { - loadSession: () => ({ disabledChannels: null }), + loadSession: () => sessionWithPlan("beta", ["telegram"]), getRegistryDisabledChannels: (sandboxName) => (sandboxName === "alpha" ? ["discord"] : []), }), ).toEqual(["discord"]); @@ -32,7 +66,7 @@ describe("onboard channel state helpers", () => { expect( resolveDisabledChannels("alpha", { - loadSession: () => ({ disabledChannels: [] }), + loadSession: () => sessionWithPlan("alpha", []), getRegistryDisabledChannels, }), ).toEqual([]); diff --git a/src/lib/onboard/channel-state.ts b/src/lib/onboard/channel-state.ts index 641ffaff38..9d97cceb83 100644 --- a/src/lib/onboard/channel-state.ts +++ b/src/lib/onboard/channel-state.ts @@ -3,11 +3,14 @@ import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; +import { readMessagingPlanFromEnv } from "./messaging-channel-setup"; +import { getDisabledChannelsFromPlan } from "./messaging-plan-session"; -type DisabledChannelsSession = Pick; +type DisabledChannelsSession = Pick; export type DisabledChannelsDeps = { loadSession: () => DisabledChannelsSession | null; + readMessagingPlanFromEnv?: () => onboardSession.Session["messagingPlan"]; getRegistryDisabledChannels: (sandboxName: string) => string[]; }; @@ -15,12 +18,13 @@ export function resolveDisabledChannels( sandboxName: string, deps?: DisabledChannelsDeps, ): string[] { - // `rebuild` destroys the registry entry before `onboard --resume` reaches - // createSandbox, so the session mirror is authoritative when present. - const sessionDisabledChannels = (deps?.loadSession ?? onboardSession.loadSession)() - ?.disabledChannels; - if (Array.isArray(sessionDisabledChannels)) { - return sessionDisabledChannels; + const envPlan = (deps?.readMessagingPlanFromEnv ?? readMessagingPlanFromEnv)(); + if (envPlan?.sandboxName === sandboxName) { + return getDisabledChannelsFromPlan(envPlan) ?? []; + } + const session = (deps?.loadSession ?? onboardSession.loadSession)(); + if (session?.sandboxName === sandboxName && session.messagingPlan) { + return getDisabledChannelsFromPlan(session.messagingPlan) ?? []; } return (deps?.getRegistryDisabledChannels ?? registry.getDisabledChannels)(sandboxName); } diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index cfba15c0fb..712c92ad84 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -143,7 +143,6 @@ function createPhases( getStoredMessagingChannelConfig: () => null, hydrateMessagingChannelConfig: (config) => config, messagingChannelConfigsEqual: () => true, - persistMessagingChannelConfigToSession: vi.fn(), getSandboxReuseState: () => "missing", computeTelegramRequireMention: () => null, hasSandboxGpuDrift: () => false, @@ -159,9 +158,7 @@ function createPhases( configureWebSearch: vi.fn(async () => null), startRecordedStep: vi.fn(async () => undefined), getRecordedMessagingChannelsForResume: () => null, - getSandboxMessagingChannels: () => null, setupMessagingChannels: vi.fn(async () => ["slack", "discord"]), - readMessagingChannelConfigFromEnv: () => null, readMessagingPlanFromEnv: () => null, writePlanToEnv: vi.fn(), getRegistrySandboxMessagingPlan: () => null, diff --git a/src/lib/onboard/machine/events.ts b/src/lib/onboard/machine/events.ts index ea773dd505..53d0f5153e 100644 --- a/src/lib/onboard/machine/events.ts +++ b/src/lib/onboard/machine/events.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { JsonObject, JsonValue } from "../../core/json-types"; +import { getActiveChannelsFromPlan } from "../messaging-plan-session"; import { redactSensitiveText, redactUrl } from "../../security/redact"; import type { HermesAuthMethod, Session } from "../../state/onboard-session"; import { @@ -128,7 +129,7 @@ export function buildOnboardMachineContext(session: Session): OnboardMachineCont hermesAuthMethod: hermesAuthMethod(session.hermesAuthMethod), hermesToolGateways: stringArray(session.hermesToolGateways), policyPresets: stringArray(session.policyPresets), - messagingChannels: stringArray(session.messagingChannels), + messagingChannels: getActiveChannelsFromPlan(session.messagingPlan) ?? [], gpuPassthrough: booleanValue(session.gpuPassthrough), }; } diff --git a/src/lib/onboard/machine/final-flow-phases.test.ts b/src/lib/onboard/machine/final-flow-phases.test.ts index 6542c5a041..5d824c1b6f 100644 --- a/src/lib/onboard/machine/final-flow-phases.test.ts +++ b/src/lib/onboard/machine/final-flow-phases.test.ts @@ -29,7 +29,7 @@ describe("final onboard flow phases", () => { const result = await policiesPhase.run(context({ selectedMessagingChannels: ["slack"] })); - expect(mergePolicyMessagingChannels).toHaveBeenCalledWith(["slack"], [], undefined, undefined); + expect(mergePolicyMessagingChannels).toHaveBeenCalledWith(["slack"], [], null, null); expect(result.context.selectedMessagingChannels).toEqual(["slack", "discord"]); }); diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index e983832283..a5882446a1 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -8,12 +8,48 @@ import { handlePoliciesState, type PoliciesStateOptions } from "./policies"; type Agent = { name: string } | null; type WebSearchConfig = { fetchEnabled: true }; +type MessagingPlan = NonNullable; +type MessagingChannelId = MessagingPlan["channels"][number]["channelId"]; + +function makeMessagingPlan( + sandboxName: string, + channels: readonly MessagingChannelId[], + disabledChannels: readonly MessagingChannelId[] = [], +): MessagingPlan { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: channels.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} function createDeps(overrides: Partial["deps"]> = {}) { let session = createSession(); const calls = { load: vi.fn(() => session), - activeSandbox: vi.fn(() => ({ messagingChannels: ["telegram"], disabledChannels: null })), + activeSandbox: vi.fn(() => ({ + messaging: { plan: makeMessagingPlan("my-assistant", ["telegram"]) }, + })), mergeChannels: vi.fn( (selected: string[], recorded: string[], active: string[] | null | undefined) => selected.length > 0 ? selected : (active ?? recorded), @@ -136,9 +172,9 @@ describe("handlePoliciesState", () => { }); it("uses recorded messaging channels when no active selection exists", async () => { - const session = createSession({ messagingChannels: ["slack"] }); + const session = createSession({ messagingPlan: makeMessagingPlan("my-assistant", ["slack"]) }); const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ messagingChannels: null, disabledChannels: null })), + getActiveSandbox: vi.fn(() => ({ messaging: null })), }); setSession(session); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index e1f16d57a0..ea6538140b 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -1,7 +1,12 @@ // 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 { + getActiveChannelsFromPlan, + getDisabledChannelsFromPlan, +} from "../../messaging-plan-session"; import { advanceTo, type OnboardStateTransitionResult } from "../result"; // Inlined to avoid pulling sandbox-agent's transitive runner.ts deps into @@ -18,8 +23,7 @@ export interface PolicyPresetEntry { } export interface ActiveSandboxPolicyState { - messagingChannels?: string[] | null; - disabledChannels?: string[] | null; + messaging?: { plan: SandboxMessagingPlan } | null; } export interface PolicyResumeSelection { @@ -133,15 +137,16 @@ export async function handlePoliciesState({ const recordedPolicyPresets = Array.isArray(latestSession?.policyPresets) ? latestSession.policyPresets : null; - const recordedMessagingChannels = Array.isArray(latestSession?.messagingChannels) - ? latestSession.messagingChannels - : []; + const recordedMessagingChannels = getActiveChannelsFromPlan(latestSession?.messagingPlan) ?? []; const activeSandbox = deps.getActiveSandbox(sandboxName); + const activePlan = activeSandbox?.messaging?.plan; + const activeMessagingChannels = getActiveChannelsFromPlan(activePlan); + const disabledChannels = getDisabledChannelsFromPlan(activePlan); const policyMessagingChannels = deps.mergePolicyMessagingChannels( selectedMessagingChannels, recordedMessagingChannels, - activeSandbox?.messagingChannels, - activeSandbox?.disabledChannels, + activeMessagingChannels, + disabledChannels, ); deps.verifyCompatibleEndpointSandboxSmoke({ sandboxName, @@ -155,7 +160,7 @@ export async function handlePoliciesState({ const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { recordedPolicyPresets, - disabledChannels: activeSandbox?.disabledChannels, + disabledChannels, enabledChannels: policyMessagingChannels, hermesToolGateways, agent: normalizeAgentName((agent as { name?: string } | null)?.name), @@ -210,7 +215,7 @@ export async function handlePoliciesState({ ? recordedPolicyPresetsForSupport : null, enabledChannels: policyMessagingChannels, - disabledChannels: activeSandbox?.disabledChannels, + disabledChannels, webSearchConfig, provider, // selectOnboardAgent returns null for the default OpenClaw path (no diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 892709b9af..b7d936df72 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -4,17 +4,34 @@ import { describe, expect, it, vi } from "vitest"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { hashCredential } from "../../../security/credential-hash"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import { handleSandboxState, type SandboxStateOptions } from "./sandbox"; -function makeMinimalPlan(sandboxName: string, agent = "openclaw"): SandboxMessagingPlan { +function makeMinimalPlan( + sandboxName: string, + agent = "openclaw", + channelIds: readonly SandboxMessagingPlan["channels"][number]["channelId"][] = [], + disabledChannels: readonly SandboxMessagingPlan["channels"][number]["channelId"][] = [], +): SandboxMessagingPlan { + const disabled = new Set(disabledChannels); return { schemaVersion: 1, sandboxName, agent: agent as SandboxMessagingPlan["agent"], workflow: "onboard", - channels: [], - disabledChannels: [], + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels: [...disabled], credentialBindings: [], networkPolicy: { presets: [], entries: [] }, agentRender: [], @@ -24,6 +41,41 @@ function makeMinimalPlan(sandboxName: string, agent = "openclaw"): SandboxMessag }; } +function withTelegramCredentialHash( + plan: SandboxMessagingPlan, + credentialHash: string | null, +): SandboxMessagingPlan { + return { + ...plan, + credentialBindings: [ + { + channelId: "telegram", + credentialId: "bot-token", + sourceInput: "botToken", + providerName: `${plan.sandboxName}-telegram-bridge`, + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + ...(credentialHash ? { credentialHash } : {}), + }, + ], + }; +} + +async function withEnv(key: string, value: string, run: () => Promise): Promise { + const previous = process.env[key]; + process.env[key] = value; + try { + return await run(); + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } +} + type Gpu = { type: string } | null; type Agent = { displayName?: string } | null; type WebSearchConfig = { fetchEnabled: true }; @@ -83,7 +135,6 @@ function createDeps( getStoredMessagingChannelConfig: () => null, hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, messagingChannelConfigsEqual: () => true, - persistMessagingChannelConfigToSession: calls.persistMessaging, getSandboxReuseState: () => "missing", computeTelegramRequireMention: () => null, hasSandboxGpuDrift: () => false, @@ -100,9 +151,7 @@ function createDeps( configureWebSearch: calls.configureWebSearch, startRecordedStep: calls.startStep, getRecordedMessagingChannelsForResume: calls.getRecordedChannels, - getSandboxMessagingChannels: () => ["telegram"], setupMessagingChannels: calls.setupMessaging, - readMessagingChannelConfigFromEnv: () => null, readMessagingPlanFromEnv: () => null, writePlanToEnv: () => undefined, getRegistrySandboxMessagingPlan: () => null, @@ -171,7 +220,6 @@ describe("handleSandboxState", () => { it("creates a sandbox and records messaging/web search state", async () => { const { deps, calls } = createDeps({ configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), - readMessagingChannelConfigFromEnv: () => ({ telegram: "polling" }), }); calls.setupMessaging.mockResolvedValue(["telegram"]); @@ -181,7 +229,7 @@ describe("handleSandboxState", () => { provider: "provider", model: "model", }); - expect(calls.setupMessaging).toHaveBeenCalledWith(null, ["telegram"], "my-assistant"); + expect(calls.setupMessaging).toHaveBeenCalledWith(null, null, "my-assistant"); expect(calls.promptName).toHaveBeenCalledWith(null); expect(calls.createSandbox).toHaveBeenCalledWith( { type: "nvidia" }, @@ -222,7 +270,10 @@ describe("handleSandboxState", () => { }); it("reuses a completed ready sandbox on resume", async () => { - const session = createSession({ sandboxName: "saved", messagingChannels: ["slack"] }); + const session = createSession({ + sandboxName: "saved", + messagingPlan: makeMinimalPlan("saved", "openclaw", ["slack"]), + }); session.steps.sandbox.status = "complete"; const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready" }); @@ -409,7 +460,7 @@ describe("handleSandboxState", () => { 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 session = createSession({ sandboxName: "my-assistant", messagingPlan: registryPlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps } = createDeps({ @@ -430,8 +481,8 @@ describe("handleSandboxState", () => { 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"] }); + const rebuiltPlan = makeMinimalPlan("my-assistant", "openclaw", ["telegram"], ["telegram"]); + const session = createSession({ sandboxName: "my-assistant", messagingPlan: registryPlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps, getSession } = createDeps({ @@ -449,10 +500,84 @@ describe("handleSandboxState", () => { expect(writePlanToEnv).not.toHaveBeenCalled(); expect(getSession().messagingPlan).toEqual(rebuiltPlan); + expect(getSession().messagingPlan?.disabledChannels).toEqual(["telegram"]); + expect(getSession().messagingPlan?.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + disabled: true, + }); + }); + + it("refreshes credential hashes when reusing an env-staged rebuild plan", async () => { + const oldHash = hashCredential("telegram-token-a"); + const newHash = hashCredential("telegram-token-b"); + const rebuiltPlan = withTelegramCredentialHash( + makeMinimalPlan("my-assistant", "openclaw", ["telegram"]), + oldHash, + ); + const session = createSession({ sandboxName: "my-assistant", messagingPlan: rebuiltPlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps, calls, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => rebuiltPlan, + getRegistrySandboxMessagingPlan: () => null, + }); + + await withEnv("TELEGRAM_BOT_TOKEN", "telegram-token-b", async () => { + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + }); + + expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(writePlanToEnv).toHaveBeenCalledWith( + expect.objectContaining({ + credentialBindings: [ + expect.objectContaining({ + providerEnvKey: "TELEGRAM_BOT_TOKEN", + credentialHash: newHash, + }), + ], + }), + ); + expect(getSession().messagingPlan?.credentialBindings[0]?.credentialHash).toBe(newHash); + }); + + it("preserves an empty env-staged rebuild plan instead of rediscovering token-backed channels", async () => { + const emptyRebuildPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ sandboxName: "my-assistant", messagingPlan: emptyRebuildPlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => null); + const writePlanToEnv = vi.fn(); + const { deps, calls, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => emptyRebuildPlan, + getRegistrySandboxMessagingPlan: () => emptyRebuildPlan, + }); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(result.selectedMessagingChannels).toEqual([]); + const createSandboxCall = calls.createSandbox.mock.calls[0] as unknown[]; + expect(createSandboxCall[6]).toEqual([]); + expect(getSession().messagingPlan).toEqual(emptyRebuildPlan); }); it("does not restore plan to env when registry has no entry", async () => { - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); + const session = createSession({ + sandboxName: "my-assistant", + messagingPlan: makeMinimalPlan("my-assistant"), + }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps } = createDeps({ diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index e5b00ca1c7..728afe0124 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { hashCredential } from "../../../security/credential-hash"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; +import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; import { withSandboxPhaseTrace } from "../../tracing"; import { branchTo, type OnboardStateTransitionResult } from "../result"; @@ -52,7 +54,6 @@ export interface SandboxStateOptions< left: MessagingChannelConfig | null, right: MessagingChannelConfig | null, ): boolean; - persistMessagingChannelConfigToSession(config: MessagingChannelConfig | null): void; getSandboxReuseState(sandboxName: string | null): string; computeTelegramRequireMention(): boolean | null; hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean; @@ -78,13 +79,11 @@ export interface SandboxStateOptions< session: Session | null, sandboxName: string | null, ): string[] | null; - getSandboxMessagingChannels(sandboxName: string): string[] | null | undefined; setupMessagingChannels( agent: Agent, existingChannels: string[] | null, sandboxName: string, ): Promise; - readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; readMessagingPlanFromEnv(): SandboxMessagingPlan | null; writePlanToEnv(plan: SandboxMessagingPlan): void; getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null; @@ -145,6 +144,22 @@ function sameEffectiveTelegramRequireMention(left: boolean | null, right: boolea return (left ?? false) === (right ?? false); } +function refreshCredentialHashesFromEnv(plan: SandboxMessagingPlan): { + plan: SandboxMessagingPlan; + changed: boolean; +} { + let changed = false; + const credentialBindings = plan.credentialBindings.map((binding) => { + if (binding.credentialAvailable !== true) return binding; + const credentialHash = hashCredential(process.env[binding.providerEnvKey]); + if (!credentialHash || credentialHash === binding.credentialHash) return binding; + changed = true; + return { ...binding, credentialHash }; + }); + + return changed ? { plan: { ...plan, credentialBindings }, changed } : { plan, changed }; +} + export async function handleSandboxState< Gpu, Agent, @@ -203,12 +218,6 @@ export async function handleSandboxState< effectiveMessagingChannelConfig, storedMessagingChannelConfig, ); - if (effectiveMessagingChannelConfig) { - deps.persistMessagingChannelConfigToSession(effectiveMessagingChannelConfig); - if (session) - session.messagingChannelConfig = - effectiveMessagingChannelConfig as Session["messagingChannelConfig"]; - } const sandboxReuseState = deps.getSandboxReuseState(sandboxName); const webSearchConfigChanged = @@ -248,7 +257,7 @@ export async function handleSandboxState< if (resumeSandbox) { if (webSearchConfig) deps.note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); - selectedMessagingChannels = session?.messagingChannels ?? []; + selectedMessagingChannels = getActiveChannelsFromPlan(session?.messagingPlan) ?? []; deps.skippedStepMessage("sandbox", sandboxName); await deps.recordStateSkipped("sandbox", { reason: "resume", sandboxName }); } else { @@ -324,44 +333,44 @@ export async function handleSandboxState< sandboxName, ); let messagingPlan: SandboxMessagingPlan | null = null; + const envMessagingPlan = deps.readMessagingPlanFromEnv(); + const registryMessagingPlan = sandboxName + ? deps.getRegistrySandboxMessagingPlan(sandboxName) + : null; if (recordedMessagingChannels) { selectedMessagingChannels = recordedMessagingChannels; + if (envMessagingPlan) { + const refreshed = refreshCredentialHashesFromEnv(envMessagingPlan); + messagingPlan = refreshed.plan; + if (refreshed.changed) deps.writePlanToEnv(refreshed.plan); + selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + } else if (registryMessagingPlan) { + const refreshed = refreshCredentialHashesFromEnv(registryMessagingPlan); + deps.writePlanToEnv(refreshed.plan); + messagingPlan = refreshed.plan; + selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + } if (selectedMessagingChannels.length > 0) { deps.note( ` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`, ); - // 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 { - // Registry is always current — updated by stop/start/add/remove. - // Works for plain process-restart resumes and cancel-then-resume - // when sandbox step had previously completed. - const registryPlan = deps.getRegistrySandboxMessagingPlan(sandboxName); - if (registryPlan) { - deps.writePlanToEnv(registryPlan); - messagingPlan = registryPlan; - } - } } + } else if (envMessagingPlan) { + const refreshed = refreshCredentialHashesFromEnv(envMessagingPlan); + messagingPlan = refreshed.plan; + if (refreshed.changed) deps.writePlanToEnv(refreshed.plan); + selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + } else if (registryMessagingPlan) { + const refreshed = refreshCredentialHashesFromEnv(registryMessagingPlan); + deps.writePlanToEnv(refreshed.plan); + messagingPlan = refreshed.plan; + selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; } else { - const existing = sandboxName - ? (deps.getSandboxMessagingChannels(sandboxName) ?? session?.messagingChannels ?? null) - : (session?.messagingChannels ?? null); + const existing = getChannelsFromPlan(session?.messagingPlan); 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; }); @@ -418,7 +427,7 @@ export async function handleSandboxState< model, nimContainer, webSearchConfig, - messagingChannelConfig, + messagingPlan, hermesToolGateways, }), ); diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts index e23a7ec6d1..b4d01d43a6 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -4,10 +4,9 @@ import { type MessagingChannelConfig, mergeMessagingChannelConfigs, - sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; +import { getMessagingChannelConfigFromPlan } from "../messaging/plan-validation"; import type { Session } from "../state/onboard-session"; -import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; // Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt @@ -26,25 +25,18 @@ export function getStoredMessagingChannelConfig( session: Session | null, ): MessagingChannelConfig | null { const registryConfig = sandboxName - ? sanitizeMessagingChannelConfig(registry.getSandbox(sandboxName)?.messagingChannelConfig) + ? getMessagingChannelConfigFromPlan( + registry.getMessagingPlanFromEntry(registry.getSandbox(sandboxName)), + ) : null; const sessionMatchesSandbox = !session?.sandboxName || !sandboxName || session.sandboxName === sandboxName; const sessionConfig = sessionMatchesSandbox - ? sanitizeMessagingChannelConfig(session?.messagingChannelConfig) + ? getMessagingChannelConfigFromPlan(session?.messagingPlan) : null; return mergeMessagingChannelConfigs(registryConfig, sessionConfig); } -export function persistMessagingChannelConfigToSession( - config: MessagingChannelConfig | null, -): void { - onboardSession.updateSession((current: Session) => { - current.messagingChannelConfig = config; - return current; - }); -} - export function messagingChannelConfigsEqual( left: MessagingChannelConfig | null, right: MessagingChannelConfig | null, diff --git a/src/lib/onboard/messaging-conflict-guard.ts b/src/lib/onboard/messaging-conflict-guard.ts index 6e2d319220..65ae846caf 100644 --- a/src/lib/onboard/messaging-conflict-guard.ts +++ b/src/lib/onboard/messaging-conflict-guard.ts @@ -28,8 +28,6 @@ import type { ConflictRegistry, ConflictRegistryEntry } from "../messaging/applier"; import { - backfillMessagingChannels, - createMessagingConflictProbe, findChannelConflictsFromPlan, findSlackSocketModeGatewayConflicts, formatSlackSocketModeConflictMessage, @@ -55,10 +53,6 @@ export interface MessagingConflictGuardDeps { readonly registry: ConflictRegistry & { listSandboxes: () => { sandboxes: ConflictRegistryEntry[]; defaultSandbox?: string | null }; }; - /** `openshell sandbox list` succeeded — gateway answered (for backfill probe). */ - readonly checkGatewayLiveness: () => boolean; - /** Whether the named OpenShell provider exists (gateway assumed alive). */ - readonly providerExists: (name: string) => boolean; readonly isNonInteractive: () => boolean; /** Interactive "Continue anyway?" prompt; resolves true to proceed. */ readonly promptContinue: () => Promise; @@ -103,16 +97,10 @@ export async function enforceMessagingChannelConflicts( : null; // Axis 1: credential-scoped conflict (#1953). Only runs when the plan carries - // an available credential hash to compare; backfill first so legacy entries - // expose their active channels. + // an available credential hash to compare. const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; if (currentPlan && hasPlanCredentials) { - const probe = createMessagingConflictProbe({ - checkGatewayLiveness: deps.checkGatewayLiveness, - providerExists: deps.providerExists, - }); - backfillMessagingChannels(registry, probe); const conflicts = findChannelConflictsFromPlan(sandboxName, currentPlan, registry); if (conflicts.length > 0) { for (const { channel, sandbox, reason } of conflicts) { diff --git a/src/lib/onboard/messaging-credentials.ts b/src/lib/onboard/messaging-credentials.ts index 5bf8dba828..9f336ad6b8 100644 --- a/src/lib/onboard/messaging-credentials.ts +++ b/src/lib/onboard/messaging-credentials.ts @@ -37,7 +37,7 @@ export function getRecordedMessagingChannelsForResume({ channels, (envKey: string) => Boolean(normalizeCredentialValue(process.env[envKey]) || getCredential(envKey)), - registry.getSandbox.bind(registry), + registry.getConfiguredMessagingChannels.bind(registry), registry.getDisabledChannels.bind(registry), providerExistsInGateway, isNonInteractive(), diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts index 5e530ee337..41fff02e05 100644 --- a/src/lib/onboard/messaging-plan-session.ts +++ b/src/lib/onboard/messaging-plan-session.ts @@ -1,66 +1,37 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { MessagingChannelConfig } from "../messaging-channel-config"; import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + getActiveChannelIdsFromPlan, + getConfiguredChannelIdsFromPlan, + getDisabledChannelIdsFromPlan, + getMessagingChannelConfigFromPlan, + parseSandboxMessagingPlan, +} from "../messaging/plan-validation"; -export 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 isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export { getMessagingChannelConfigFromPlan, parseSandboxMessagingPlan }; -/** Derive the equivalent of session.messagingChannels from a plan. */ +/** Derive configured channel IDs 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); + const channels = getConfiguredChannelIdsFromPlan(plan); + return channels.length > 0 ? channels : null; } -/** Derive the equivalent of session.disabledChannels from a plan. */ -export function getDisabledChannelsFromPlan( +/** Derive active channels from a plan, excluding stopped/disabled channels. */ +export function getActiveChannelsFromPlan( plan: SandboxMessagingPlan | null | undefined, ): string[] | null { - if (!plan) return null; - return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; + const channels = getActiveChannelIdsFromPlan(plan); + return channels.length > 0 ? channels : 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( +/** Derive disabled channel IDs from a plan. */ +export function getDisabledChannelsFromPlan( 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; +): string[] | null { + const channels = getDisabledChannelIdsFromPlan(plan); + return channels.length > 0 ? channels : null; } diff --git a/src/lib/onboard/messaging-reuse.test.ts b/src/lib/onboard/messaging-reuse.test.ts index a6863193ec..a74a1a794f 100644 --- a/src/lib/onboard/messaging-reuse.test.ts +++ b/src/lib/onboard/messaging-reuse.test.ts @@ -39,7 +39,7 @@ describe("onboard messaging reuse", () => { "assistant", messagingChannels, () => false, - () => ({ messagingChannels: ["slack"] }), + () => ["slack"], () => [], (provider) => provider === "assistant-slack-bridge", true, @@ -55,7 +55,7 @@ describe("onboard messaging reuse", () => { "assistant", messagingChannels, () => false, - () => ({ messagingChannels: ["slack"] }), + () => ["slack"], () => [], (provider) => provider === "assistant-slack-bridge" || provider === "assistant-slack-app", true, @@ -71,7 +71,7 @@ describe("onboard messaging reuse", () => { "assistant", messagingChannels, () => false, - () => ({ messagingChannels: ["wechat"] }), + () => ["wechat"], () => [], (provider) => provider === "assistant-wechat-bridge", true, @@ -87,7 +87,7 @@ describe("onboard messaging reuse", () => { "assistant", messagingChannels, () => false, - () => ({ messagingChannels: ["discord"] }), + () => ["discord"], () => [], () => true, true, @@ -103,7 +103,7 @@ describe("onboard messaging reuse", () => { "assistant", messagingChannels, () => true, - () => ({ messagingChannels: ["discord"] }), + () => ["discord"], () => [], () => true, true, diff --git a/src/lib/onboard/messaging-reuse.ts b/src/lib/onboard/messaging-reuse.ts index ae6224a34b..5ebd2735dc 100644 --- a/src/lib/onboard/messaging-reuse.ts +++ b/src/lib/onboard/messaging-reuse.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 type MessagingChannel = { name: string; envKey: string }; -type SandboxEntry = { messagingChannels?: string[] | null } | null | undefined; export function getMessagingProviderNamesForChannel( sandboxName: string, @@ -30,7 +29,7 @@ export function getNonInteractiveStoredMessagingChannels( sandboxName: string | null, messagingChannels: readonly MessagingChannel[], hasMessagingToken: (envKey: string) => boolean, - getSandbox: (sandboxName: string) => SandboxEntry, + getConfiguredChannels: (sandboxName: string) => string[], getDisabledChannels: (sandboxName: string) => string[], providerExists: (providerName: string) => boolean, nonInteractive: boolean, @@ -49,7 +48,7 @@ export function getNonInteractiveStoredMessagingChannels( } const configuredChannels = getKnownMessagingChannels( - getSandbox(sandboxName)?.messagingChannels, + getConfiguredChannels(sandboxName), messagingChannels, ); const disabledChannels = new Set(getDisabledChannels(sandboxName)); diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index 632599dd7b..a901882bc2 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -24,7 +24,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { schemaVersion: 1 as const, plan: { sandboxName: "demo" }, }; - const messagingChannelConfig = { DISCORD_ALLOWED_USER_IDS: "123" }; const entry = buildCreatedSandboxRegistryEntry({ sandboxName: "demo", @@ -36,11 +35,7 @@ describe("buildCreatedSandboxRegistryEntry", () => { imageTag: "nemoclaw-demo:123", providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-slack-bot" }, appliedPolicies: ["discord", "slack"], - configuredMessagingChannels: ["slack", "discord", "slack"], - activeMessagingChannels: ["discord"], - messagingChannelConfig, plannedMessagingState: plannedMessagingState as any, - disabledChannels: ["telegram"], hermesToolGateways: ["filesystem"], hermesDashboardState: { enabled: true, @@ -58,9 +53,6 @@ describe("buildCreatedSandboxRegistryEntry", () => { imageTag: "nemoclaw-demo:123", providerCredentialHashes: { SLACK_BOT_TOKEN: "hash-slack-bot" }, policies: ["discord", "slack"], - messagingChannels: ["slack", "discord"], - messagingChannelConfig, - disabledChannels: ["telegram"], hermesToolGateways: ["filesystem"], hermesDashboardEnabled: true, hermesDashboardPort: 18790, @@ -75,9 +67,13 @@ describe("buildCreatedSandboxRegistryEntry", () => { }); expect(entry.agent).toBeNull(); expect(entry.messaging).toBe(plannedMessagingState); + const rawEntry = entry as unknown as Record; + expect(rawEntry.messagingChannels).toBeUndefined(); + expect(rawEntry.messagingChannelConfig).toBeUndefined(); + expect(rawEntry.disabledChannels).toBeUndefined(); }); - it("uses active channels and skips stale messaging plans when no configured channel set exists", () => { + it("skips stale messaging plans without writing legacy messaging fields", () => { const entry = buildCreatedSandboxRegistryEntry({ sandboxName: "demo", model: "", @@ -88,14 +84,10 @@ describe("buildCreatedSandboxRegistryEntry", () => { imageTag: null, providerCredentialHashes: {}, appliedPolicies: [], - configuredMessagingChannels: null, - activeMessagingChannels: ["telegram"], - messagingChannelConfig: null, plannedMessagingState: { schemaVersion: 1 as const, plan: { sandboxName: "other" }, } as any, - disabledChannels: [], hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, dashboardPort: 18789, @@ -105,10 +97,11 @@ describe("buildCreatedSandboxRegistryEntry", () => { expect(entry.model).toBeNull(); expect(entry.provider).toBeNull(); - expect(entry.messagingChannels).toEqual(["telegram"]); - expect(entry.messagingChannelConfig).toBeUndefined(); + const rawEntry = entry as unknown as Record; + expect(rawEntry.messagingChannels).toBeUndefined(); + expect(rawEntry.messagingChannelConfig).toBeUndefined(); expect(entry.messaging).toBeUndefined(); - expect(entry.disabledChannels).toBeUndefined(); + expect(rawEntry.disabledChannels).toBeUndefined(); expect(entry.hermesToolGateways).toBeUndefined(); expect(entry.hermesDashboardEnabled).toBeUndefined(); expect(entry.hermesDashboardPort).toBeUndefined(); @@ -131,11 +124,7 @@ describe("registerCreatedSandbox", () => { imageTag: null, providerCredentialHashes: {}, appliedPolicies: [], - configuredMessagingChannels: null, - activeMessagingChannels: [], - messagingChannelConfig: undefined, plannedMessagingState: undefined, - disabledChannels: [], hermesToolGateways: [], hermesDashboardState: { enabled: false, config: null }, dashboardPort: 18789, diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index ba9842d08c..860d01a21c 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -4,7 +4,6 @@ import type { AgentDefinition } from "../agent/defs"; import type { SandboxEntry, SandboxMessagingState } from "../state/registry"; import * as registry from "../state/registry"; -import type { MessagingChannelConfig } from "../messaging-channel-config"; import { getHermesDashboardRegistryFields, type HermesDashboardOnboardState, @@ -33,11 +32,7 @@ export interface CreatedSandboxRegistryEntryInput { imageTag: string | null; providerCredentialHashes: Record; appliedPolicies: string[]; - configuredMessagingChannels: string[] | null; - activeMessagingChannels: string[]; - messagingChannelConfig: MessagingChannelConfig | null | undefined; plannedMessagingState: SandboxMessagingState | undefined; - disabledChannels: string[]; hermesToolGateways: string[]; hermesDashboardState: HermesDashboardOnboardState; dashboardPort: number; @@ -69,13 +64,7 @@ export function buildCreatedSandboxRegistryEntry( ? input.providerCredentialHashes : undefined, policies: input.appliedPolicies, - messagingChannels: - input.configuredMessagingChannels != null - ? [...new Set(input.configuredMessagingChannels)] - : input.activeMessagingChannels, - messagingChannelConfig: input.messagingChannelConfig || undefined, messaging: messagingState, - disabledChannels: input.disabledChannels.length > 0 ? [...input.disabledChannels] : undefined, hermesToolGateways: input.hermesToolGateways.length > 0 ? [...input.hermesToolGateways] : undefined, ...getHermesDashboardRegistryFields(input.hermesDashboardState), diff --git a/src/lib/onboard/session-updates.ts b/src/lib/onboard/session-updates.ts index 558bc3b62d..cf520c28ca 100644 --- a/src/lib/onboard/session-updates.ts +++ b/src/lib/onboard/session-updates.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 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"; @@ -17,8 +16,6 @@ export interface OnboardSessionUpdateInput { nimContainer?: string | null; webSearchConfig?: WebSearchConfig | null; policyPresets?: string[] | null; - messagingChannels?: string[] | null; - messagingChannelConfig?: MessagingChannelConfig | null; messagingPlan?: SandboxMessagingPlan | null; hermesToolGateways?: string[] | null; } @@ -54,11 +51,6 @@ export function toSessionUpdates(updates: OnboardSessionUpdateInput = {}): Sessi normalized.nimContainer = toNullableString(updates.nimContainer); if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; if (updates.policyPresets !== undefined) normalized.policyPresets = updates.policyPresets; - if (updates.messagingChannels !== undefined) - normalized.messagingChannels = updates.messagingChannels; - if (updates.messagingChannelConfig !== undefined) { - normalized.messagingChannelConfig = updates.messagingChannelConfig; - } if (updates.messagingPlan !== undefined) normalized.messagingPlan = updates.messagingPlan; if (updates.hermesToolGateways !== undefined) normalized.hermesToolGateways = updates.hermesToolGateways; diff --git a/src/lib/sandbox/whatsapp-diagnostics.ts b/src/lib/sandbox/whatsapp-diagnostics.ts index ac7fee3b1e..a509cec3c4 100644 --- a/src/lib/sandbox/whatsapp-diagnostics.ts +++ b/src/lib/sandbox/whatsapp-diagnostics.ts @@ -87,8 +87,7 @@ export type WhatsappProbeInput = { // Whether the whatsapp preset's network policy is loaded on the gateway, // or null when the gateway could not be reached. presetOnGateway: boolean | null; - // Whether the whatsapp channel is recorded in the registry's - // messagingChannels list. + // Whether the whatsapp channel is recorded in the registry messaging plan. channelEnabledInRegistry: boolean; }; @@ -307,7 +306,7 @@ function configCoverageSignal(input: WhatsappProbeInput): DiagnosticSignal { return { label: "Channel registration", severity: "fail", - detail: "whatsapp is not in the sandbox messagingChannels list", + detail: "whatsapp is not in the sandbox messaging plan", hint: "run `nemoclaw channels add whatsapp` before pairing", }; } diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index aa23603b6f..6ea64d4679 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -16,6 +16,8 @@ type OnboardMachineEventsModule = typeof import("../../../dist/lib/onboard/machi type OnboardMachineEvent = import("../../../dist/lib/onboard/machine/events").OnboardMachineEvent; type LoadedSession = NonNullable>; type DebugSummary = NonNullable>; +type MessagingPlan = NonNullable; +type MessagingChannelId = MessagingPlan["channels"][number]["channelId"]; let session: OnboardSessionModule; let machineEvents: OnboardMachineEventsModule; let tmpDir: string; @@ -48,6 +50,38 @@ function normalizeLegacySession( ); } +function makeMessagingPlan( + sandboxName: string, + channels: readonly MessagingChannelId[] = [], + disabledChannels: readonly MessagingChannelId[] = [], +): MessagingPlan { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: channels.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels: [...disabledChannels], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + beforeEach(() => { // Recreate tmpDir per test so lock artifacts (and any other on-disk state) // from a previous test cannot leak into this one. Without this, malformed @@ -539,113 +573,64 @@ describe("onboard session", () => { expect(loaded.nimContainer).toBeNull(); }); - it("persists messagingChannels across save/load roundtrips", () => { + it("persists messagingPlan across save/load roundtrips", () => { const created = session.createSession(); - created.messagingChannels = ["telegram", "slack"]; + created.messagingPlan = makeMessagingPlan("my-assistant", ["telegram", "slack"], ["slack"]); session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannels).toEqual(["telegram", "slack"]); + expect(loaded.messagingPlan).toEqual(created.messagingPlan); }); - it("filters non-string entries out of persisted messagingChannels", () => { + it("drops malformed persisted messagingPlan on load", () => { const created = session.createSession(); fs.mkdirSync(path.dirname(session.SESSION_FILE), { recursive: true }); fs.writeFileSync( session.SESSION_FILE, JSON.stringify({ ...created, - messagingChannels: ["telegram", 42, null, "discord"], + messagingPlan: { + ...makeMessagingPlan("my-assistant", ["telegram"]), + disabledChannels: ["telegram", 42, null], + }, }), ); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannels).toEqual(["telegram", "discord"]); + expect(loaded.messagingPlan).toBeNull(); }); - it("persists disabledChannels across save/load roundtrips", () => { + it("persists disabled channel state inside messagingPlan", () => { // Regression: `channels stop X` followed by rebuild must carry the paused - // set through the destroy/recreate window. The Session mirror is the only + // set through the destroy/recreate window. The session plan is the only // place this can survive, because rebuild destroys the registry entry // before `onboard --resume` reads it back. const created = session.createSession(); - created.disabledChannels = ["telegram"]; + created.messagingPlan = makeMessagingPlan("my-assistant", ["telegram"], ["telegram"]); session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.disabledChannels).toEqual(["telegram"]); - }); - - it("filters non-string entries out of persisted disabledChannels", () => { - const created = session.createSession(); - fs.mkdirSync(path.dirname(session.SESSION_FILE), { recursive: true }); - fs.writeFileSync( - session.SESSION_FILE, - JSON.stringify({ - ...created, - disabledChannels: ["telegram", 42, null, "discord"], - }), - ); - - const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.disabledChannels).toEqual(["telegram", "discord"]); - }); - - it("defaults disabledChannels to null for fresh sessions", () => { - const fresh = session.createSession(); - expect(fresh.disabledChannels).toBeNull(); + expect(loaded.messagingPlan?.disabledChannels).toEqual(["telegram"]); + expect(loaded.messagingPlan?.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + disabled: true, + }); }); - it("filterSafeUpdates passes through disabledChannels and accepts explicit null clear", () => { + it("filterSafeUpdates passes through messagingPlan and accepts explicit null clear", () => { session.saveSession(session.createSession()); - session.markStepComplete("provider_selection", { disabledChannels: ["discord"] }); - expect(requireLoadedSession(session.loadSession()).disabledChannels).toEqual(["discord"]); + const plan = makeMessagingPlan("my-assistant", ["discord"]); + session.markStepComplete("provider_selection", { messagingPlan: plan }); + expect(requireLoadedSession(session.loadSession()).messagingPlan).toEqual(plan); - session.markStepComplete("provider_selection", { disabledChannels: null }); - expect(requireLoadedSession(session.loadSession()).disabledChannels).toBeNull(); + session.markStepComplete("provider_selection", { messagingPlan: null }); + expect(requireLoadedSession(session.loadSession()).messagingPlan).toBeNull(); }); - it("defaults messagingChannels to null for fresh sessions", () => { + it("defaults messagingPlan to null for fresh sessions", () => { const fresh = session.createSession(); - expect(fresh.messagingChannels).toBeNull(); - }); - - it("persists messagingChannelConfig across save/load roundtrips", () => { - const created = session.createSession(); - created.messagingChannelConfig = { - TELEGRAM_ALLOWED_IDS: "123,456", - TELEGRAM_REQUIRE_MENTION: "1", - }; - session.saveSession(created); - - const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123,456", - TELEGRAM_REQUIRE_MENTION: "1", - }); - }); - - it("filters malformed messagingChannelConfig entries on load", () => { - const created = session.createSession(); - fs.mkdirSync(path.dirname(session.SESSION_FILE), { recursive: true }); - fs.writeFileSync( - session.SESSION_FILE, - JSON.stringify({ - ...created, - messagingChannelConfig: { - TELEGRAM_ALLOWED_IDS: "123", - TELEGRAM_REQUIRE_MENTION: "true", - DISCORD_REQUIRE_MENTION: "0", - NVIDIA_API_KEY: "not-channel-config", - }, - }), - ); - - const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123", - DISCORD_REQUIRE_MENTION: "0", - }); + expect(fresh.messagingPlan).toBeNull(); }); it("#1737: persists telegramConfig across save/load roundtrips (requireMention=true)", () => { @@ -1032,49 +1017,44 @@ describe("onboard session", () => { expect(loaded.failure.message).toBe(loaded.steps.inference.error); }); - it("round-trips null messagingChannels through normalizeSession", () => { + it("round-trips null messagingPlan through normalizeSession", () => { const created = session.createSession(); - expect(created.messagingChannels).toBeNull(); + expect(created.messagingPlan).toBeNull(); const saved = session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(saved.messagingChannels).toBeNull(); - expect(loaded.messagingChannels).toBeNull(); + expect(saved.messagingPlan).toBeNull(); + expect(loaded.messagingPlan).toBeNull(); }); - it("round-trips messagingChannels=['telegram'] through normalizeSession", () => { - const created = session.createSession({ messagingChannels: ["telegram"] }); - expect(created.messagingChannels).toEqual(["telegram"]); + it("round-trips messagingPlan through normalizeSession", () => { + const plan = makeMessagingPlan("my-assistant", ["telegram"]); + const created = session.createSession({ messagingPlan: plan }); + expect(created.messagingPlan).toEqual(plan); const saved = session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(saved.messagingChannels).toEqual(["telegram"]); - expect(loaded.messagingChannels).toEqual(["telegram"]); + expect(saved.messagingPlan).toEqual(plan); + expect(loaded.messagingPlan).toEqual(plan); }); - it("filterSafeUpdates preserves messagingChannels field", () => { + it("filterSafeUpdates preserves messagingPlan field", () => { session.saveSession(session.createSession()); + const plan = makeMessagingPlan("my-assistant", ["slack", "discord"]); session.markStepComplete("provider_selection", { - messagingChannels: ["slack", "discord"], + messagingPlan: plan, }); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannels).toEqual(["slack", "discord"]); + expect(loaded.messagingPlan).toEqual(plan); }); - it("filterSafeUpdates preserves sanitized messagingChannelConfig", () => { + it("filterSafeUpdates ignores malformed messagingPlan values", () => { session.saveSession(session.createSession()); session.markStepComplete("provider_selection", { - messagingChannelConfig: { - TELEGRAM_ALLOWED_IDS: "123", - TELEGRAM_REQUIRE_MENTION: "1", - DISCORD_REQUIRE_MENTION: "invalid", - }, - }); + messagingPlan: { sandboxName: "my-assistant" }, + } as unknown as Parameters[1]); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123", - TELEGRAM_REQUIRE_MENTION: "1", - }); + expect(loaded.messagingPlan).toBeNull(); }); it("#1737: filterSafeUpdates routes telegramConfig through markStepComplete", () => { @@ -1133,20 +1113,19 @@ describe("onboard session", () => { expect(loaded.wechatConfig).toBeNull(); }); - it("createSession with messagingChannels override", () => { - const created = session.createSession({ messagingChannels: ["telegram", "slack"] }); - expect(created.messagingChannels).toEqual(["telegram", "slack"]); + it("createSession with messagingPlan override", () => { + const plan = makeMessagingPlan("my-assistant", ["telegram", "slack"]); + const created = session.createSession({ messagingPlan: plan }); + expect(created.messagingPlan).toEqual(plan); expect(created.provider).toBeNull(); }); it("filters non-string array entries in createSession overrides", () => { const created = session.createSession({ policyPresets: ["pypi", 7, null, "npm"] as unknown as string[], - messagingChannels: ["telegram", 42, null, "discord"] as unknown as string[], }); expect(created.policyPresets).toEqual(["pypi", "npm"]); - expect(created.messagingChannels).toEqual(["telegram", "discord"]); }); it("summarizes the session for debug output", () => { diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index c421876231..3d8737fce8 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -14,12 +14,8 @@ import path from "node:path"; import { isErrnoException } from "../core/errno"; import type { JsonObject, JsonValue } from "../core/json-types"; import type { WebSearchConfig } from "../inference/web-search"; -import { - type MessagingChannelConfig, - sanitizeMessagingChannelConfig, -} from "../messaging-channel-config"; import type { SandboxMessagingPlan } from "../messaging/manifest"; -import { parseSandboxMessagingPlan } from "../onboard/messaging-plan-session"; +import { parseSandboxMessagingPlan } from "../messaging/plan-validation"; import { createOnboardMachineEvent, emitOnboardMachineEvent, @@ -104,17 +100,7 @@ export interface Session { webSearchConfig: WebSearchConfig | null; hermesToolGateways: string[] | null; 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` — - // can carry the paused set across the destroy/recreate window. - // Without this mirror, the disabledChannels filter inside createSandbox - // reads back `[]` from the freshly-empty registry and the channel - // comes back live after rebuild. See #(channels-stop-rebuild bug). - disabledChannels: string[] | null; // SHA-256 hex digest of every legacy credential value successfully // written to the OpenShell gateway during this onboard session, keyed by // env-name. Persisted across process restarts so a `--resume` run that @@ -183,10 +169,7 @@ export interface SessionUpdates { webSearchConfig?: WebSearchConfig | null; hermesToolGateways?: string[] | null; policyPresets?: string[] | null; - messagingChannels?: string[] | null; - messagingChannelConfig?: MessagingChannelConfig | null; messagingPlan?: SandboxMessagingPlan | null; - disabledChannels?: string[] | null; migratedLegacyValueHashes?: Record; gpuPassthrough?: boolean; telegramConfig?: TelegramConfig | null; @@ -465,10 +448,7 @@ export function createSession(overrides: Partial = {}): Session { overrides.webSearchConfig?.fetchEnabled === true ? { fetchEnabled: true } : null, hermesToolGateways: readStringArray(overrides.hermesToolGateways), 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) : null, @@ -509,10 +489,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): webSearchConfig: parseWebSearchConfig(data.webSearchConfig), hermesToolGateways: readStringArray(data.hermesToolGateways), 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, telegramConfig: parseTelegramConfig(data.telegramConfig), @@ -953,28 +930,12 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { } else if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); } - if (updates.messagingChannels === null) { - safe.messagingChannels = null; - } else if (Array.isArray(updates.messagingChannels)) { - safe.messagingChannels = updates.messagingChannels.filter((value) => typeof value === "string"); - } - if (updates.messagingChannelConfig === null) { - safe.messagingChannelConfig = null; - } else { - 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)) { - safe.disabledChannels = updates.disabledChannels.filter((value) => typeof value === "string"); - } if (isObject(updates.migratedLegacyValueHashes)) { const cleaned: Record = {}; for (const [k, v] of Object.entries(updates.migratedLegacyValueHashes)) { diff --git a/src/lib/state/registry-messaging.ts b/src/lib/state/registry-messaging.ts new file mode 100644 index 0000000000..b03b89140f --- /dev/null +++ b/src/lib/state/registry-messaging.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + getActiveChannelIdsFromPlan, + getConfiguredChannelIdsFromPlan, + getDisabledChannelIdsFromPlan, + parseSandboxMessagingPlan, +} from "../messaging/plan-validation"; +import type { SandboxRegistry } from "./registry"; + +export interface SandboxMessagingState { + schemaVersion: 1; + plan: SandboxMessagingPlan; +} + +type EntryWithMessaging = { + messaging?: { schemaVersion?: number; plan?: unknown } | null; +}; + +export interface RegistryMessagingReadDeps { + load(): SandboxRegistry; +} + +export interface RegistryMessagingMutationDeps extends RegistryMessagingReadDeps { + save(data: SandboxRegistry): void; + withLock(fn: () => T): T; +} + +export function cloneSandboxMessagingState( + messaging: SandboxMessagingState | null | undefined, +): SandboxMessagingState | undefined { + if (!messaging || messaging.schemaVersion !== 1) return undefined; + const plan = parseSandboxMessagingPlan(messaging.plan); + return plan ? { schemaVersion: 1, plan } : undefined; +} + +export function getMessagingPlanFromEntry( + entry: EntryWithMessaging | null | undefined, +): SandboxMessagingPlan | null { + if (entry?.messaging?.schemaVersion !== 1) return null; + return parseSandboxMessagingPlan(entry.messaging.plan); +} + +export function getConfiguredMessagingChannelsFromEntry( + entry: EntryWithMessaging | null | undefined, +): string[] { + return getConfiguredChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); +} + +export function getActiveMessagingChannelsFromEntry( + entry: EntryWithMessaging | null | undefined, +): string[] { + return getActiveChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); +} + +export function getDisabledMessagingChannelsFromEntry( + entry: EntryWithMessaging | null | undefined, +): string[] { + return getDisabledChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); +} + +export function getConfiguredMessagingChannels( + name: string, + deps: RegistryMessagingReadDeps, +): string[] { + const data = deps.load(); + return getConfiguredMessagingChannelsFromEntry(data.sandboxes[name]); +} + +export function getDisabledChannels(name: string, deps: RegistryMessagingReadDeps): string[] { + const data = deps.load(); + return getDisabledMessagingChannelsFromEntry(data.sandboxes[name]); +} + +export function setChannelDisabled( + name: string, + channelId: string, + disabled: boolean, + deps: RegistryMessagingMutationDeps, +): boolean { + return deps.withLock(() => { + const data = deps.load(); + const entry = data.sandboxes[name]; + const plan = getMessagingPlanFromEntry(entry); + if (!entry || !plan) return false; + + const configured = new Set(plan.channels.map((channel) => channel.channelId)); + if (!configured.has(channelId)) return false; + + const disabledChannels = new Set(plan.disabledChannels); + if (disabled) disabledChannels.add(channelId); + else disabledChannels.delete(channelId); + const disabledList = [...disabledChannels].filter((id) => configured.has(id)).sort(); + const disabledSet = new Set(disabledList); + + entry.messaging = { + schemaVersion: 1, + plan: { + ...plan, + workflow: disabled ? "stop-channel" : "start-channel", + disabledChannels: disabledList, + channels: plan.channels.map((channel) => { + const channelDisabled = disabledSet.has(channel.channelId); + return { + ...channel, + disabled: channelDisabled, + active: channel.configured && !channelDisabled, + }; + }), + }, + }; + deps.save(data); + return true; + }); +} diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 12df32afb1..20c0edccbb 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -4,9 +4,21 @@ import fs from "node:fs"; import path from "node:path"; import { isErrnoException } from "../core/errno"; -import type { SandboxMessagingPlan } from "../messaging/manifest"; -import type { MessagingChannelConfig } from "../messaging-channel-config"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; +import { + cloneSandboxMessagingState, + getConfiguredMessagingChannels as getRegistryConfiguredMessagingChannels, + getDisabledChannels as getRegistryDisabledChannels, + setChannelDisabled as setRegistryChannelDisabled, +} from "./registry-messaging"; +import type { SandboxMessagingState } from "./registry-messaging"; +export { + getActiveMessagingChannelsFromEntry, + getConfiguredMessagingChannelsFromEntry, + getDisabledMessagingChannelsFromEntry, + getMessagingPlanFromEntry, + type SandboxMessagingState, +} from "./registry-messaging"; export interface CustomPolicyEntry { name: string; @@ -68,15 +80,12 @@ export interface SandboxEntry { nemoclawVersion?: string | null; imageTag?: string | null; providerCredentialHashes?: Record; - messagingChannels?: string[]; - messagingChannelConfig?: MessagingChannelConfig; messaging?: SandboxMessagingState; hermesToolGateways?: string[]; hermesDashboardEnabled?: boolean; hermesDashboardPort?: number | null; hermesDashboardInternalPort?: number | null; hermesDashboardTui?: boolean; - disabledChannels?: string[]; dashboardPort?: number | null; // OpenShell gateway registration name and host port bound to this sandbox. // Persisted so later lifecycle commands operate on the sandbox's own gateway @@ -86,11 +95,6 @@ export interface SandboxEntry { gatewayPort?: number | null; } -export interface SandboxMessagingState { - schemaVersion: 1; - plan: SandboxMessagingPlan; -} - export interface SandboxRegistry { sandboxes: Record; defaultSandbox: string | null; @@ -351,11 +355,6 @@ export function registerSandbox(entry: SandboxEntry): void { nemoclawVersion: entry.nemoclawVersion || null, imageTag: entry.imageTag || null, providerCredentialHashes: entry.providerCredentialHashes || undefined, - messagingChannels: entry.messagingChannels || [], - messagingChannelConfig: - entry.messagingChannelConfig && Object.keys(entry.messagingChannelConfig).length > 0 - ? { ...entry.messagingChannelConfig } - : undefined, messaging: cloneSandboxMessagingState(entry.messaging), hermesToolGateways: Array.isArray(entry.hermesToolGateways) && entry.hermesToolGateways.length > 0 @@ -365,10 +364,6 @@ export function registerSandbox(entry: SandboxEntry): void { hermesDashboardPort: entry.hermesDashboardPort ?? undefined, hermesDashboardInternalPort: entry.hermesDashboardInternalPort ?? undefined, hermesDashboardTui: entry.hermesDashboardTui === true ? true : undefined, - disabledChannels: - Array.isArray(entry.disabledChannels) && entry.disabledChannels.length > 0 - ? [...entry.disabledChannels] - : undefined, dashboardPort: entry.dashboardPort ?? undefined, gatewayName: entry.gatewayName ?? undefined, gatewayPort: entry.gatewayPort ?? undefined, @@ -380,16 +375,6 @@ export function registerSandbox(entry: SandboxEntry): void { }); } -function cloneSandboxMessagingState( - messaging: SandboxMessagingState | undefined, -): SandboxMessagingState | undefined { - if (!messaging || messaging.schemaVersion !== 1) return undefined; - return { - schemaVersion: 1, - plan: JSON.parse(JSON.stringify(messaging.plan)) as SandboxMessagingPlan, - }; -} - export function updateSandbox(name: string, updates: Partial): boolean { return withLock(() => { const data = load(); @@ -507,20 +492,13 @@ export function removeCustomPolicyByName(name: string, presetName: string): bool } export function getDisabledChannels(name: string): string[] { - const data = load(); - return data.sandboxes[name]?.disabledChannels ?? []; + return getRegistryDisabledChannels(name, { load }); +} + +export function getConfiguredMessagingChannels(name: string): string[] { + return getRegistryConfiguredMessagingChannels(name, { load }); } export function setChannelDisabled(name: string, channel: string, disabled: boolean): boolean { - return withLock(() => { - const data = load(); - const entry = data.sandboxes[name]; - if (!entry) return false; - const current = new Set(entry.disabledChannels ?? []); - if (disabled) current.add(channel); - else current.delete(channel); - entry.disabledChannels = current.size > 0 ? Array.from(current).sort() : undefined; - save(data); - return true; - }); + return setRegistryChannelDisabled(name, channel, disabled, { load, save, withLock }); } diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index f1081d1771..8f3adb3615 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -3,17 +3,13 @@ import { spawnSync } from "node:child_process"; import type { CaptureOpenshellResult } from "./adapters/openshell/client"; -import { captureOpenshellCommand, stripAnsi } from "./adapters/openshell/client"; +import { captureOpenshellCommand } from "./adapters/openshell/client"; import { resolveOpenshell } from "./adapters/openshell/resolve"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import { getNamedGatewayLifecycleState } from "./gateway-runtime-action"; import { getLiveGatewayInference } from "./inference/live"; import type { GatewayHealth, MessagingBridgeHealth, ShowStatusCommandDeps } from "./inventory"; -import { - backfillMessagingChannels, - detectAllSlackSocketModeGatewayOverlaps, - findAllOverlaps, -} from "./messaging/applier"; +import { detectAllSlackSocketModeGatewayOverlaps, findAllOverlaps } from "./messaging/applier"; import * as registry from "./state/registry"; import { createSystemDeps, parseSshProcesses } from "./state/sandbox-session"; import { getServiceStatuses, showStatus as showServiceStatus } from "./tunnel/services"; @@ -61,50 +57,10 @@ function checkMessagingBridgeHealth( } } -function isMissingProviderOutput(output: string): boolean { - const normalized = stripAnsi(output).toLowerCase(); - return [ - /\bno such provider\b/, - /\bno provider named\b/, - /\bunknown provider\b/, - /\bprovider\b[\s\S]{0,120}\bnot found\b/, - /\bnot found\b[\s\S]{0,120}\bprovider\b/, - /\bprovider\b[\s\S]{0,120}\bdoes not exist\b/, - ].some((pattern) => pattern.test(normalized)); -} - -function makeConflictProbe(rootDir: string) { - // Upfront liveness check so we can distinguish "provider not attached" from - // "gateway unreachable". Provider probes also classify only explicit missing - // provider responses as absent so status remains non-destructive under - // transient transport, auth, or timeout failures. - let gatewayAlive: boolean | null = null; - const isGatewayAlive = (): boolean => { - if (gatewayAlive === null) { - const result = captureOpenshell(rootDir, ["sandbox", "list"], { - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); - gatewayAlive = result.status === 0; - } - return gatewayAlive; - }; - return { - providerExists: (name: string) => { - if (!isGatewayAlive()) return "error" as const; - const result = captureOpenshell(rootDir, ["provider", "get", name], { - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); - if (result.status === 0) return "present" as const; - return isMissingProviderOutput(result.output) ? ("absent" as const) : ("error" as const); - }, - }; -} - -function backfillAndFindOverlaps(rootDir: string) { - // Non-critical path: status must remain usable even if the gateway probe or - // registry write throws, so any failure yields an empty overlap list. +function findMessagingOverlaps() { + // Non-critical path: status must remain usable even if overlap detection + // throws, so any failure yields an empty overlap list. try { - backfillMessagingChannels(registry, makeConflictProbe(rootDir)); // Report both conflict axes independently and without deduping. They are // distinct, both-true facts: a shared messaging credential conflicts on any // gateway (the gateway-independent, more actionable signal), while two Slack @@ -221,7 +177,7 @@ export function buildStatusCommandDeps(rootDir: string): ShowStatusCommandDeps { : undefined, checkMessagingBridgeHealth: (sandboxName, channels) => checkMessagingBridgeHealth(rootDir, sandboxName, channels), - backfillAndFindOverlaps: () => backfillAndFindOverlaps(rootDir), + findMessagingOverlaps, readGatewayLog: (sandboxName) => readGatewayLog(rootDir, sandboxName), log: console.log, }; diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index f4b7fdb127..5dc34829e0 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -7,7 +7,7 @@ // the bridge has egress to its upstream API after the new sandbox boots. import assert from "node:assert/strict"; -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -151,12 +151,20 @@ workflowPlanner.MessagingWorkflowPlanner.prototype.buildPlan = async function(co const registry = require(${j("state/registry.js")}); const registryUpdates = []; -registry.getSandbox = () => ({ - name: "test-sb", - agent: ${JSON.stringify(sandboxAgent)}, - messagingChannels: [], - disabledChannels: [], -}); +function makeMessagingPlan(sandboxName, channelIds = [], disabledChannels = []) { + const disabled = new Set(disabledChannels); + return { schemaVersion: 1, sandboxName, agent: ${JSON.stringify(sandboxAgent)}, workflow: "onboard", channels: channelIds.map((channelId) => ({ channelId, displayName: channelId, authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste", active: !disabled.has(channelId), selected: true, configured: true, disabled: disabled.has(channelId), inputs: [], hooks: [] })), disabledChannels, credentialBindings: [], networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], stateUpdates: [], healthChecks: [] }; +} +function makeRegistryEntry(channelIds = [], disabledChannels = []) { + return { + name: "test-sb", + agent: ${JSON.stringify(sandboxAgent)}, + ...(channelIds.length > 0 + ? { messaging: { schemaVersion: 1, plan: makeMessagingPlan("test-sb", channelIds, disabledChannels) } } + : {}), + }; +} +registry.getSandbox = () => makeRegistryEntry(); registry.updateSandbox = (name, updates) => { registryUpdates.push({ name, updates }); return true; @@ -380,10 +388,6 @@ const ctx = module.exports; const payload = parseResultPayload(result); assert.deepEqual(payload.providerCalls, [], "WhatsApp must not create host-side providers"); - assert.deepEqual(payload.registryUpdates[0], { - name: "test-sb", - updates: { messagingChannels: ["whatsapp"], disabledChannels: [] }, - }); const messagingStateUpdate = payload.registryUpdates.find( (entry: { updates?: { messaging?: { plan?: { channels?: Array<{ channelId?: string }> } } }; @@ -401,9 +405,11 @@ const ctx = module.exports; ); assert.equal(messagingStateUpdate.updates.messaging.plan.agent, "hermes"); assert.deepEqual(messagingStateUpdate.updates.messaging.plan.credentialBindings, []); + assert.equal(messagingStateUpdate.updates.messagingChannels, undefined); + assert.equal(messagingStateUpdate.updates.disabledChannels, undefined); assert.deepEqual( payload.registryUpdates.map((entry: { name: string }) => entry.name), - ["test-sb", "test-sb"], + ["test-sb"], ); assert.deepEqual( payload.appliedCalls, @@ -528,7 +534,7 @@ process.exit = (code) => { assert.deepEqual( payload.registryUpdates, [], - `missing preset YAML must not register telegram in messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + `missing preset YAML must not register telegram in the messaging plan; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( !payload.callOrder.includes("promptAndRebuild"), @@ -780,7 +786,7 @@ process.exit = (code) => { assert.deepEqual( payload.registryUpdates, [], - `missing whatsapp.yaml must not flip messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + `missing whatsapp.yaml must not write messaging plan state; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( !payload.callOrder.includes("promptAndRebuild"), @@ -794,7 +800,7 @@ process.exit = (code) => { ); }); - it("rolls back providers, registry, and credentials when applyPreset fails after a successful loadPreset", () => { + it("rolls back providers and credentials without writing plan state when applyPreset fails", () => { const script = `${buildPreamble({ applyPresetResult: false })} const ctx = module.exports; const exitCodes = []; @@ -836,12 +842,11 @@ process.exit = (code) => { [{ sandboxName: "test-sb", presetName: "telegram" }], `expected one failed applyPreset call; got ${JSON.stringify(payload.appliedCalls)}`, ); - assert.ok( - payload.registryUpdates.length === 2, - `expected one add update and one rollback update; got ${JSON.stringify(payload.registryUpdates)}`, + assert.deepEqual( + payload.registryUpdates, + [], + `failed preset apply must not write messaging plan state; got ${JSON.stringify(payload.registryUpdates)}`, ); - assert.deepEqual(payload.registryUpdates[0].updates.messagingChannels, ["telegram"]); - assert.deepEqual(payload.registryUpdates[1].updates.messagingChannels, []); assert.deepEqual( payload.deletedCredentialKeys, ["TELEGRAM_BOT_TOKEN"], @@ -858,7 +863,7 @@ process.exit = (code) => { ); }); - it("completes rollback registry update and reports residual gateway state when openshell detach fails", () => { + it("leaves plan state untouched and reports residual gateway state when detach fails", () => { const script = `${buildPreamble({ applyPresetResult: false })} openshellRuntime.runOpenshell = (args) => { if (Array.isArray(args) && args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach") { @@ -908,11 +913,11 @@ process.exit = (code) => { assert.deepEqual(payload.exitCodes, [1]); assert.deepEqual(payload.appliedCalls, [{ sandboxName: "test-sb", presetName: "telegram" }]); - assert.ok( - payload.registryUpdates.length === 2, - `expected registry add + rollback even when openshell detach fails; got ${JSON.stringify(payload.registryUpdates)}`, + assert.deepEqual( + payload.registryUpdates, + [], + `failed preset apply must leave plan-backed registry state untouched; got ${JSON.stringify(payload.registryUpdates)}`, ); - assert.deepEqual(payload.registryUpdates[1].updates.messagingChannels, []); assert.deepEqual( payload.deletedCredentialKeys, ["TELEGRAM_BOT_TOKEN"], @@ -932,13 +937,12 @@ process.exit = (code) => { ); }); - it("restores prior channel config when re-add applyPreset fails on an already-enabled channel", () => { + it("restores prior channel credentials when re-add applyPreset fails on an already-enabled channel", () => { const script = `${buildPreamble({ applyPresetResult: false })} registry.getSandbox = () => ({ name: "test-sb", agent: "openclaw", - messagingChannels: ["telegram"], - disabledChannels: [], + messaging: { schemaVersion: 1, plan: makeMessagingPlan("test-sb", ["telegram"]) }, }); credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; const ctx = module.exports; @@ -976,11 +980,10 @@ process.exit = (code) => { assert.deepEqual(payload.exitCodes, [1]); assert.deepEqual(payload.appliedCalls, [{ sandboxName: "test-sb", presetName: "telegram" }]); - const lastRegistry = payload.registryUpdates[payload.registryUpdates.length - 1]; assert.deepEqual( - lastRegistry.updates.messagingChannels, - ["telegram"], - `re-add failure must keep prior 'telegram' in messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + payload.registryUpdates, + [], + `re-add failure must leave prior plan-backed registry state untouched; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), @@ -1001,13 +1004,12 @@ process.exit = (code) => { ); }); - it("restores prior registry state even when re-upsert during re-add rollback throws", () => { + it("leaves prior plan state untouched even when re-upsert during re-add rollback throws", () => { const script = `${buildPreamble({ applyPresetResult: false })} registry.getSandbox = () => ({ name: "test-sb", agent: "openclaw", - messagingChannels: ["telegram"], - disabledChannels: [], + messaging: { schemaVersion: 1, plan: makeMessagingPlan("test-sb", ["telegram"]) }, }); credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; let upsertCalls = 0; @@ -1048,11 +1050,10 @@ process.exit = (code) => { const payload = parseResultPayload(result); assert.deepEqual(payload.exitCodes, [1]); - const lastRegistry = payload.registryUpdates[payload.registryUpdates.length - 1]; assert.deepEqual( - lastRegistry.updates.messagingChannels, - ["telegram"], - `registry restoration must precede gateway re-upsert so an upsert failure cannot orphan the channel; got ${JSON.stringify(payload.registryUpdates)}`, + payload.registryUpdates, + [], + `re-add gateway restore failure must leave prior plan-backed registry state untouched; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), @@ -1625,8 +1626,6 @@ const registry = require(${j("state/registry.js")}); registry.getSandbox = () => ({ name: "test-sb", agent: global.__testAgent || "openclaw", - messagingChannels: [], - disabledChannels: [], }); registry.updateSandbox = () => true; diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts index b3b4880b61..bab5296fd3 100644 --- a/test/channels-remove-full-teardown.test.ts +++ b/test/channels-remove-full-teardown.test.ts @@ -112,6 +112,35 @@ credentials.prompt = async (msg) => { throw new Error("unexpected prompt: " + ms const onboard = require(${j("onboard.js")}); onboard.isNonInteractive = () => true; +const initialChannel = ${JSON.stringify(channelInRegistry)}; +function makeMessagingPlan(channelIds = initialChannel ? [initialChannel] : [], disabledChannels = []) { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName: "test-sb", + agent: ${JSON.stringify(sandboxAgent)}, + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + const onboardSession = require(${j("state/onboard-session.js")}); const sessionStore = { sandboxName: "test-sb", @@ -129,9 +158,7 @@ const sessionStore = { routerPid: null, routerCredentialHash: null, policyTier: null, - messagingChannels: [${JSON.stringify(channelInRegistry)}], - messagingChannelConfig: null, - disabledChannels: [], + messagingPlan: makeMessagingPlan(), hermesToolGateways: [], wechatConfig: null, }; @@ -143,8 +170,7 @@ const registryUpdates = []; registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(sandboxAgent)}, - messagingChannels: [${JSON.stringify(channelInRegistry)}], - disabledChannels: [], + messaging: { schemaVersion: 1, plan: makeMessagingPlan() }, policies: ${JSON.stringify(presetNamesApplied)}, }); registry.updateSandbox = (name, updates) => { @@ -407,8 +433,6 @@ const registryOverride = require(${JSON.stringify(path.join(repoRoot, "dist", "l registryOverride.getSandbox = () => ({ name: "test-sb", agent: "openclaw", - messagingChannels: [], - disabledChannels: [], policies: [], }); const policiesOverride = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "policy/index.js"))}); @@ -536,18 +560,20 @@ const ctx = module.exports; "other presets must remain after removing a token-based channel", ); - const messagingChannelsUpdate = payload.registryUpdates.find( - (u: { updates: { messagingChannels?: string[] } }) => - u.updates.messagingChannels !== undefined, + const messagingPlanUpdate = payload.registryUpdates.find( + (u: { updates: { messaging?: { plan?: { channels?: Array<{ channelId: string }> } } } }) => + u.updates.messaging?.plan, ); assert.ok( - messagingChannelsUpdate, - `expected an updateSandbox call that writes messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + messagingPlanUpdate, + `expected an updateSandbox call that writes messaging.plan; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.deepEqual( - messagingChannelsUpdate.updates.messagingChannels, + messagingPlanUpdate.updates.messaging.plan.channels.map( + (channel: { channelId: string }) => channel.channelId, + ), [], - "messagingChannels must be empty after removing telegram", + "messaging.plan.channels must be empty after removing telegram", ); }); }); diff --git a/test/cli/logs.test.ts b/test/cli/logs.test.ts index 78454d4205..cf1e9b22c7 100644 --- a/test/cli/logs.test.ts +++ b/test/cli/logs.test.ts @@ -310,7 +310,35 @@ describe("CLI dispatch", () => { provider: "nvidia-prod", gpuEnabled: false, policies: [], - messagingChannels: ["telegram"], + messaging: { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName: "alpha", + agent: "hermes", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, agent: "hermes", }, }, diff --git a/test/cli/status-root-json.test.ts b/test/cli/status-root-json.test.ts index a3d04f05cf..fab97a8b0d 100644 --- a/test/cli/status-root-json.test.ts +++ b/test/cli/status-root-json.test.ts @@ -30,7 +30,35 @@ describe("CLI root status JSON", () => { policies: ["npm"], agent: "openclaw", dashboardPort: 18789, - messagingChannels: ["slack"], + messaging: { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "slack", + displayName: "slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, dashboardUrl: "http://127.0.0.1:18789/?token=dashboard-secret", logs: "Bearer should-not-render xoxb-should-not-render-000000", }, diff --git a/test/e2e/test-channels-add-remove.sh b/test/e2e/test-channels-add-remove.sh index 6dbc8a4196..8d7fff4047 100755 --- a/test/e2e/test-channels-add-remove.sh +++ b/test/e2e/test-channels-add-remove.sh @@ -175,20 +175,24 @@ if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPat const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); const entry = registry.sandboxes?.[sandboxName]; if (!entry) fail("sandbox " + sandboxName + " missing from registry"); -const config = entry.messagingChannelConfig; -if (!config || typeof config !== "object" || Array.isArray(config)) { - fail("messagingChannelConfig missing or not an object"); -} -if (config.TELEGRAM_ALLOWED_IDS !== allowedIds) { - fail("TELEGRAM_ALLOWED_IDS expected " + allowedIds + ", got " + JSON.stringify(config.TELEGRAM_ALLOWED_IDS)); +const plan = entry.messaging?.plan; +if (!plan || plan.schemaVersion !== 1) fail("messaging.plan missing or schemaVersion != 1"); +const channel = Array.isArray(plan.channels) + ? plan.channels.find((item) => item?.channelId === "telegram") + : null; +if (!channel) fail("telegram channel missing from messaging.plan.channels"); +const inputs = Array.isArray(channel.inputs) ? channel.inputs : []; +const inputValue = (id) => inputs.find((input) => input?.inputId === id)?.value; +if (inputValue("allowedIds") !== allowedIds) { + fail("allowedIds input expected " + allowedIds + ", got " + JSON.stringify(inputValue("allowedIds"))); } -if (config.TELEGRAM_REQUIRE_MENTION !== requireMention) { - fail("TELEGRAM_REQUIRE_MENTION expected " + requireMention + ", got " + JSON.stringify(config.TELEGRAM_REQUIRE_MENTION)); +if (inputValue("requireMention") !== requireMention) { + fail("requireMention input expected " + requireMention + ", got " + JSON.stringify(inputValue("requireMention"))); } ' "$REGISTRY" "$SANDBOX_NAME" "$TELEGRAM_ALLOWED_IDS_VALUE" "$TELEGRAM_REQUIRE_MENTION_VALUE" 2>&1)"; then - pass "host registry messagingChannelConfig persists telegram config ${context}" + pass "host registry messaging.plan persists telegram config ${context}" else - fail "host registry messagingChannelConfig missing telegram config ${context}: ${output}" + fail "host registry messaging.plan missing telegram config ${context}: ${output}" fi } @@ -567,7 +571,6 @@ else fail "C5a: channels remove telegram did not unregister" tail -20 /tmp/nc-remove.log 2>/dev/null || true fi -assert_host_telegram_config "after channels remove" assert_host_telegram_plan "removed" "after channels remove" info "Rebuilding sandbox to apply the remove..." @@ -614,6 +617,6 @@ else pass "C6c: 'telegram' preset removed from policy list after remove+rebuild" fi -assert_host_telegram_config "after remove+rebuild" +assert_host_telegram_plan "removed" "after remove+rebuild" print_summary diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index 7d86bd2c4a..a4ee450c55 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -196,6 +196,31 @@ registry_array_contains() { printf '%s' "$value" | grep -Fq "\"${item}\"" } +registry_plan_channel_contains() { + local item="$1" + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, channelId] = process.argv.slice(1); +if (!fs.existsSync(registryPath)) process.exit(1); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const plan = registry.sandboxes?.[sandboxName]?.messaging?.plan; +const channels = Array.isArray(plan?.channels) ? plan.channels : []; +process.exit(channels.some((channel) => channel?.channelId === channelId) ? 0 : 1); +' "$REGISTRY" "$ACTIVE_SANDBOX" "$item" +} + +registry_plan_disabled_contains() { + local item="$1" + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, channelId] = process.argv.slice(1); +if (!fs.existsSync(registryPath)) process.exit(1); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const disabled = registry.sandboxes?.[sandboxName]?.messaging?.plan?.disabledChannels; +process.exit(Array.isArray(disabled) && disabled.includes(channelId) ? 0 : 1); +' "$REGISTRY" "$ACTIVE_SANDBOX" "$item" +} + provider_names_for_channel() { local sandbox="$1" local channel="$2" @@ -250,8 +275,8 @@ channel_presence() { } dump_channel_state() { - info "registry.messagingChannels: $(registry_field messagingChannels)" - info "registry.disabledChannels: $(registry_field disabledChannels)" + info "registry.messaging.plan.channels: $(node -e 'const fs=require("fs"); const [p,n]=process.argv.slice(1); const r=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,"utf8")):{}; const c=r.sandboxes?.[n]?.messaging?.plan?.channels; process.stdout.write(JSON.stringify(Array.isArray(c)?c.map((x)=>x?.channelId):null));' "$REGISTRY" "$ACTIVE_SANDBOX" 2>/dev/null || echo null)" + info "registry.messaging.plan.disabledChannels: $(node -e 'const fs=require("fs"); const [p,n]=process.argv.slice(1); const r=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,"utf8")):{}; process.stdout.write(JSON.stringify(r.sandboxes?.[n]?.messaging?.plan?.disabledChannels ?? null));' "$REGISTRY" "$ACTIVE_SANDBOX" 2>/dev/null || echo null)" info "registry.providerCredentialHashes: $(registry_field providerCredentialHashes)" if [ "$ACTIVE_AGENT" = "openclaw" ]; then info "openclaw.json channels:" @@ -287,14 +312,14 @@ assert_registry_channels() { local context="$2" local channel msg for channel in "${CHANNELS[@]}"; do - if [ "$expected" = "present" ] && registry_array_contains messagingChannels "$channel"; then - msg="${ACTIVE_AGENT}/${channel}: registry.messagingChannels contains channel ${context}" + if [ "$expected" = "present" ] && registry_plan_channel_contains "$channel"; then + msg="${ACTIVE_AGENT}/${channel}: registry.messaging.plan.channels contains channel ${context}" pass_msg "$msg" - elif [ "$expected" = "absent" ] && ! registry_array_contains messagingChannels "$channel"; then - msg="${ACTIVE_AGENT}/${channel}: registry.messagingChannels excludes channel ${context}" + elif [ "$expected" = "absent" ] && ! registry_plan_channel_contains "$channel"; then + msg="${ACTIVE_AGENT}/${channel}: registry.messaging.plan.channels excludes channel ${context}" pass_msg "$msg" else - msg="${ACTIVE_AGENT}/${channel}: registry.messagingChannels expected ${expected} ${context}, got $(registry_field messagingChannels)" + msg="${ACTIVE_AGENT}/${channel}: registry.messaging.plan.channels expected ${expected} ${context}" fail_msg "$msg" fi done @@ -303,17 +328,16 @@ assert_registry_channels() { assert_disabled_channels() { local expected="$1" local context="$2" - local channel msg value - value="$(registry_field disabledChannels)" + local channel msg for channel in "${CHANNELS[@]}"; do - if [ "$expected" = "present" ] && registry_array_contains disabledChannels "$channel"; then - msg="${ACTIVE_AGENT}/${channel}: registry.disabledChannels contains channel ${context}" + if [ "$expected" = "present" ] && registry_plan_disabled_contains "$channel"; then + msg="${ACTIVE_AGENT}/${channel}: registry.messaging.plan.disabledChannels contains channel ${context}" pass_msg "$msg" - elif [ "$expected" = "absent" ] && ! registry_array_contains disabledChannels "$channel"; then - msg="${ACTIVE_AGENT}/${channel}: registry.disabledChannels excludes channel ${context}" + elif [ "$expected" = "absent" ] && ! registry_plan_disabled_contains "$channel"; then + msg="${ACTIVE_AGENT}/${channel}: registry.messaging.plan.disabledChannels excludes channel ${context}" pass_msg "$msg" else - msg="${ACTIVE_AGENT}/${channel}: registry.disabledChannels expected ${expected} ${context}, got ${value}" + msg="${ACTIVE_AGENT}/${channel}: registry.messaging.plan.disabledChannels expected ${expected} ${context}" fail_msg "$msg" fi done @@ -333,15 +357,30 @@ if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPat const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); const entry = registry.sandboxes?.[sandboxName]; if (!entry) fail("sandbox " + sandboxName + " missing from registry"); -const config = entry.messagingChannelConfig; -if (!config || typeof config !== "object" || Array.isArray(config)) { - fail("messagingChannelConfig missing or not an object"); -} +const plan = entry.messaging?.plan; +if (!plan || plan.schemaVersion !== 1) fail("messaging.plan missing or schemaVersion != 1"); +const channels = Array.isArray(plan.channels) ? plan.channels : []; +const inputMap = { + TELEGRAM_ALLOWED_IDS: ["telegram", "allowedIds"], + TELEGRAM_REQUIRE_MENTION: ["telegram", "requireMention"], + DISCORD_SERVER_ID: ["discord", "serverId"], + DISCORD_USER_ID: ["discord", "userId"], + DISCORD_REQUIRE_MENTION: ["discord", "requireMention"], + SLACK_ALLOWED_USERS: ["slack", "allowedUsers"], + WECHAT_ALLOWED_IDS: ["wechat", "allowedIds"], +}; for (let i = 0; i < pairs.length; i += 2) { const key = pairs[i]; const expected = pairs[i + 1]; - if (config[key] !== expected) { - fail(key + " expected " + expected + ", got " + JSON.stringify(config[key])); + const mapping = inputMap[key]; + if (!mapping) fail("no plan input mapping for " + key); + const [channelId, inputId] = mapping; + const channel = channels.find((item) => item?.channelId === channelId); + if (!channel) fail(channelId + " missing from messaging.plan.channels"); + const inputs = Array.isArray(channel.inputs) ? channel.inputs : []; + const actual = inputs.find((input) => input?.inputId === inputId)?.value; + if (actual !== expected) { + fail(key + " expected " + expected + ", got " + JSON.stringify(actual)); } } ' "$REGISTRY" "$ACTIVE_SANDBOX" \ @@ -352,10 +391,10 @@ for (let i = 0; i < pairs.length; i += 2) { DISCORD_REQUIRE_MENTION "$DISCORD_REQUIRE_MENTION" \ SLACK_ALLOWED_USERS "$SLACK_ALLOWED_USERS" \ WECHAT_ALLOWED_IDS "$WECHAT_ALLOWED_IDS" 2>&1)"; then - msg="${ACTIVE_AGENT}: host registry messagingChannelConfig persists channel config ${context}" + msg="${ACTIVE_AGENT}: host registry messaging.plan persists channel config ${context}" pass_msg "$msg" else - msg="${ACTIVE_AGENT}: host registry messagingChannelConfig missing channel config ${context}: ${output}" + msg="${ACTIVE_AGENT}: host registry messaging.plan missing channel config ${context}: ${output}" fail_msg "$msg" fi } @@ -605,7 +644,7 @@ ensure_tokenless_channels_enabled() { local added=0 local channel log rc msg for channel in "${TOKENLESS_CHANNELS[@]}"; do - if registry_array_contains messagingChannels "$channel"; then + if registry_plan_channel_contains "$channel"; then msg="${ACTIVE_AGENT}/${channel}: tokenless channel already registered" pass_msg "$msg" continue diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 4d9935206c..9fbcc9cdc0 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -143,30 +143,16 @@ openshell() { fi } -registry_field() { - local field="$1" - if [ ! -f "$REGISTRY" ]; then - echo "null" - return - fi - if command -v jq >/dev/null 2>&1; then - jq -c --arg name "$SANDBOX_NAME" --arg field "$field" \ - '.sandboxes[$name][$field]' "$REGISTRY" 2>/dev/null || echo "null" - else - node -e " -const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); -const v = (r.sandboxes || {})[process.argv[2]]?.[process.argv[3]]; -process.stdout.write(JSON.stringify(v ?? null)); -" "$REGISTRY" "$SANDBOX_NAME" "$field" 2>/dev/null || echo "null" - fi -} - -registry_array_contains() { - local field="$1" - local item="$2" - local value - value="$(registry_field "$field")" - printf '%s' "$value" | grep -Fq "\"${item}\"" +registry_plan_channel_contains() { + local item="$1" + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, channelId] = process.argv.slice(1); +if (!fs.existsSync(registryPath)) process.exit(1); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const channels = registry.sandboxes?.[sandboxName]?.messaging?.plan?.channels; +process.exit(Array.isArray(channels) && channels.some((channel) => channel?.channelId === channelId) ? 0 : 1); +' "$REGISTRY" "$SANDBOX_NAME" "$item" } assert_openclaw_config_activation() { @@ -899,10 +885,10 @@ else pass "M-WA1: WhatsApp QR-only channel creates no bridge provider" fi -if registry_array_contains messagingChannels "whatsapp"; then - pass "M-WA2: registry.messagingChannels contains whatsapp after channel add" +if registry_plan_channel_contains "whatsapp"; then + pass "M-WA2: registry.messaging.plan.channels contains whatsapp after channel add" else - fail "M-WA2: registry.messagingChannels missing whatsapp after channel add ($(registry_field messagingChannels))" + fail "M-WA2: registry.messaging.plan.channels missing whatsapp after channel add" fi whatsapp_policy_pre=$(openshell policy get --full "$SANDBOX_NAME" 2>/dev/null || true) diff --git a/test/e2e/test-rebuild-hermes.sh b/test/e2e/test-rebuild-hermes.sh index 50c9751324..cbc2bdbea3 100755 --- a/test/e2e/test-rebuild-hermes.sh +++ b/test/e2e/test-rebuild-hermes.sh @@ -247,6 +247,40 @@ echo "$PRE_REBUILD_CONFIG" | grep -Fq "discord:" \ # Register in NemoClaw registry python3 -c " import hashlib, json +credential_hash = hashlib.sha256('${DISCORD_FAKE_TOKEN}'.encode()).hexdigest() +plan = { + 'schemaVersion': 1, + 'sandboxName': '${SANDBOX_NAME}', + 'agent': 'hermes', + 'workflow': 'onboard', + 'channels': [{ + 'channelId': 'discord', + 'displayName': 'discord', + 'authMode': 'token-paste', + 'active': True, + 'selected': True, + 'configured': True, + 'disabled': False, + 'inputs': [], + 'hooks': [], + }], + 'disabledChannels': [], + 'credentialBindings': [{ + 'channelId': 'discord', + 'credentialId': 'discordBotToken', + 'sourceInput': 'botToken', + 'providerName': '${SANDBOX_NAME}-discord-bridge', + 'providerEnvKey': 'DISCORD_BOT_TOKEN', + 'placeholder': '${DISCORD_PLACEHOLDER}', + 'credentialAvailable': True, + 'credentialHash': credential_hash, + }], + 'networkPolicy': {'presets': ['discord'], 'entries': []}, + 'agentRender': [], + 'buildSteps': [], + 'stateUpdates': [], + 'healthChecks': [], +} reg = {'sandboxes': {'${SANDBOX_NAME}': { 'name': '${SANDBOX_NAME}', 'createdAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)', @@ -257,9 +291,9 @@ reg = {'sandboxes': {'${SANDBOX_NAME}': { 'policyTier': None, 'agent': 'hermes', 'agentVersion': '${OLD_HERMES_REGISTRY_VERSION}', - 'messagingChannels': ['discord'], + 'messaging': {'schemaVersion': 1, 'plan': plan}, 'providerCredentialHashes': { - 'DISCORD_BOT_TOKEN': hashlib.sha256('${DISCORD_FAKE_TOKEN}'.encode()).hexdigest() + 'DISCORD_BOT_TOKEN': credential_hash } }}, 'defaultSandbox': '${SANDBOX_NAME}'} with open('${REGISTRY_FILE}', 'w') as f: @@ -274,7 +308,9 @@ except Exception: sess['sandboxName'] = '${SANDBOX_NAME}' sess['agent'] = 'hermes' sess['status'] = 'complete' -sess['messagingChannels'] = ['discord'] +for key in ('messagingChannels', 'messagingChannelConfig', 'disabledChannels'): + sess.pop(key, None) +sess['messagingPlan'] = plan with open(sess_path, 'w') as f: json.dump(sess, f, indent=2) print('Registry and session updated') diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index a3c69a0bd4..28ce5120bb 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -26,8 +26,12 @@ type CommandEntry = { dockerfileReadError?: string; }; -function parseStdoutJson(stdout: string): T { - const line = stdout.trim().split("\n").pop(); +function parseStdoutJson>(stdout: string): T { + const line = stdout + .trim() + .split("\n") + .reverse() + .find((value) => /^[{[]/.test(value) && /[}\]]$/.test(value)); assert.ok(line, `expected JSON payload in stdout:\n${stdout}`); return JSON.parse(line); } @@ -38,6 +42,12 @@ const yamlModulePath = requireForTest.resolve("yaml"); const onboardScriptMocksPath = JSON.stringify( path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), ); +const inlineMessagingPlanHelper = String.raw` +function makeMessagingPlan(channelIds, disabledChannels = []) { + const disabled = new Set(disabledChannels); + return { schemaVersion: 1, sandboxName: "my-assistant", agent: "openclaw", workflow: "onboard", channels: channelIds.map((channelId) => ({ channelId, displayName: channelId, authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste", active: !disabled.has(channelId), selected: true, configured: true, disabled: disabled.has(channelId), inputs: [], hooks: [] })), disabledChannels, credentialBindings: [], networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], stateUpdates: [], healthChecks: [] }; +} +`.trim(); describe("onboard messaging", () => { it("creates providers for messaging tokens and attaches them to the sandbox", { @@ -127,6 +137,7 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; + process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token-value"; @@ -159,14 +170,7 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); // Verify providers were created with the right credential keys const providerCommands = payload.commands.filter((e: CommandEntry) => @@ -452,14 +456,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); assert.ok(payload.createCommand.command.includes("sandbox create")); assert.match(payload.createCommand.command, /--provider my-assistant-slack-bridge/); @@ -529,9 +526,10 @@ const fs = require("node:fs"); const commands = []; const registerCalls = []; +${inlineMessagingPlanHelper} registry.registerSandbox({ name: "my-assistant", - messagingChannels: ["discord", "slack"], + messaging: { schemaVersion: 1, plan: makeMessagingPlan(["discord", "slack"]) }, }); runner.run = (command, opts = {}) => { const normalized = _n(command); @@ -594,6 +592,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["discord", "slack"]))).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["discord", "slack"], ); @@ -622,14 +621,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); const providerMutationCommands = payload.commands.filter((entry: CommandEntry) => /\bprovider (create|update)\b/.test(entry.command), @@ -653,7 +645,13 @@ const { createSandbox } = require(${onboardPath}); "discord", "slack", ]); - assert.deepEqual(payload.registerCalls[0]?.messagingChannels, ["discord", "slack"]); + assert.deepEqual( + payload.registerCalls[0]?.messaging?.plan?.channels.map( + (channel: { channelId: string }) => channel.channelId, + ), + ["discord", "slack"], + ); + assert.equal(payload.registerCalls[0]?.messagingChannels, undefined); assert.equal(payload.registerCalls[0]?.providerCredentialHashes, undefined); }); @@ -693,10 +691,10 @@ const fs = require("node:fs"); const commands = []; const registerCalls = []; +${inlineMessagingPlanHelper} registry.registerSandbox({ name: "my-assistant", - messagingChannels: ["telegram"], - disabledChannels: ["telegram"], + messaging: { schemaVersion: 1, plan: makeMessagingPlan(["telegram"], ["telegram"]) }, }); runner.run = (command, opts = {}) => { const normalized = _n(command); @@ -754,6 +752,7 @@ const { createSandbox } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; delete process.env.TELEGRAM_BOT_TOKEN; + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["telegram"], ["telegram"]))).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["telegram"], ); @@ -779,14 +778,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); const createCommand = payload.commands.find((entry: CommandEntry) => entry.command.includes("sandbox create"), @@ -805,15 +797,16 @@ const { createSandbox } = require(${onboardPath}); "disabled channel's bridge must not be attached to the new sandbox", ); + const registeredPlan = payload.registerCalls[0]?.messaging?.plan; assert.deepEqual( - payload.registerCalls[0]?.messagingChannels, + registeredPlan?.channels.map((channel: { channelId: string }) => channel.channelId), ["telegram"], - "registry.messagingChannels must keep the disabled-but-configured channel so `channels start` can recover it", + "registry.messaging.plan must keep the disabled-but-configured channel so `channels start` can recover it", ); assert.deepEqual( - payload.registerCalls[0]?.disabledChannels, + registeredPlan?.disabledChannels, ["telegram"], - "registry.disabledChannels must round-trip through the rebuild", + "registry.messaging.plan.disabledChannels must round-trip through the rebuild", ); }); @@ -854,6 +847,7 @@ const fs = require("node:fs"); const commands = []; const registerCalls = []; +${inlineMessagingPlanHelper} runner.run = (command, opts = {}) => { const normalized = _n(command); commands.push({ command: normalized, env: opts.env || null }); @@ -913,6 +907,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env[key]; } } + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["whatsapp"]))).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["whatsapp"], ); @@ -937,14 +932,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); const providerMutationCommands = payload.commands.filter((entry: CommandEntry) => /\bprovider (create|update)\b/.test(entry.command), @@ -963,7 +951,13 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /--provider \S+-bridge\b/); assert.deepEqual(activeChannelsFromDockerfile(createCommand.dockerfileContent), ["whatsapp"]); - assert.deepEqual(payload.registerCalls[0]?.messagingChannels, ["whatsapp"]); + assert.deepEqual( + payload.registerCalls[0]?.messaging?.plan?.channels.map( + (channel: { channelId: string }) => channel.channelId, + ), + ["whatsapp"], + ); + assert.equal(payload.registerCalls[0]?.messagingChannels, undefined); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -1004,9 +998,10 @@ const childProcess = require("node:child_process"); const { EventEmitter } = require("node:events"); const fs = require("node:fs"); +${inlineMessagingPlanHelper} registry.registerSandbox({ name: "my-assistant", - disabledChannels: ["whatsapp"], + messaging: { schemaVersion: 1, plan: makeMessagingPlan(["whatsapp"], ["whatsapp"]) }, }); const commands = []; @@ -1070,6 +1065,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env[key]; } } + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["whatsapp"], ["whatsapp"]))).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["whatsapp"], ); @@ -1094,14 +1090,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); const createCommand = payload.commands.find((entry: CommandEntry) => entry.command.includes("sandbox create"), @@ -1114,15 +1103,16 @@ const { createSandbox } = require(${onboardPath}); [], "disabled QR channel must not be active in the image plan", ); + const registeredPlan = payload.registerCalls[0]?.messaging?.plan; assert.deepEqual( - payload.registerCalls[0]?.messagingChannels, + registeredPlan?.channels.map((channel: { channelId: string }) => channel.channelId), ["whatsapp"], - "registry.messagingChannels must keep the disabled QR channel so `channels start` can recover it (mirrors #3381)", + "registry.messaging.plan must keep the disabled QR channel so `channels start` can recover it (mirrors #3381)", ); assert.deepEqual( - payload.registerCalls[0]?.disabledChannels, + registeredPlan?.disabledChannels, ["whatsapp"], - "registry.disabledChannels must round-trip through the rebuild", + "registry.messaging.plan.disabledChannels must round-trip through the rebuild", ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -1273,14 +1263,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); assert.equal(payload.sandboxName, "my-assistant", "should reuse existing sandbox"); assert.ok( @@ -1409,14 +1392,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); // Only telegram provider should be created const providerCommands = payload.commands.filter((e: CommandEntry) => @@ -1547,14 +1523,7 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payloadLine = result.stdout - .trim() - .split("\n") - .slice() - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); - const payload = JSON.parse(payloadLine); + const payload = parseStdoutJson(result.stdout); // No messaging providers should be created at all const providerCommands = payload.commands.filter((e: CommandEntry) => diff --git a/test/onboard.test.ts b/test/onboard.test.ts index dd9b4250db..eb82f5f791 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1081,7 +1081,6 @@ registry.getSandbox = (name) => provider: "hermes-provider", model: "moonshotai/kimi-k2.6", hermesToolGateways: [], - messagingChannels: [], policies: ["nous-web"], } : null; diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index aa8a61eaea..fff0e1c9c4 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -33,6 +33,33 @@ afterEach(() => { } }); +function makeMessagingPlan(sandboxName: string, agent: string, channelIds: string[]) { + return { + schemaVersion: 1, + sandboxName, + agent, + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + /** * Create a temp HOME with a sandbox registry, onboard session, and * optionally a saved credential in credentials.json. @@ -50,7 +77,7 @@ function createFixture(opts: { providerSelectionStatus?: string; agent?: string | null; hermesAuthMethod?: string | null; - messagingChannels?: string[] | null; + messagingPlanChannels?: string[] | null; dockerBuildExitCode?: number; providerRegistered?: boolean; }) { @@ -62,7 +89,7 @@ function createFixture(opts: { providerSelectionStatus = "complete", agent = null, hermesAuthMethod = null, - messagingChannels = null, + messagingPlanChannels = null, dockerBuildExitCode = 0, providerRegistered = true, } = opts; @@ -70,6 +97,10 @@ function createFixture(opts: { tmpFixtures.push(tmpDir); const nemoclawDir = path.join(tmpDir, ".nemoclaw"); fs.mkdirSync(nemoclawDir, { recursive: true, mode: 0o700 }); + const messagingPlan = + messagingPlanChannels && messagingPlanChannels.length > 0 + ? makeMessagingPlan(sandboxName, agent ?? "openclaw", messagingPlanChannels) + : null; // ── Registry ────────────────────────────────────────────────── fs.writeFileSync( @@ -84,7 +115,7 @@ function createFixture(opts: { gpuEnabled: false, policies: [], agent, - messagingChannels, + ...(messagingPlan ? { messaging: { schemaVersion: 1, plan: messagingPlan } } : {}), }, }, }), @@ -116,7 +147,7 @@ function createFixture(opts: { nimContainer: null, webSearchConfig: null, policyPresets: [], - messagingChannels: null, + messagingPlan: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, steps: { preflight: { @@ -352,7 +383,7 @@ describe("Issue #2273: atomic rebuild", () => { }, () => { const f = createFixture({ agent: "hermes", - messagingChannels: ["discord"], + messagingPlanChannels: ["discord"], credentialEnv: "NVIDIA_API_KEY", savedCredential: { key: "NVIDIA_API_KEY", @@ -368,7 +399,9 @@ describe("Issue #2273: atomic rebuild", () => { fs.readFileSync(path.join(f.nemoclawDir, "onboard-session.json"), "utf-8"), ); expect(session.agent).toBe("hermes"); - expect(session.messagingChannels).toEqual(["discord"]); + expect( + session.messagingPlan?.channels.map((channel: { channelId: string }) => channel.channelId), + ).toEqual(["discord"]); }); it("aborts rebuild before backup when forced Hermes base image build fails", { diff --git a/test/rebuild-shields-auto-unlock.test.ts b/test/rebuild-shields-auto-unlock.test.ts index c35973ff72..13d68ec242 100644 --- a/test/rebuild-shields-auto-unlock.test.ts +++ b/test/rebuild-shields-auto-unlock.test.ts @@ -90,7 +90,6 @@ function createFixture(opts: { shieldsLocked: boolean }) { policies: [], agent: null, openshellDriver: "vm", - messagingChannels: null, }, }, }), @@ -126,7 +125,7 @@ function createFixture(opts: { shieldsLocked: boolean }) { nimContainer: null, webSearchConfig: null, policyPresets: [], - messagingChannels: null, + messagingPlan: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, steps: { preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, diff --git a/test/rebuild-stale-recovery.test.ts b/test/rebuild-stale-recovery.test.ts index 3936b05f60..0de4bbed97 100644 --- a/test/rebuild-stale-recovery.test.ts +++ b/test/rebuild-stale-recovery.test.ts @@ -81,7 +81,6 @@ function createStaleFixture( gpuEnabled: false, policies: [], agent: null, - messagingChannels: null, ...(gatewayName ? { gatewayName } : {}), }, }, @@ -113,7 +112,7 @@ function createStaleFixture( nimContainer: null, webSearchConfig: null, policyPresets: [], - messagingChannels: null, + messagingPlan: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, steps: {}, }), diff --git a/test/registry.test.ts b/test/registry.test.ts index 2b0c59f165..e9b5591b84 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -18,6 +18,38 @@ const registry = require("../dist/lib/state/registry"); const regFile = path.join(tmpDir, ".nemoclaw", "sandboxes.json"); +function makeMessagingPlan( + name: string, + channels: string[] = ["telegram"], + disabledChannels: string[] = [], +) { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName: name, + agent: "openclaw", + workflow: "onboard", + channels: channels.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: !disabled.has(channelId), + selected: true, + configured: true, + disabled: disabled.has(channelId), + inputs: [], + hooks: [], + })), + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + beforeEach(() => { if (fs.existsSync(regFile)) fs.unlinkSync(regFile); }); @@ -196,26 +228,23 @@ describe("registry", () => { expect(data.sandboxes.tagged.imageTag).toBe("openshell/sandbox-from:1776766054"); }); - it("stores messaging channel config at registration time", () => { + it("stores messaging plan state at registration time", () => { + const plan = makeMessagingPlan("messaging", ["telegram"]); registry.registerSandbox({ name: "messaging", - messagingChannels: ["telegram"], - messagingChannelConfig: { - TELEGRAM_ALLOWED_IDS: "123,456", - TELEGRAM_REQUIRE_MENTION: "1", - }, + messaging: { schemaVersion: 1, plan }, }); const sb = registry.getSandbox("messaging"); - expect(sb.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123,456", - TELEGRAM_REQUIRE_MENTION: "1", - }); + expect(sb.messaging).toEqual({ schemaVersion: 1, plan }); + const rawSandbox = sb as unknown as Record; + expect(rawSandbox.messagingChannels).toBeUndefined(); + expect(rawSandbox.messagingChannelConfig).toBeUndefined(); + expect(registry.getConfiguredMessagingChannels("messaging")).toEqual(["telegram"]); const data = JSON.parse(fs.readFileSync(regFile, "utf-8")); - expect(data.sandboxes.messaging.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123,456", - TELEGRAM_REQUIRE_MENTION: "1", - }); + expect(data.sandboxes.messaging.messaging).toEqual({ schemaVersion: 1, plan }); + expect(data.sandboxes.messaging.messagingChannels).toBeUndefined(); + expect(data.sandboxes.messaging.messagingChannelConfig).toBeUndefined(); }); it("imageTag defaults to null when not provided", () => { @@ -239,7 +268,10 @@ describe("registry", () => { }); it("setChannelDisabled toggles a channel on and off for a sandbox", () => { - registry.registerSandbox({ name: "s1" }); + registry.registerSandbox({ + name: "s1", + messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram", "discord"]) }, + }); expect(registry.getDisabledChannels("s1")).toEqual([]); expect(registry.setChannelDisabled("s1", "telegram", true)).toBe(true); @@ -252,20 +284,25 @@ describe("registry", () => { expect(registry.getDisabledChannels("s1")).toEqual(["discord"]); }); - it("setChannelDisabled clears the disabledChannels field when empty", () => { - registry.registerSandbox({ name: "s1" }); + it("setChannelDisabled clears plan.disabledChannels when empty", () => { + registry.registerSandbox({ + name: "s1", + messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram"]) }, + }); registry.setChannelDisabled("s1", "telegram", true); registry.setChannelDisabled("s1", "telegram", false); const persisted = JSON.parse(fs.readFileSync(regFile, "utf-8")); + expect(persisted.sandboxes.s1.messaging.plan.disabledChannels).toEqual([]); expect(persisted.sandboxes.s1.disabledChannels).toBeUndefined(); }); - it("updateSandbox clears disabledChannels when explicitly set to undefined", () => { - registry.registerSandbox({ name: "s1" }); - registry.setChannelDisabled("s1", "telegram", true); - expect(registry.updateSandbox("s1", { disabledChannels: undefined })).toBe(true); - const persisted = JSON.parse(fs.readFileSync(regFile, "utf-8")); - expect(persisted.sandboxes.s1.disabledChannels).toBeUndefined(); + it("setChannelDisabled returns false when the channel is not configured in the plan", () => { + registry.registerSandbox({ + name: "s1", + messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram"]) }, + }); + expect(registry.setChannelDisabled("s1", "discord", true)).toBe(false); + expect(registry.getDisabledChannels("s1")).toEqual([]); }); it("setChannelDisabled returns false when sandbox is missing", () => { @@ -273,11 +310,14 @@ describe("registry", () => { }); it("registerSandbox preserves disabledChannels when re-registering", () => { - registry.registerSandbox({ name: "s1" }); + registry.registerSandbox({ + name: "s1", + messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram"]) }, + }); registry.setChannelDisabled("s1", "telegram", true); registry.registerSandbox({ name: "s1", - disabledChannels: registry.getDisabledChannels("s1"), + messaging: registry.getSandbox("s1").messaging, }); expect(registry.getDisabledChannels("s1")).toEqual(["telegram"]); }); diff --git a/test/repro-2201.test.ts b/test/repro-2201.test.ts index 7e06015552..7efc4dac9f 100644 --- a/test/repro-2201.test.ts +++ b/test/repro-2201.test.ts @@ -45,6 +45,33 @@ afterEach(() => { } }); +function makeMessagingPlan(sandboxName: string, agent: string | null, channelIds: string[]) { + return { + schemaVersion: 1, + sandboxName, + agent: agent ?? "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + /** * Set up a temp HOME that mirrors the reporter's scenario: * @@ -64,12 +91,12 @@ function createFixture({ rebuildTarget: { name: string; agent: string | null; - messagingChannelConfig?: Record | null; + messagingPlanChannels?: string[] | null; }; lastOnboarded: { name: string; agent: string | null; - messagingChannelConfig?: Record | null; + messagingPlanChannels?: string[] | null; }; fromDockerfile?: string | null; }) { @@ -77,6 +104,20 @@ function createFixture({ tmpFixtures.push(tmpDir); const nemoclawDir = path.join(tmpDir, ".nemoclaw"); fs.mkdirSync(nemoclawDir, { recursive: true, mode: 0o700 }); + const rebuildTargetMessagingPlan = rebuildTarget.messagingPlanChannels + ? makeMessagingPlan( + rebuildTarget.name, + rebuildTarget.agent, + rebuildTarget.messagingPlanChannels, + ) + : null; + const lastOnboardedMessagingPlan = lastOnboarded.messagingPlanChannels + ? makeMessagingPlan( + lastOnboarded.name, + lastOnboarded.agent, + lastOnboarded.messagingPlanChannels, + ) + : null; // ── Registry — both sandboxes exist ─────────────────────────── fs.writeFileSync( @@ -91,8 +132,8 @@ function createFixture({ gpuEnabled: false, policies: [], agent: rebuildTarget.agent, - ...(rebuildTarget.messagingChannelConfig - ? { messagingChannelConfig: rebuildTarget.messagingChannelConfig } + ...(rebuildTargetMessagingPlan + ? { messaging: { schemaVersion: 1, plan: rebuildTargetMessagingPlan } } : {}), }, [lastOnboarded.name]: { @@ -102,8 +143,8 @@ function createFixture({ gpuEnabled: false, policies: [], agent: lastOnboarded.agent, - ...(lastOnboarded.messagingChannelConfig - ? { messagingChannelConfig: lastOnboarded.messagingChannelConfig } + ...(lastOnboardedMessagingPlan + ? { messaging: { schemaVersion: 1, plan: lastOnboardedMessagingPlan } } : {}), }, }, @@ -135,8 +176,7 @@ function createFixture({ nimContainer: null, webSearchConfig: null, policyPresets: [], - messagingChannels: null, - messagingChannelConfig: lastOnboarded.messagingChannelConfig ?? null, + messagingPlan: lastOnboardedMessagingPlan, metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile }, steps: { preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -249,7 +289,7 @@ function runRebuild(fixture: ReturnType) { type SessionFixture = { agent?: string | null; - messagingChannelConfig?: Record | null; + messagingPlan?: { channels?: Array<{ channelId?: string }> } | null; }; /** @@ -268,12 +308,12 @@ function readSessionAgent(fixture: ReturnType): string | n } /** - * Read only the messaging config recorded in the fixture onboarding session. + * Read only the messaging plan recorded in the fixture onboarding session. */ -function readSessionMessagingChannelConfig( +function readSessionMessagingPlan( fixture: ReturnType, -): Record | null | undefined { - return readSession(fixture).messagingChannelConfig; +): SessionFixture["messagingPlan"] { + return readSession(fixture).messagingPlan; } describe("Issue #2201: rebuild syncs agent from registry, not stale session", () => { @@ -305,7 +345,7 @@ describe("Issue #2201: rebuild syncs agent from registry, not stale session", () expect(readSessionAgent(f)).toBe("hermes"); }); - it("does not inherit messaging channel config from a stale session for another sandbox", { + it("does not inherit messaging plan from a stale session for another sandbox", { timeout: 60_000, }, () => { const f = createFixture({ @@ -313,14 +353,11 @@ describe("Issue #2201: rebuild syncs agent from registry, not stale session", () lastOnboarded: { name: "hermes", agent: "hermes", - messagingChannelConfig: { - TELEGRAM_ALLOWED_IDS: "999", - TELEGRAM_REQUIRE_MENTION: "1", - }, + messagingPlanChannels: ["telegram"], }, }); runRebuild(f); - expect(readSessionMessagingChannelConfig(f)).toBeNull(); + expect(readSessionMessagingPlan(f)).toBeNull(); }); });