diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index d087e32d37..00a0e86580 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": 1866, "test/generate-openclaw-config.test.ts": 2091, "test/install-preflight.test.ts": 4396, "test/nemoclaw-start.test.ts": 5289, "test/onboard-messaging.test.ts": 2097, "test/onboard-selection.test.ts": 6922, - "test/onboard.test.ts": 4874, - "test/policies.test.ts": 2763 + "test/onboard.test.ts": 4873, + "test/policies.test.ts": 2756 } } 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..fef63df28d 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; +import { makeMessagingState } from "../../../../test/helpers/messaging-plan-fixtures"; // The orchestrator transitively pulls in policy/index.ts and agent/defs.ts, // both of which require runner.ts via CJS; runner.ts uses `require()` calls @@ -15,6 +16,14 @@ vi.mock("../../policy", () => ({ vi.mock("../../state/registry", () => ({ getSandbox: vi.fn(), + getConfiguredMessagingChannelsFromEntry: vi.fn((entry?: SandboxEntry | null) => + (entry?.messaging?.plan.channels ?? []) + .filter((channel) => channel.configured) + .map((channel) => channel.channelId), + ), + getDisabledMessagingChannelsFromEntry: vi.fn((entry?: SandboxEntry | null) => + entry?.messaging?.plan.disabledChannels ? [...entry.messaging.plan.disabledChannels] : [], + ), })); vi.mock("../../agent/defs", () => ({ @@ -104,15 +113,11 @@ function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { } as unknown as AgentDefinition; } -function entry( - messagingChannels: string[] = ["whatsapp"], - disabledChannels: string[] = [], -): SandboxEntry { +function entry(channelIds: string[] = ["whatsapp"], disabledChannels: string[] = []): SandboxEntry { return { name: "alpha", agent: "openclaw", - messagingChannels, - disabledChannels, + messaging: makeMessagingState("alpha", channelIds, disabledChannels), } as SandboxEntry; } @@ -434,7 +439,7 @@ describe("showSandboxChannelStatus (whatsapp)", () => { expect(capturedCmd as unknown as string).toMatch(/pgrep -fa/); }); - it("skips the deep probe and reports paused state when WhatsApp is in disabledChannels", async () => { + it("skips the deep probe and reports paused state when WhatsApp is disabled in the plan", async () => { // Regression guard: `channels stop whatsapp` deliberately drops the // bridge and preset until the operator runs `channels start`. The // status command should reflect that rather than probing a torn-down 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..18c3df5262 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -423,9 +423,9 @@ 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 channels = registeredChannels.filter((channel: string) => !disabledChannels.has(channel)); + const registeredChannels = registry.getConfiguredMessagingChannelsFromEntry(sb); + const disabledChannels = new Set(registry.getDisabledMessagingChannelsFromEntry(sb)); + const channels = registry.getActiveMessagingChannelsFromEntry(sb); const pausedChannels = registeredChannels.filter((channel: string) => disabledChannels.has(channel), ); @@ -783,13 +783,7 @@ 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 enabledChannels = registeredChannels.filter( - (channel: string) => !disabledChannelsSet.has(channel), - ); + const enabledChannels = registry.getActiveMessagingChannelsFromEntry(sb); const runtimeCheck = channelRuntimeDoctorCheck(sandboxName, enabledChannels); if (runtimeCheck) checks.push(runtimeCheck); } diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index b718d8c67e..aa3cff6b95 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -44,11 +44,15 @@ const policy = D("policy/index.js"); const { hashCredential } = D("security/credential-hash.js") as { hashCredential: (v: string) => string | null; }; -const { addSandboxChannel } = D("actions/sandbox/policy-channel.js") as { +const { addSandboxChannel, removeSandboxChannel } = D("actions/sandbox/policy-channel.js") as { addSandboxChannel: ( name: string, options?: { channel?: string; dryRun?: boolean; force?: boolean }, ) => Promise; + removeSandboxChannel: ( + name: string, + options?: { channel?: string; dryRun?: boolean; force?: boolean }, + ) => Promise; }; const TELEGRAM_TOKEN = "123456:AAH-secret-bot-token-value"; @@ -61,6 +65,8 @@ function makePlanEntry( channelId: "telegram" | "slack" | "discord" | "wechat" | "whatsapp", bindings: Array<{ providerEnvKey: string; credentialHash?: string }>, ): SandboxEntry { + const authMode = + channelId === "wechat" ? "host-qr" : channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste"; return { name, messaging: { @@ -74,7 +80,7 @@ function makePlanEntry( { channelId, displayName: channelId, - authMode: "token-paste", + authMode, active: true, selected: true, configured: true, @@ -84,16 +90,9 @@ function makePlanEntry( }, ], disabledChannels: [], - credentialBindings: bindings.map((b) => ({ - channelId, - credentialId: b.providerEnvKey.toLowerCase(), - sourceInput: b.providerEnvKey.toLowerCase(), - providerName: `${name}-${channelId}-bridge`, - providerEnvKey: b.providerEnvKey, - placeholder: `openshell:resolve:env:${b.providerEnvKey}`, - credentialAvailable: true, - ...(b.credentialHash ? { credentialHash: b.credentialHash } : {}), - })), + credentialBindings: bindings.map((b) => + makeCredentialBinding(name, channelId, b.providerEnvKey, b.credentialHash), + ), networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], @@ -104,6 +103,66 @@ function makePlanEntry( } as unknown as SandboxEntry; } +function makeCredentialBinding( + sandboxName: string, + channelId: "telegram" | "slack" | "discord" | "wechat" | "whatsapp", + providerEnvKey: string, + credentialHash?: string, +) { + const byEnvKey: Record< + string, + { + readonly credentialId: string; + readonly sourceInput: string; + readonly providerName: string; + readonly placeholder: string; + } + > = { + TELEGRAM_BOT_TOKEN: { + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: `${sandboxName}-telegram-bridge`, + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }, + DISCORD_BOT_TOKEN: { + credentialId: "discordBotToken", + sourceInput: "botToken", + providerName: `${sandboxName}-discord-bridge`, + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + }, + WECHAT_BOT_TOKEN: { + credentialId: "wechatBotToken", + sourceInput: "botToken", + providerName: `${sandboxName}-wechat-bridge`, + placeholder: "openshell:resolve:env:WECHAT_BOT_TOKEN", + }, + SLACK_BOT_TOKEN: { + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: `${sandboxName}-slack-bridge`, + placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + }, + SLACK_APP_TOKEN: { + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: `${sandboxName}-slack-app`, + placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }, + }; + const spec = byEnvKey[providerEnvKey]; + if (!spec) throw new Error(`Unsupported test credential env key: ${providerEnvKey}`); + return { + channelId, + credentialId: spec.credentialId, + sourceInput: spec.sourceInput, + providerName: spec.providerName, + providerEnvKey, + placeholder: spec.placeholder, + credentialAvailable: true, + ...(credentialHash ? { credentialHash } : {}), + }; +} + let spies: MockInstance[]; let logSpy: MockInstance; let errSpy: MockInstance; @@ -246,7 +305,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -268,7 +327,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -288,7 +347,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -307,7 +366,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -335,7 +394,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -359,8 +418,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: { name: "alpha" }, + others: [makePlanEntry("bob", "telegram", [{ providerEnvKey: "TELEGRAM_BOT_TOKEN" }])], }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); promptMock.mockResolvedValue("y"); @@ -375,7 +434,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { @@ -421,7 +480,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -447,7 +506,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: { name: "alpha" }, others: [ makePlanEntry("bob", "wechat", [ { providerEnvKey: "WECHAT_BOT_TOKEN", credentialHash: wechatHash }, @@ -473,8 +532,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: { name: "alpha" }, + others: [makePlanEntry("bob", "whatsapp", [])], }); process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -487,59 +546,45 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { expect(promptMock).not.toHaveBeenCalled(); }); - // Scenario 9 - it("probe + backfill failure is swallowed; a pre-recorded matching hash still warns", async () => { - arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, - 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" }, - ], - }); - 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; - }); + it("in-sandbox-qr whatsapp add exits before success when messaging plan persistence fails", async () => { + arrangeRegistry({ current: { name: "alpha" }, others: [] }); + updateSandboxMock.mockReturnValue(false); 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( + await expect(addSandboxChannel("alpha", { channel: "whatsapp" })).rejects.toThrow( "process.exit(1)", ); + + const text = loggedText(); + expect(text).toContain("Could not persist the messaging plan"); + expect(text).not.toContain("Enabled whatsapp channel"); + expect(text).not.toContain("Change queued"); expect(exitMock).toHaveBeenCalledWith(1); - expect(loggedText()).toContain("same telegram credential"); + expect(promptMock).not.toHaveBeenCalled(); }); - it("probe + backfill failure with no pre-recorded conflict lets the add proceed", async () => { - arrangeRegistry({ - current: { name: "alpha", messagingChannels: [] }, - others: [], // no other sandbox -> no conflict resolvable - }); + it("token-backed add rolls back and exits before rebuild prompt when messaging plan persistence fails", async () => { + arrangeRegistry({ current: { name: "alpha" }, others: [] }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); - runOpenshellMock.mockReturnValue({ status: 1, stdout: "", stderr: "down" }); + updateSandboxMock.mockReturnValue(false); + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; - await addSandboxChannel("alpha", { channel: "telegram" }); + await expect(addSandboxChannel("alpha", { channel: "telegram" })).rejects.toThrow( + "process.exit(1)", + ); - expect(exitMock).not.toHaveBeenCalled(); + const text = loggedText(); + expect(text).toContain("Rolling back 'telegram' bridge registration"); + expect(text).toContain("Could not persist the messaging plan"); + expect(text).not.toContain("Registered telegram bridge"); + expect(text).not.toContain("Change queued"); expect(upsertMock).toHaveBeenCalledTimes(1); - expect(updateSandboxMock).toHaveBeenCalledWith("alpha", expect.any(Object)); + expect(exitMock).toHaveBeenCalledWith(1); + expect(promptMock).not.toHaveBeenCalled(); }); it("non-interactive add aborts when the conflict check throws", async () => { - arrangeRegistry({ current: { name: "alpha", messagingChannels: [] }, others: [] }); + arrangeRegistry({ current: { name: "alpha" }, others: [] }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); listSandboxesMock.mockImplementation(() => { throw new Error("malformed messaging plan"); @@ -557,7 +602,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: { name: "alpha" }, others: [] }); getCredentialMock.mockReturnValue(TELEGRAM_TOKEN); listSandboxesMock.mockImplementation(() => { throw new Error("malformed messaging plan"); @@ -575,7 +620,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -595,7 +640,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: { name: "alpha" }, others: [ makePlanEntry("bob", "telegram", [ { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, @@ -618,7 +663,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: { name: "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: [ @@ -643,4 +688,26 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { expect(text).not.toContain(slackApp); expect(upsertMock).toHaveBeenCalledTimes(1); }); + + it("remove exits before success and rebuild prompt when messaging plan persistence fails", async () => { + arrangeRegistry({ + current: makePlanEntry("alpha", "telegram", [ + { providerEnvKey: "TELEGRAM_BOT_TOKEN", credentialHash: TELEGRAM_HASH }, + ]), + others: [], + }); + updateSandboxMock.mockReturnValue(false); + process.env.NEMOCLAW_NON_INTERACTIVE = "1"; + + await expect(removeSandboxChannel("alpha", { channel: "telegram" })).rejects.toThrow( + "process.exit(1)", + ); + + const text = loggedText(); + expect(text).toContain("Could not persist the messaging plan"); + expect(text).not.toContain("Removed telegram bridge"); + expect(text).not.toContain("Change queued"); + expect(exitMock).toHaveBeenCalledWith(1); + expect(promptMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 0c213c9e88..e9bb7412bb 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -20,13 +20,7 @@ import { type SandboxMessagingPlan, toMessagingAgentId, } from "../../messaging"; -import { - type MessagingChannelConfig, - mergeMessagingChannelConfigs, - normalizeMessagingChannelConfigValue, - resolveMessagingChannelConfigEnvValue, - sanitizeMessagingChannelConfig, -} from "../../messaging-channel-config"; +import { parseValidSandboxMessagingPlan } from "../../messaging/plan-validation"; import { hashCredential } from "../../security/credential-hash"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; @@ -334,6 +328,31 @@ function channelSupportedByAgent(channelName: string, agent: AgentDefinition): b return availableManifestChannelsForAgent(agent).some((manifest) => manifest.id === channelName); } +function readValidatedSandboxMessagingPlan( + sandboxName: string, + entry: NonNullable>, + agent: AgentDefinition, +): SandboxMessagingPlan | null { + return parseValidSandboxMessagingPlan(entry.messaging?.plan, { + registry: messagingManifestRegistry, + sandboxName, + agent: toMessagingAgentId(agent), + supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), + }); +} + +function readValidatedSandboxMessagingChannelPlan( + sandboxName: string, + entry: NonNullable>, + agent: AgentDefinition, + channelId: string, +): { plan: SandboxMessagingPlan; channel: SandboxMessagingChannelPlan } | null { + const plan = readValidatedSandboxMessagingPlan(sandboxName, entry, agent); + if (!plan) return null; + const channel = plan.channels.find((candidate) => candidate.channelId === channelId); + return channel ? { plan, channel } : null; +} + export function listSandboxChannels(sandboxName: string) { const agent = resolveAgentForSandbox(sandboxName); console.log(""); @@ -358,35 +377,11 @@ 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. Core conflict-detection errors fail closed unless --force +// is set. async function checkChannelAddConflict( sandboxName: string, channelName: string, @@ -411,15 +406,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( @@ -475,12 +464,8 @@ async function checkChannelAddConflict( 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, @@ -505,24 +490,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, @@ -624,12 +595,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 }; } @@ -804,10 +769,13 @@ async function persistManifestChannelDisabledPlan( sandboxName: string, channelId: string, disabled: boolean, -): Promise { +): Promise { const entry = registry.getSandbox(sandboxName); - if (!entry) return; + if (!entry) return false; const agent = resolveAgentForSandbox(sandboxName); + if (!readValidatedSandboxMessagingChannelPlan(sandboxName, entry, agent, channelId)) { + return false; + } const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); const context = { sandboxName, @@ -819,15 +787,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({ @@ -837,7 +805,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 { @@ -927,47 +896,27 @@ 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 persistManifestAddPlan(sandboxName: string, plan: SandboxMessagingPlan): boolean { + return MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); } -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 printMessagingPlanPersistenceFailure( + sandboxName: string, + channelName: string, + action: "add" | "remove", +): void { + console.error( + ` ${YW}⚠${R} Could not persist the messaging plan for '${sandboxName}' after channel ${action}.`, + ); + console.error( + " Earlier gateway or policy side effects may already have run, but durable messaging.plan was not saved.", + ); + console.error( + ` Re-run '${CLI_NAME} ${sandboxName} channels ${action} ${channelName}' after verifying the sandbox still exists in the registry.`, + ); } function persistWechatConfigFromEnv(sandboxName: string): void { @@ -1074,7 +1023,11 @@ export async function addSandboxChannel( } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); persistManifestAddState(sandboxName, manifest); - MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); + if (!persistManifestAddPlan(sandboxName, plan)) { + removeChannelPresetIfPresent(sandboxName, canonical); + printMessagingPlanPersistenceFailure(sandboxName, canonical, "add"); + process.exit(1); + } console.log(""); const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; if (help) console.log(` ${help}`); @@ -1097,10 +1050,7 @@ export async function addSandboxChannel( console.error(` Unknown channel '${canonical}'.`); process.exit(1); } - const priorEntry = registry.getSandbox(sandboxName); - const priorMessagingChannels: string[] = priorEntry?.messagingChannels - ? [...priorEntry.messagingChannels] - : []; + const priorMessagingChannels = registry.getConfiguredMessagingChannels(sandboxName); const wasAlreadyEnabled = priorMessagingChannels.includes(canonical); const channelTokenKeys = getChannelTokenKeys(channelDef); const priorCreds: Record = {}; @@ -1115,19 +1065,26 @@ export async function addSandboxChannel( // wrote credentials.json; with env-only persistence, exiting before // the rebuild used to drop the queued token. await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, acquired); - console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`); if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { await rollbackChannelAdd(sandboxName, channelDef, canonical, { wasAlreadyEnabled, - priorMessagingChannels, priorCreds, }); process.exit(1); } persistManifestAddState(sandboxName, manifest); - MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); + if (!persistManifestAddPlan(sandboxName, plan)) { + removeChannelPresetIfPresent(sandboxName, canonical); + await rollbackChannelAdd(sandboxName, channelDef, canonical, { + wasAlreadyEnabled, + priorCreds, + }); + printMessagingPlanPersistenceFailure(sandboxName, canonical, "add"); + process.exit(1); + } + console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`); const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); @@ -1139,7 +1096,6 @@ async function rollbackChannelAdd( canonical: string, snapshot: { wasAlreadyEnabled: boolean; - priorMessagingChannels: string[]; priorCreds: Record; }, ): Promise<{ ok: boolean; residual: string[] }> { @@ -1147,9 +1103,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); @@ -1180,7 +1133,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( @@ -1402,7 +1355,7 @@ export async function removeSandboxChannel( ? sessionForSandbox.policyPresets : []; const hasChannelResidue = - (registryEntry?.messagingChannels || []).includes(canonical) || + registry.getConfiguredMessagingChannels(sandboxName).includes(canonical) || (registryEntry?.policies || []).includes(canonical) || sessionPolicyPresets.includes(canonical) || policies.getAppliedPresets(sandboxName).includes(canonical); @@ -1430,15 +1383,18 @@ export async function removeSandboxChannel( } await applyChannelRemoveToGatewayAndRegistry(sandboxName, canonical, tokenKeys); + removeChannelPresetIfPresent(sandboxName, canonical); + if (!(await persistManifestChannelRemovePlan(sandboxName, canonical))) { + printMessagingPlanPersistenceFailure(sandboxName, canonical, "remove"); + process.exit(1); + } + if (tokenKeys.length > 0) { console.log(` ${G}✓${R} Removed ${canonical} bridge from the OpenShell gateway.`); } else { console.log(` ${G}✓${R} Removed ${canonical} channel.`); } - removeChannelPresetIfPresent(sandboxName, canonical); - await persistManifestChannelRemovePlan(sandboxName, canonical); - // Token-based channels: best-effort tidy of any leftover dir. Token // revocation already prevents the bot from authenticating, so a // failure here is a warning, not a bail. @@ -1470,13 +1426,28 @@ async function sandboxChannelsSetEnabled( process.exit(1); } - if (!registry.getSandbox(sandboxName)) { + const entry = registry.getSandbox(sandboxName); + if (!entry) { console.error(` Sandbox '${sandboxName}' not found in the registry.`); process.exit(1); } const normalized = channelArg.trim().toLowerCase(); - const alreadyDisabled = registry.getDisabledChannels(sandboxName).includes(normalized); + const agent = resolveAgentForSandbox(sandboxName); + const planChannel = readValidatedSandboxMessagingChannelPlan( + sandboxName, + entry, + agent, + normalized, + ); + if (!planChannel) { + console.error( + ` Messaging plan for '${sandboxName}' does not include channel '${normalized}'.`, + ); + process.exit(1); + } + + const alreadyDisabled = planChannel.plan.disabledChannels.includes(normalized); if (alreadyDisabled === disabled) { console.log( ` Channel '${normalized}' is already ${disabled ? "disabled" : "enabled"} for '${sandboxName}'. Nothing to do.`, @@ -1489,11 +1460,12 @@ 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( + ` Messaging plan for '${sandboxName}' does not include channel '${normalized}'.`, + ); 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 b210b241f6..dcc54acaad 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -200,7 +200,7 @@ async function stageMessagingManifestPlanForRebuild( sandboxEntry, supportedChannelIds: agent.messagingPlatforms, }); - if (!plan || plan.channels.length === 0) { + if (!plan) { MessagingSetupApplier.clearPlanEnv(); log("Messaging manifest rebuild plan: no configured channels"); return null; @@ -474,6 +474,9 @@ export async function rebuildSandbox( bail(message); return; } + const rebuildDisabledChannels = rebuildMessagingPlan + ? [...rebuildMessagingPlan.disabledChannels] + : []; // Step 1: Ensure sandbox is live for backup log("Checking sandbox liveness: openshell sandbox list"); @@ -765,21 +768,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)) { @@ -801,23 +789,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}`, ); @@ -831,9 +802,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 @@ -1012,9 +980,6 @@ export async function rebuildSandbox( } const preservedRegistryFields = { - ...(hasRebuildMessagingChannels ? { messagingChannels: [...rebuildMessagingChannels] } : {}), - disabledChannels: - rebuildDisabledChannels.length > 0 ? [...rebuildDisabledChannels] : undefined, ...(hasRebuildHermesToolGateways ? { hermesToolGateways: [...rebuildHermesToolGateways] } : {}), diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index b9ca85e399..6dc52e557a 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -2,14 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeMessagingState } from "../../../test/helpers/messaging-plan-fixtures"; import { getSandboxInventory, getStatusReport, listSandboxesCommand, showStatusCommand, + type SandboxEntry, } from "./index"; +function withMessaging( + sandbox: Omit, + channels: readonly string[], + disabledChannels: readonly string[] = [], +): SandboxEntry { + return { + ...sandbox, + messaging: makeMessagingState(sandbox.name, channels, disabledChannels), + }; +} + describe("inventory commands", () => { it("returns structured empty inventory for JSON consumers", async () => { const getLiveInference = vi.fn().mockReturnValue(null); @@ -352,13 +365,7 @@ describe("inventory commands", () => { .mockReturnValue([{ channel: "telegram", conflicts: 7 }]); showStatusCommand({ listSandboxes: () => ({ - sandboxes: [ - { - name: "alpha", - model: "m", - messagingChannels: ["telegram"], - }, - ], + sandboxes: [withMessaging({ name: "alpha", model: "m" }, ["telegram"])], defaultSandbox: "alpha", }), getLiveInference: () => null, @@ -401,8 +408,8 @@ describe("inventory commands", () => { showStatusCommand({ listSandboxes: () => ({ sandboxes: [ - { name: "alice", model: "m", messagingChannels: ["telegram"] }, - { name: "bob", model: "m", messagingChannels: ["telegram"] }, + withMessaging({ name: "alice", model: "m" }, ["telegram"]), + withMessaging({ name: "bob", model: "m" }, ["telegram"]), ], defaultSandbox: "alice", }), @@ -426,8 +433,8 @@ describe("inventory commands", () => { showStatusCommand({ listSandboxes: () => ({ sandboxes: [ - { name: "alice", model: "m", messagingChannels: ["telegram"] }, - { name: "bob", model: "m", messagingChannels: ["telegram"] }, + withMessaging({ name: "alice", model: "m" }, ["telegram"]), + withMessaging({ name: "bob", model: "m" }, ["telegram"]), ], defaultSandbox: "alice", }), @@ -459,14 +466,7 @@ describe("inventory commands", () => { ); showStatusCommand({ listSandboxes: () => ({ - sandboxes: [ - { - name: "alpha", - model: "m", - messagingChannels: ["telegram"], - agent: "hermes", - }, - ], + sandboxes: [withMessaging({ name: "alpha", model: "m", agent: "hermes" }, ["telegram"])], defaultSandbox: "alpha", }), getLiveInference: () => null, @@ -489,13 +489,7 @@ describe("inventory commands", () => { const readGatewayLog = vi.fn(); showStatusCommand({ listSandboxes: () => ({ - sandboxes: [ - { - name: "alpha", - model: "m", - messagingChannels: ["telegram"], - }, - ], + sandboxes: [withMessaging({ name: "alpha", model: "m" }, ["telegram"])], defaultSandbox: "alpha", }), getLiveInference: () => null, diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 74c7752fa9..d029584d89 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -3,6 +3,7 @@ import { CLI_NAME } from "../cli/branding"; import type { GatewayInference } from "../inference/config"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import { redactFull } from "../security/redact"; import { resolveDefaultSandboxName } from "../tunnel/service-command"; @@ -18,7 +19,7 @@ export interface SandboxEntry { openshellDriver?: string | null; openshellVersion?: string | null; policies?: string[] | null; - messagingChannels?: string[] | null; + messaging?: { plan: SandboxMessagingPlan } | null; agent?: string | null; dashboardPort?: number | null; } @@ -155,6 +156,15 @@ export interface StatusReport { services: StatusServiceRow[]; } +function activeMessagingChannels(entry: SandboxEntry | null | undefined): string[] { + const plan = entry?.messaging?.plan; + 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); +} + function safeStatusString(value: string | null | undefined): string | null { if (typeof value !== "string" || value.length === 0) return null; return redactFull(value); @@ -489,13 +499,12 @@ 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. + // Re-fetch after overlap detection so this health check observes the latest + // registry snapshot. 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 = activeMessagingChannels(defaultEntry); + 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..574d7792a7 100644 --- a/src/lib/messaging/applier/conflict-detection/entries.ts +++ b/src/lib/messaging/applier/conflict-detection/entries.ts @@ -12,20 +12,14 @@ 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. + * + * Returns `null` when the entry has no compiled messaging plan. */ 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; } /** @@ -141,11 +135,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 2622b19fcf..ed2317a077 100644 --- a/src/lib/messaging/applier/conflict-detection/index.ts +++ b/src/lib/messaging/applier/conflict-detection/index.ts @@ -1,9 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export * from "./backfill"; export * from "./entries"; export * from "./plan"; -export * from "./probe"; export * from "./registry"; export type * from "./types"; diff --git a/src/lib/messaging/applier/conflict-detection/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 fc33eaaefa..f7f402dedf 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; } export interface ConflictRegistry { @@ -51,5 +34,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..aca75f10d2 100644 --- a/src/lib/messaging/applier/host-state-applier.test.ts +++ b/src/lib/messaging/applier/host-state-applier.test.ts @@ -2,11 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { beforeEach, describe, expect, it, vi } from "vitest"; - +import * as registry from "../../state/registry"; import type { SandboxMessagingPlan } from "../manifest"; import { MessagingHostStateApplier } from "./host-state-applier"; import { MessagingSetupApplier } from "./setup-applier"; -import * as registry from "../../state/registry"; vi.mock("../../state/registry", () => { const sandboxes = new Map>(); @@ -53,8 +52,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,13 +65,13 @@ describe("MessagingHostStateApplier", () => { }, }); expect(registryMock.__getSandbox("demo")).toMatchObject({ - messagingChannels: ["telegram"], - disabledChannels: ["discord"], messaging: { schemaVersion: 1, plan, }, }); + expect(registryMock.__getSandbox("demo")).not.toHaveProperty("messagingChannels"); + expect(registryMock.__getSandbox("demo")).not.toHaveProperty("disabledChannels"); }); it("can merge a single-channel add plan into existing messaging state", () => { @@ -119,7 +116,12 @@ function makePlan( channels: channelIds.map((channelId) => ({ channelId, displayName: channelId, - authMode: "token-paste", + authMode: + channelId === "wechat" + ? "host-qr" + : channelId === "whatsapp" + ? "in-sandbox-qr" + : "token-paste", active: true, selected: true, configured: true, @@ -128,52 +130,71 @@ function makePlan( hooks: [], })), disabledChannels: [], - credentialBindings: channelIds.map((channelId) => makeCredentialBinding(channelId, "bot")), + credentialBindings: channelIds.flatMap((channelId) => makeCredentialBindings(channelId)), networkPolicy: { presets: [...channelIds], - entries: channelIds.map((channelId) => ({ - channelId, - presetName: channelId, - policyKeys: [channelId], - source: "manifest", - })), + entries: channelIds.map((channelId) => makePolicyEntry(channelId)), }, - agentRender: channelIds.map((channelId) => ({ - channelId, - agent: "openclaw", - target: "openclaw.json", - kind: "json-fragment", - path: `channels.${channelId}`, - value: { enabled: true }, - templateRefs: [], - })), + agentRender: [], buildSteps: [], - stateUpdates: channelIds.map((channelId) => ({ - channelId, - kind: "persist-inputs", - stateKey: channelId, - inputIds: [], - })), + stateUpdates: [], healthChecks: [], ...overrides, }; } +function makeCredentialBindings( + channelId: string, +): SandboxMessagingPlan["credentialBindings"][number][] { + if (channelId === "slack") { + return [makeCredentialBinding("slack", "bot"), makeCredentialBinding("slack", "app")]; + } + if (channelId === "whatsapp") return []; + return [makeCredentialBinding(channelId, "bot")]; +} + function makeCredentialBinding( channelId: string, credentialId: string, ): SandboxMessagingPlan["credentialBindings"][number] { - const envKey = - channelId === "slack" && credentialId === "app" - ? "SLACK_APP_TOKEN" - : `${channelId.toUpperCase()}_BOT_TOKEN`; + if (channelId === "slack" && credentialId === "app") { + return { + channelId, + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: "demo-slack-app", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + credentialAvailable: true, + }; + } + const envKey = `${channelId.toUpperCase()}_BOT_TOKEN`; return { channelId, - credentialId, - sourceInput: credentialId, - providerName: `demo-${channelId}-${credentialId}`, + credentialId: `${channelId}BotToken`, + sourceInput: "botToken", + providerName: `demo-${channelId}-bridge`, providerEnvKey: envKey, - placeholder: `\${${envKey}}`, + placeholder: + channelId === "slack" + ? "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN" + : `openshell:resolve:env:${envKey}`, credentialAvailable: true, }; } + +function makePolicyEntry( + channelId: string, +): SandboxMessagingPlan["networkPolicy"]["entries"][number] { + return { + channelId, + presetName: channelId, + policyKeys: + channelId === "telegram" + ? ["telegram_bot"] + : channelId === "wechat" + ? ["wechat_bridge"] + : [channelId], + source: "manifest", + }; +} diff --git a/src/lib/messaging/applier/host-state-applier.ts b/src/lib/messaging/applier/host-state-applier.ts index c3e5b8644a..59d820a10d 100644 --- a/src/lib/messaging/applier/host-state-applier.ts +++ b/src/lib/messaging/applier/host-state-applier.ts @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../manifest"; import * as registry from "../../state/registry"; +import type { SandboxMessagingPlan } from "../manifest"; +import { parseValidSandboxMessagingPlan } from "../plan-validation"; import { MessagingSetupApplier } from "./setup-applier"; import type { MessagingSetupEnvOptions } from "./types"; @@ -42,9 +43,15 @@ export class MessagingHostStateApplier { if (plan.sandboxName !== sandboxName) return false; const entry = registry.getSandbox(sandboxName); if (!entry) return false; + const existingPlan = entry.messaging?.plan + ? parseValidSandboxMessagingPlan(entry.messaging.plan, { + sandboxName, + agent: plan.agent, + }) + : null; const nextPlan = - options.mode === "merge" && entry.messaging?.plan - ? mergeSandboxMessagingPlans(entry.messaging.plan, plan) + options.mode === "merge" && existingPlan + ? mergeSandboxMessagingPlans(existingPlan, plan) : clonePlan(plan); return registry.updateSandbox(sandboxName, { messaging: { diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 9fae64c39a..0043430631 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; import { MessagingWorkflowPlanner } from "../compiler"; import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../hooks"; -import type { ChannelHookSpec } from "../manifest"; import type { + ChannelHookSpec, MessagingAgentId, MessagingSerializableObject, SandboxMessagingPlan, @@ -126,17 +126,23 @@ describe("MessagingSetupApplier", () => { const repeated = { value: "same" }; const planWithAlias = { ...plan, - agentRender: [ - { - channelId: "telegram", - kind: "json-fragment", - agent: "openclaw", - target: "openclaw.json", - path: "x", - value: [repeated, repeated], - templateRefs: [], - }, - ], + channels: plan.channels.map((channel) => + channel.channelId === "telegram" + ? { + ...channel, + inputs: [ + ...channel.inputs, + { + channelId: "telegram", + inputId: "alias-test", + kind: "config", + required: false, + value: [repeated, repeated], + }, + ], + } + : channel, + ), } satisfies SandboxMessagingPlan; const env: NodeJS.ProcessEnv = {}; @@ -145,9 +151,9 @@ describe("MessagingSetupApplier", () => { const decoded = MessagingSetupApplier.readPlanFromEnv({ env }); expect(env[MESSAGING_SETUP_APPLIER_ENV_KEY]).toBeTruthy(); expect(decoded?.sandboxName).toBe("demo"); - expect(decoded?.agentRender[0]).toMatchObject({ + expect(decoded?.channels[0]?.inputs.at(-1)).toMatchObject({ channelId: "telegram", - kind: "json-fragment", + inputId: "alias-test", }); const cyclic = { ...plan } as Record; @@ -581,7 +587,7 @@ describe("MessagingSetupApplier", () => { expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); - it("rejects prototype-polluting JSON render paths", async () => { + it("rejects tampered JSON render paths before applying agent config", async () => { const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", ]); @@ -608,11 +614,11 @@ describe("MessagingSetupApplier", () => { await expect( MessagingSetupApplier.applyAgentConfigAtOpenShell(unsafePlan, { runOpenshell }), - ).rejects.toThrow("Messaging render path rejected unsafe object key '__proto__'"); + ).rejects.toThrow("render entry is not declared by the channel manifest"); expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); - it("rejects render targets outside the selected agent config root", async () => { + it("rejects tampered render targets before applying agent config", async () => { const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", ]); @@ -623,12 +629,12 @@ describe("MessagingSetupApplier", () => { return { status: 0 }; }; const unsafeTargets = [ - { target: "/tmp/openclaw.json", error: "must stay inside /sandbox/.openclaw" }, - { target: "~/.openclaw/../openclaw.json", error: "must not traverse directories" }, - { target: "~/.hermes/config.yaml", error: "Cannot apply Hermes messaging target" }, + "/tmp/openclaw.json", + "~/.openclaw/../openclaw.json", + "~/.hermes/config.yaml", ]; - for (const { target, error } of unsafeTargets) { + for (const target of unsafeTargets) { const unsafePlan = { ...plan, agentRender: [ @@ -646,7 +652,7 @@ describe("MessagingSetupApplier", () => { await expect( MessagingSetupApplier.applyAgentConfigAtOpenShell(unsafePlan, { runOpenshell }), - ).rejects.toThrow(error); + ).rejects.toThrow("render entry is not declared by the channel manifest"); } }); diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 2463feef40..53a567b256 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -4,6 +4,7 @@ import { Buffer } from "node:buffer"; import type { ChannelHookPhase, SandboxMessagingPlan } from "../manifest"; +import { assertValidSandboxMessagingPlan } from "../plan-validation"; import { applyAgentConfigAtOpenShell as applyAgentConfigPlanAtOpenShell, listHookRequests as listPlanHookRequests, @@ -24,7 +25,7 @@ import { export class MessagingSetupApplier { static encodePlan(plan: SandboxMessagingPlan): string { - assertSandboxMessagingPlan(plan); + assertValidSandboxMessagingPlan(plan); assertJsonSerializable(plan); return Buffer.from(JSON.stringify(plan), "utf8").toString("base64"); } @@ -32,7 +33,7 @@ export class MessagingSetupApplier { static decodePlan(encoded: string): SandboxMessagingPlan { const raw = Buffer.from(encoded, "base64").toString("utf8"); const parsed = JSON.parse(raw) as unknown; - assertSandboxMessagingPlan(parsed); + assertValidSandboxMessagingPlan(parsed); return parsed; } @@ -64,7 +65,7 @@ export class MessagingSetupApplier { plan: SandboxMessagingPlan, phase?: ChannelHookPhase, ): MessagingHookApplyRequest[] { - assertSandboxMessagingPlan(plan); + assertValidSandboxMessagingPlan(plan); return listPlanHookRequests(plan, phase); } @@ -79,7 +80,7 @@ export class MessagingSetupApplier { readonly appliedHooks: readonly string[]; readonly unresolvedTemplateRefs: readonly string[]; }> { - assertSandboxMessagingPlan(plan); + assertValidSandboxMessagingPlan(plan); return applyAgentConfigPlanAtOpenShell(plan, options); } @@ -87,7 +88,7 @@ export class MessagingSetupApplier { plan: SandboxMessagingPlan, options: MessagingCredentialApplyOptions, ): MessagingCredentialApplyResult { - assertSandboxMessagingPlan(plan); + assertValidSandboxMessagingPlan(plan); return applyCredentialsPlanAtOpenShell(plan, options); } @@ -95,35 +96,11 @@ export class MessagingSetupApplier { plan: SandboxMessagingPlan, options: MessagingPolicyApplyOptions, ): MessagingPolicyApplyResult { - assertSandboxMessagingPlan(plan); + assertValidSandboxMessagingPlan(plan); return applyPolicyPlanAtOpenShell(plan, options); } } -function assertSandboxMessagingPlan(value: unknown): asserts value is SandboxMessagingPlan { - 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) - ) { - throw new Error("Expected a serializable SandboxMessagingPlan."); - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function assertJsonSerializable( value: unknown, path = "$", diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 8b45dd4c90..b1e8d85f03 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -625,7 +625,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", sandboxEntry: { name: "demo", - messagingChannels: ["telegram"], messaging: { schemaVersion: 1, plan: existingPlan, @@ -657,7 +656,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", sandboxEntry: { name: "demo", - messagingChannels: ["telegram"], }, }); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 956c526151..9a2c64651d 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -10,6 +10,7 @@ import type { SandboxMessagingChannelPlan, SandboxMessagingPlan, } from "../manifest"; +import { parseValidSandboxMessagingPlan } from "../plan-validation"; import { ManifestCompiler } from "./manifest-compiler"; import type { ManifestCompilerContext, MessagingCompilerCredentialAvailability } from "./types"; @@ -55,7 +56,7 @@ export class MessagingWorkflowPlanner { async buildChannelAddPlanFromSandboxEntry( context: MessagingWorkflowPlannerChannelAddContext, ): Promise { - const existingPlan = readSandboxEntryPlan(context); + const existingPlan = readSandboxEntryPlan(context, this.registry); const compiledPlan = await this.buildPlan({ sandboxName: context.sandboxName, agent: context.agent, @@ -66,7 +67,7 @@ export class MessagingWorkflowPlanner { supportedChannelIds: context.supportedChannelIds, credentialAvailability: mergeAvailability( credentialAvailabilityFromPlan(existingPlan), - this.credentialAvailabilityFromSandboxEntry(context.sandboxEntry, [context.channelId]), + this.credentialAvailabilityFromSandboxEntry(context, [context.channelId]), context.credentialAvailability, ), }); @@ -97,13 +98,9 @@ export class MessagingWorkflowPlanner { async buildRebuildPlanFromSandboxEntry( context: MessagingWorkflowPlannerSandboxRebuildContext, ): Promise { - const existingPlan = readSandboxEntryPlan(context); + const existingPlan = readSandboxEntryPlan(context, this.registry); if (!existingPlan) return null; - return setPlanDisabledChannels( - existingPlan, - disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), - "rebuild", - ); + return setPlanDisabledChannels(existingPlan, existingPlan.disabledChannels, "rebuild"); } private assertSupportedChannels( @@ -141,16 +138,16 @@ export class MessagingWorkflowPlanner { context: MessagingWorkflowPlannerChannelMutationContext, workflow: MessagingCompilerWorkflow, ): Promise { - const existingPlan = readSandboxEntryPlan(context); + const existingPlan = readSandboxEntryPlan(context, this.registry); if (existingPlan) return { ...clonePlan(existingPlan), workflow }; return null; } private credentialAvailabilityFromSandboxEntry( - sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, + context: MessagingWorkflowPlannerSandboxContext, channelIds: readonly MessagingChannelId[], ): MessagingCompilerCredentialAvailability | undefined { - const plan = sandboxEntry?.messaging?.plan; + const plan = readSandboxEntryPlan(context, this.registry); if (!plan) return undefined; const availability: Record = {}; @@ -176,8 +173,6 @@ export class MessagingWorkflowPlanner { export interface MessagingWorkflowPlannerSandboxEntry { readonly name: string; readonly agent?: string | null; - readonly messagingChannels?: readonly MessagingChannelId[] | null; - readonly disabledChannels?: readonly MessagingChannelId[] | null; readonly messaging?: { readonly schemaVersion: 1; readonly plan: SandboxMessagingPlan; @@ -220,29 +215,20 @@ function onlyConfiguredChannels( } function readSandboxEntryPlan( - context: Pick, + context: Pick< + MessagingWorkflowPlannerSandboxContext, + "agent" | "sandboxEntry" | "sandboxName" | "supportedChannelIds" + >, + registry: ChannelManifestRegistry, ): SandboxMessagingPlan | null { const plan = context.sandboxEntry?.messaging?.plan; - if ( - !plan || - plan.schemaVersion !== 1 || - plan.sandboxName !== context.sandboxName || - plan.agent !== context.agent - ) { - return null; - } - return clonePlan(plan); -} - -function disabledChannelsFromSandboxEntry( - sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, - fallbackPlan: SandboxMessagingPlan | null, -): MessagingChannelId[] { - return uniqueChannels( - Array.isArray(sandboxEntry?.disabledChannels) - ? sandboxEntry.disabledChannels - : (fallbackPlan?.disabledChannels ?? []), - ); + const validPlan = parseValidSandboxMessagingPlan(plan, { + registry, + sandboxName: context.sandboxName, + agent: context.agent, + supportedChannelIds: context.supportedChannelIds, + }); + return validPlan ? clonePlan(validPlan) : null; } function clonePlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { diff --git a/src/lib/messaging/plan-validation/assertions.ts b/src/lib/messaging/plan-validation/assertions.ts new file mode 100644 index 0000000000..96581c6354 --- /dev/null +++ b/src/lib/messaging/plan-validation/assertions.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingAgentId, + MessagingCompilerWorkflow, + MessagingSerializableValue, +} from "../manifest"; + +const AGENTS = new Set(["openclaw", "hermes"]); +const WORKFLOWS = new Set([ + "onboard", + "add-channel", + "remove-channel", + "start-channel", + "stop-channel", + "rebuild", +]); + +export function isAgent(value: unknown): value is MessagingAgentId { + return typeof value === "string" && AGENTS.has(value as MessagingAgentId); +} + +export function isWorkflow(value: unknown): value is MessagingCompilerWorkflow { + return typeof value === "string" && WORKFLOWS.has(value as MessagingCompilerWorkflow); +} + +export function assertRecord(value: unknown, path: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail(path, "expected object"); + } + return value as Record; +} + +export function assertArray(value: unknown, path: string): asserts value is readonly unknown[] { + if (!Array.isArray(value)) fail(path, "expected array"); +} + +export function assertString(value: unknown, path: string): asserts value is string { + if (typeof value !== "string") fail(path, "expected string"); +} + +export function assertBoolean(value: unknown, path: string): asserts value is boolean { + if (typeof value !== "boolean") fail(path, "expected boolean"); +} + +export function assertStringArray( + value: unknown, + path: string, +): asserts value is readonly string[] { + assertArray(value, path); + value.forEach((entry, index) => assertString(entry, `${path}[${index}]`)); +} + +export function assertSerializableValue( + value: unknown, + path: string, + visiting: Set = new Set(), +): asserts value is MessagingSerializableValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return; + } + if (Array.isArray(value)) { + assertAcyclicObject(value, path, visiting, () => { + value.forEach((entry, index) => + assertSerializableValue(entry, `${path}[${index}]`, visiting), + ); + }); + return; + } + if (isPlainObject(value)) { + assertAcyclicObject(value, path, visiting, () => { + for (const [key, entry] of Object.entries(value)) { + assertSerializableValue(entry, `${path}.${key}`, visiting); + } + }); + return; + } + fail(path, "expected JSON-serializable value"); +} + +export function optionalStringArraysEqual( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + return stringArraysEqual(left, right); +} + +export function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +export function jsonEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function fail(path: string, reason: string): never { + throw new Error(`Invalid SandboxMessagingPlan at ${path}: ${reason}.`); +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertAcyclicObject( + value: object, + path: string, + visiting: Set, + visit: () => void, +): void { + if (visiting.has(value)) fail(path, "contains a cycle"); + visiting.add(value); + try { + visit(); + } finally { + visiting.delete(value); + } +} diff --git a/src/lib/messaging/plan-validation/credentials-policy.ts b/src/lib/messaging/plan-validation/credentials-policy.ts new file mode 100644 index 0000000000..50e77ed7cc --- /dev/null +++ b/src/lib/messaging/plan-validation/credentials-policy.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + SandboxMessagingCredentialBindingPlan, + SandboxMessagingNetworkPolicyEntryPlan, + SandboxMessagingPlan, +} from "../manifest"; +import { assertBoolean, assertRecord, assertString, assertStringArray, fail } from "./assertions"; +import { + credentialBindingMatches, + networkPolicyEntryMatches, + normalizePolicyPreset, + policyEntriesForManifest, + requirePlanManifest, +} from "./manifest-matchers"; +import type { PlanManifestMap } from "./types"; + +export function validateCredentialBindings( + plan: SandboxMessagingPlan, + manifests: PlanManifestMap, +): void { + plan.credentialBindings.forEach((binding, index) => { + const path = `$.credentialBindings[${index}]`; + assertCredentialBindingShape(binding, path); + const manifest = requirePlanManifest(manifests, binding.channelId, `${path}.channelId`); + const expected = manifest.credentials.find((credential) => + credentialBindingMatches(plan, binding, credential), + ); + if (!expected) fail(path, "credential binding is not declared by the channel manifest"); + }); +} + +export function validateNetworkPolicy( + plan: SandboxMessagingPlan, + manifests: PlanManifestMap, +): void { + const allowedPresets = new Set( + Array.from(manifests.values()).flatMap((manifest) => + (manifest.policyPresets ?? []).map((preset) => normalizePolicyPreset(preset).name), + ), + ); + plan.networkPolicy.presets.forEach((preset, index) => { + const path = `$.networkPolicy.presets[${index}]`; + assertString(preset, path); + if (!allowedPresets.has(preset)) fail(path, "policy preset is not declared by a plan channel"); + }); + + plan.networkPolicy.entries.forEach((entry, index) => { + const path = `$.networkPolicy.entries[${index}]`; + assertNetworkPolicyEntryShape(entry, path); + const manifest = requirePlanManifest(manifests, entry.channelId, `${path}.channelId`); + const expected = policyEntriesForManifest(manifest, plan.agent).find((candidate) => + networkPolicyEntryMatches(entry, candidate), + ); + if (!expected) fail(path, "policy entry is not declared by the channel manifest"); + }); +} + +function assertCredentialBindingShape( + binding: unknown, + path: string, +): asserts binding is SandboxMessagingCredentialBindingPlan { + const record = assertRecord(binding, path); + assertString(record.channelId, `${path}.channelId`); + assertString(record.credentialId, `${path}.credentialId`); + assertString(record.sourceInput, `${path}.sourceInput`); + assertString(record.providerName, `${path}.providerName`); + assertString(record.providerEnvKey, `${path}.providerEnvKey`); + assertString(record.placeholder, `${path}.placeholder`); + assertBoolean(record.credentialAvailable, `${path}.credentialAvailable`); + if (record.credentialHash !== undefined) + assertString(record.credentialHash, `${path}.credentialHash`); +} + +function assertNetworkPolicyEntryShape( + entry: unknown, + path: string, +): asserts entry is SandboxMessagingNetworkPolicyEntryPlan { + const record = assertRecord(entry, path); + assertString(record.channelId, `${path}.channelId`); + assertString(record.presetName, `${path}.presetName`); + assertStringArray(record.policyKeys, `${path}.policyKeys`); + if (record.source !== "agent-alias" && record.source !== "manifest") { + fail(`${path}.source`, "expected manifest or agent-alias"); + } +} diff --git a/src/lib/messaging/plan-validation/envelope-channel.ts b/src/lib/messaging/plan-validation/envelope-channel.ts new file mode 100644 index 0000000000..3b42c9943d --- /dev/null +++ b/src/lib/messaging/plan-validation/envelope-channel.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifestRegistry, + MessagingChannelId, + SandboxMessagingChannelPlan, + SandboxMessagingInputReference, + SandboxMessagingPlan, +} from "../manifest"; +import { + assertArray, + assertBoolean, + assertRecord, + assertSerializableValue, + assertString, + fail, + isAgent, + isWorkflow, +} from "./assertions"; +import type { PlanManifestMap } from "./types"; + +const AUTH_MODES = new Set(["none", "token-paste", "host-qr", "in-sandbox-qr"]); + +export function assertPlanEnvelope(value: unknown): SandboxMessagingPlan { + const plan = assertRecord(value, "$"); + if (plan.schemaVersion !== 1) fail("$.schemaVersion", "expected 1"); + assertString(plan.sandboxName, "$.sandboxName"); + if (!isAgent(plan.agent)) fail("$.agent", "expected supported messaging agent"); + if (!isWorkflow(plan.workflow)) fail("$.workflow", "expected supported messaging workflow"); + assertArray(plan.channels, "$.channels"); + assertArray(plan.disabledChannels, "$.disabledChannels"); + assertArray(plan.credentialBindings, "$.credentialBindings"); + const networkPolicy = assertRecord(plan.networkPolicy, "$.networkPolicy"); + assertArray(networkPolicy.presets, "$.networkPolicy.presets"); + assertArray(networkPolicy.entries, "$.networkPolicy.entries"); + assertArray(plan.agentRender, "$.agentRender"); + assertArray(plan.buildSteps, "$.buildSteps"); + assertArray(plan.stateUpdates, "$.stateUpdates"); + assertArray(plan.healthChecks, "$.healthChecks"); + return plan as unknown as SandboxMessagingPlan; +} + +export function validateChannels( + plan: SandboxMessagingPlan, + registry: ChannelManifestRegistry, + supportedChannelIds: readonly MessagingChannelId[] | undefined, +): PlanManifestMap { + const supported = + supportedChannelIds && supportedChannelIds.length > 0 ? new Set(supportedChannelIds) : null; + const manifests = new Map>(); + const seen = new Set(); + plan.channels.forEach((channel, index) => { + const path = `$.channels[${index}]`; + assertChannelShape(channel, path); + if (seen.has(channel.channelId)) fail(`${path}.channelId`, "duplicate channel id"); + seen.add(channel.channelId); + + const manifest = registry.get(channel.channelId); + if (!manifest) fail(`${path}.channelId`, "unknown messaging channel"); + if (!manifest.supportedAgents.includes(plan.agent)) { + fail(`${path}.channelId`, `channel is not supported for ${plan.agent}`); + } + if (supported && !supported.has(channel.channelId)) { + fail(`${path}.channelId`, `channel is not enabled for ${plan.agent}`); + } + if (channel.authMode !== manifest.auth.mode) { + fail(`${path}.authMode`, "does not match channel manifest"); + } + manifests.set(channel.channelId, manifest); + }); + return manifests as PlanManifestMap; +} + +export function validateDisabledChannels( + plan: SandboxMessagingPlan, + manifests: PlanManifestMap, +): void { + const seen = new Set(); + plan.disabledChannels.forEach((channelId, index) => { + const path = `$.disabledChannels[${index}]`; + assertString(channelId, path); + if (!manifests.has(channelId)) fail(path, "disabled channel is not in plan channels"); + if (seen.has(channelId)) fail(path, "duplicate disabled channel id"); + seen.add(channelId); + }); +} + +export function validateChannelInputs( + plan: SandboxMessagingPlan, + manifests: PlanManifestMap, +): void { + plan.channels.forEach((channel, channelIndex) => { + const manifest = manifests.get(channel.channelId); + if (!manifest) return; + const manifestInputs = new Map(manifest.inputs.map((input) => [input.id, input])); + channel.inputs.forEach((input, inputIndex) => { + const path = `$.channels[${channelIndex}].inputs[${inputIndex}]`; + assertInputShape(input, path); + if (input.channelId !== channel.channelId) { + fail(`${path}.channelId`, "input channel does not match parent channel"); + } + const manifestInput = manifestInputs.get(input.inputId); + if (manifestInput) { + if (input.kind !== manifestInput.kind) + fail(`${path}.kind`, "does not match manifest input"); + if (input.required !== manifestInput.required) { + fail(`${path}.required`, "does not match manifest input"); + } + if (input.sourceEnv !== undefined && input.sourceEnv !== manifestInput.envKey) { + fail(`${path}.sourceEnv`, "does not match manifest input env key"); + } + if (input.statePath !== undefined && input.statePath !== manifestInput.statePath) { + fail(`${path}.statePath`, "does not match manifest input state path"); + } + } + if (input.kind === "secret" && input.value !== undefined) { + fail(`${path}.value`, "secret input values must not be persisted"); + } + if (input.value !== undefined) assertSerializableValue(input.value, `${path}.value`); + }); + }); +} + +function assertChannelShape( + channel: unknown, + path: string, +): asserts channel is SandboxMessagingChannelPlan { + const record = assertRecord(channel, path); + assertString(record.channelId, `${path}.channelId`); + assertString(record.displayName, `${path}.displayName`); + if (typeof record.authMode !== "string" || !AUTH_MODES.has(record.authMode)) { + fail(`${path}.authMode`, "expected supported auth mode"); + } + assertBoolean(record.active, `${path}.active`); + assertBoolean(record.selected, `${path}.selected`); + assertBoolean(record.configured, `${path}.configured`); + assertBoolean(record.disabled, `${path}.disabled`); + assertArray(record.inputs, `${path}.inputs`); + assertArray(record.hooks, `${path}.hooks`); +} + +function assertInputShape( + input: unknown, + path: string, +): asserts input is SandboxMessagingInputReference { + const record = assertRecord(input, path); + assertString(record.channelId, `${path}.channelId`); + assertString(record.inputId, `${path}.inputId`); + if (record.kind !== "secret" && record.kind !== "config") { + fail(`${path}.kind`, "expected secret or config"); + } + assertBoolean(record.required, `${path}.required`); + if (record.sourceEnv !== undefined) assertString(record.sourceEnv, `${path}.sourceEnv`); + if (record.statePath !== undefined) assertString(record.statePath, `${path}.statePath`); + if (record.credentialAvailable !== undefined) { + assertBoolean(record.credentialAvailable, `${path}.credentialAvailable`); + } +} diff --git a/src/lib/messaging/plan-validation/hooks-health.ts b/src/lib/messaging/plan-validation/hooks-health.ts new file mode 100644 index 0000000000..a093b499d7 --- /dev/null +++ b/src/lib/messaging/plan-validation/hooks-health.ts @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistry } from "../hooks"; +import type { + SandboxMessagingHealthCheckPlan, + SandboxMessagingHookReferencePlan, + SandboxMessagingPlan, +} from "../manifest"; +import { + assertArray, + assertBoolean, + assertRecord, + assertString, + assertStringArray, + fail, +} from "./assertions"; +import { + healthCheckForManifest, + healthCheckMatches, + hooksEqual, + isHookForAgent, + requirePlanManifest, +} from "./manifest-matchers"; +import { assertHookHandlerRegistered } from "./registered-hooks"; +import type { PlanManifestMap } from "./types"; + +const HOOK_PHASES = new Set([ + "enroll", + "reachability-check", + "apply", + "post-agent-install", + "health-check", + "diagnostic", + "status", +]); + +export function validateChannelHooks( + plan: SandboxMessagingPlan, + manifests: PlanManifestMap, + hooks: MessagingHookRegistry, +): void { + plan.channels.forEach((channel, channelIndex) => { + const manifest = manifests.get(channel.channelId); + if (!manifest) return; + const expectedHooks = manifest.hooks.filter((hook) => isHookForAgent(hook, plan.agent)); + channel.hooks.forEach((hook, hookIndex) => { + const path = `$.channels[${channelIndex}].hooks[${hookIndex}]`; + assertHookShape(hook, path); + if (hook.channelId !== channel.channelId) { + fail(`${path}.channelId`, "hook channel does not match parent channel"); + } + const expected = expectedHooks.find((candidate) => hooksEqual(hook, candidate)); + if (!expected) fail(path, "hook is not declared by the channel manifest"); + assertHookHandlerRegistered(hooks, hook.handler, `${path}.handler`); + }); + }); +} + +export function validateHealthChecks( + plan: SandboxMessagingPlan, + manifests: PlanManifestMap, + hooks: MessagingHookRegistry, +): void { + plan.healthChecks.forEach((check, index) => { + const path = `$.healthChecks[${index}]`; + assertHealthCheckShape(check, path); + const manifest = requirePlanManifest(manifests, check.channelId, `${path}.channelId`); + const expected = healthCheckForManifest(manifest); + if (!healthCheckMatches(check, expected)) { + fail(path, "health check is not declared by the channel manifest"); + } + const manifestHooks = new Map(manifest.hooks.map((hook) => [hook.id, hook])); + check.hookIds.forEach((hookId, hookIndex) => { + const hook = manifestHooks.get(hookId); + if (hook) assertHookHandlerRegistered(hooks, hook.handler, `${path}.hookIds[${hookIndex}]`); + }); + }); +} + +function assertHookShape( + hook: unknown, + path: string, +): asserts hook is SandboxMessagingHookReferencePlan { + const record = assertRecord(hook, path); + assertString(record.channelId, `${path}.channelId`); + assertString(record.id, `${path}.id`); + if (typeof record.phase !== "string" || !HOOK_PHASES.has(record.phase)) { + fail(`${path}.phase`, "expected supported hook phase"); + } + assertString(record.handler, `${path}.handler`); + if (record.agents !== undefined) assertStringArray(record.agents, `${path}.agents`); + if (record.inputs !== undefined) assertStringArray(record.inputs, `${path}.inputs`); + if (record.outputs !== undefined) { + assertArray(record.outputs, `${path}.outputs`); + record.outputs.forEach((output, index) => { + const outputPath = `${path}.outputs[${index}]`; + const outputRecord = assertRecord(output, outputPath); + assertString(outputRecord.id, `${outputPath}.id`); + if ( + outputRecord.kind !== "secret" && + outputRecord.kind !== "config" && + outputRecord.kind !== "build-arg" && + outputRecord.kind !== "build-file" + ) { + fail(`${outputPath}.kind`, "expected supported hook output kind"); + } + if (outputRecord.required !== undefined) { + assertBoolean(outputRecord.required, `${outputPath}.required`); + } + }); + } + if ( + record.onFailure !== undefined && + record.onFailure !== "abort" && + record.onFailure !== "skip-channel" + ) { + fail(`${path}.onFailure`, "expected supported failure mode"); + } +} + +function assertHealthCheckShape( + check: unknown, + path: string, +): asserts check is SandboxMessagingHealthCheckPlan { + const record = assertRecord(check, path); + assertString(record.channelId, `${path}.channelId`); + if (record.phase !== "health-check") fail(`${path}.phase`, "expected health-check"); + if (record.requiredBefore !== "lifecycle-success") { + fail(`${path}.requiredBefore`, "expected lifecycle-success"); + } + assertStringArray(record.hookIds, `${path}.hookIds`); +} diff --git a/src/lib/messaging/plan-validation/index.test.ts b/src/lib/messaging/plan-validation/index.test.ts new file mode 100644 index 0000000000..237e058eeb --- /dev/null +++ b/src/lib/messaging/plan-validation/index.test.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { MessagingWorkflowPlanner } from "../compiler"; +import { createBuiltInMessagingHookRegistry } from "../hooks"; +import type { SandboxMessagingPlan } from "../manifest"; +import { assertValidSandboxMessagingPlan } from "."; + +const registry = createBuiltInChannelManifestRegistry(); +const TEST_WECHAT_LOGIN = { + token: "test-wechat-token", + accountId: "test-wechat-account", + baseUrl: "https://ilinkai.wechat.example", + userId: "test-wechat-user", +} as const; + +type MutablePlan = SandboxMessagingPlan & { + disabledChannels: string[]; + credentialBindings: Array<{ providerEnvKey: string }>; + networkPolicy: { entries: Array<{ policyKeys: string[] }> }; + agentRender: Array<{ target: string }>; + buildSteps: Array<{ handler: string }>; + healthChecks: Array<{ hookIds: string[] }>; +}; + +function planner(): MessagingWorkflowPlanner { + return new MessagingWorkflowPlanner( + registry, + createBuiltInMessagingHookRegistry({ + common: { + env: {}, + getCredential: () => null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + slack: { + validateCredentials: { + log: () => {}, + validateCredentials: () => ({ ok: true }), + }, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + wechat: { + ilinkLogin: { + env: {}, + log: () => {}, + saveCredential: () => {}, + runLogin: async () => ({ + kind: "ok", + credentials: TEST_WECHAT_LOGIN, + }), + }, + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }), + ); +} + +function cloneMutablePlan(plan: SandboxMessagingPlan): MutablePlan { + return JSON.parse(JSON.stringify(plan)) as MutablePlan; +} + +async function validPlan(): Promise { + return planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram", "slack", "wechat"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + WECHAT_BOT_TOKEN: true, + }, + }); +} + +function expectInvalid(plan: MutablePlan, reason: string): void { + expect(() => + assertValidSandboxMessagingPlan(plan, { + registry, + sandboxName: "demo", + agent: "openclaw", + }), + ).toThrow(reason); +} + +describe("SandboxMessagingPlan validation boundaries", () => { + it("rejects disabled channels that are outside plan channels", async () => { + const plan = cloneMutablePlan(await validPlan()); + plan.disabledChannels = ["discord"]; + + expectInvalid(plan, "disabled channel is not in plan channels"); + }); + + it("rejects credential bindings that no longer match the manifest", async () => { + const plan = cloneMutablePlan(await validPlan()); + plan.credentialBindings[0].providerEnvKey = "OTHER_TOKEN"; + + expectInvalid(plan, "credential binding is not declared by the channel manifest"); + }); + + it("rejects policy entries that no longer match the manifest", async () => { + const plan = cloneMutablePlan(await validPlan()); + plan.networkPolicy.entries[0].policyKeys = ["wildcard"]; + + expectInvalid(plan, "policy entry is not declared by the channel manifest"); + }); + + it("rejects render entries that no longer match the manifest", async () => { + const plan = cloneMutablePlan(await validPlan()); + plan.agentRender[0].target = "other.json"; + + expectInvalid(plan, "render entry is not declared by the channel manifest"); + }); + + it("rejects build steps that no longer match a registered hook", async () => { + const plan = cloneMutablePlan(await validPlan()); + plan.buildSteps[0].handler = "missing.handler"; + + expectInvalid(plan, "build step is not declared by the channel manifest"); + }); + + it("rejects health checks that no longer match the manifest", async () => { + const plan = cloneMutablePlan(await validPlan()); + plan.healthChecks[0].hookIds = ["missing-health-hook"]; + + expectInvalid(plan, "health check is not declared by the channel manifest"); + }); +}); diff --git a/src/lib/messaging/plan-validation/index.ts b/src/lib/messaging/plan-validation/index.ts new file mode 100644 index 0000000000..9b05d1ebd0 --- /dev/null +++ b/src/lib/messaging/plan-validation/index.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { BUILT_IN_MESSAGING_HOOK_REGISTRY } from "../hooks"; +import type { SandboxMessagingPlan } from "../manifest"; +import { fail } from "./assertions"; +import { validateCredentialBindings, validateNetworkPolicy } from "./credentials-policy"; +import { + assertPlanEnvelope, + validateChannelInputs, + validateChannels, + validateDisabledChannels, +} from "./envelope-channel"; +import { validateChannelHooks, validateHealthChecks } from "./hooks-health"; +import { + validateAgentRender, + validateBuildSteps, + validateStateUpdates, +} from "./render-build-state"; +import type { SandboxMessagingPlanValidationOptions } from "./types"; + +export type { SandboxMessagingPlanValidationOptions } from "./types"; + +export function parseValidSandboxMessagingPlan( + value: unknown, + options: SandboxMessagingPlanValidationOptions = {}, +): SandboxMessagingPlan | null { + try { + assertValidSandboxMessagingPlan(value, options); + return value; + } catch { + return null; + } +} + +export function validateSandboxMessagingPlan( + value: unknown, + options: SandboxMessagingPlanValidationOptions = {}, +): value is SandboxMessagingPlan { + return parseValidSandboxMessagingPlan(value, options) !== null; +} + +export function assertValidSandboxMessagingPlan( + value: unknown, + options: SandboxMessagingPlanValidationOptions = {}, +): asserts value is SandboxMessagingPlan { + const plan = assertPlanEnvelope(value); + if (options.sandboxName !== undefined && plan.sandboxName !== options.sandboxName) { + fail("$.sandboxName", `expected '${options.sandboxName}'`); + } + if (options.agent !== undefined && plan.agent !== options.agent) { + fail("$.agent", `expected '${options.agent}'`); + } + + const registry = options.registry ?? createBuiltInChannelManifestRegistry(); + const hooks = options.hooks ?? BUILT_IN_MESSAGING_HOOK_REGISTRY; + const manifests = validateChannels(plan, registry, options.supportedChannelIds); + validateDisabledChannels(plan, manifests); + validateChannelInputs(plan, manifests); + validateChannelHooks(plan, manifests, hooks); + validateCredentialBindings(plan, manifests); + validateNetworkPolicy(plan, manifests); + validateAgentRender(plan, manifests); + validateBuildSteps(plan, manifests, hooks); + validateStateUpdates(plan, manifests); + validateHealthChecks(plan, manifests, hooks); +} diff --git a/src/lib/messaging/plan-validation/manifest-matchers.ts b/src/lib/messaging/plan-validation/manifest-matchers.ts new file mode 100644 index 0000000000..ebae6e8207 --- /dev/null +++ b/src/lib/messaging/plan-validation/manifest-matchers.ts @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + collectTemplateReferencesInLines, + collectTemplateReferencesInValue, + resolveCredentialTemplatesInLines, + resolveCredentialTemplatesInValue, + resolveSandboxNameTemplate, +} from "../compiler/engines/template"; +import type { + ChannelCredentialSpec, + ChannelHookOutputSpec, + ChannelHookSpec, + ChannelManifest, + ChannelPolicyPresetReference, + ChannelPolicyPresetSpec, + MessagingAgentId, + MessagingChannelId, + SandboxMessagingAgentRenderPlan, + SandboxMessagingBuildStepPlan, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingHealthCheckPlan, + SandboxMessagingHookReferencePlan, + SandboxMessagingNetworkPolicyEntryPlan, + SandboxMessagingPlan, + SandboxMessagingStateUpdatePlan, +} from "../manifest"; +import { fail, jsonEqual, optionalStringArraysEqual, stringArraysEqual } from "./assertions"; +import type { PlanManifestMap } from "./types"; + +export function credentialBindingMatches( + plan: SandboxMessagingPlan, + binding: SandboxMessagingCredentialBindingPlan, + credential: ChannelCredentialSpec, +): boolean { + return ( + binding.credentialId === credential.id && + binding.sourceInput === credential.sourceInput && + binding.providerName === + resolveSandboxNameTemplate(credential.providerName, plan.sandboxName) && + binding.providerEnvKey === credential.providerEnvKey && + binding.placeholder === credential.placeholder + ); +} + +export function policyEntriesForManifest( + manifest: ChannelManifest, + agent: MessagingAgentId, +): SandboxMessagingNetworkPolicyEntryPlan[] { + return (manifest.policyPresets ?? []).map((preset) => { + const policy = normalizePolicyPreset(preset); + const agentPolicyKeys = policy.agentPolicyKeys?.[agent]; + if (agentPolicyKeys) { + return { + channelId: manifest.id, + presetName: policy.name, + policyKeys: agentPolicyKeys, + source: "agent-alias", + }; + } + return { + channelId: manifest.id, + presetName: policy.name, + policyKeys: policy.policyKeys ?? [policy.name], + source: "manifest", + }; + }); +} + +export function renderEntriesForManifest( + manifest: ChannelManifest, + agent: MessagingAgentId, +): SandboxMessagingAgentRenderPlan[] { + return manifest.render + .filter((render) => render.agent === agent) + .map((render) => { + if (render.kind === "json-fragment") { + const value = resolveCredentialTemplatesInValue( + render.fragment.value, + manifest.credentials, + ); + return { + channelId: manifest.id, + renderId: render.id, + kind: "json-fragment", + agent: render.agent, + target: render.target, + path: render.fragment.path, + value, + templateRefs: collectTemplateReferencesInValue(value), + }; + } + const lines = resolveCredentialTemplatesInLines(render.lines, manifest.credentials); + return { + channelId: manifest.id, + renderId: render.id, + kind: "env-lines", + agent: render.agent, + target: render.target, + lines, + templateRefs: collectTemplateReferencesInLines(lines), + }; + }); +} + +export function buildStepsForManifest( + manifest: ChannelManifest, + agent: MessagingAgentId, +): SandboxMessagingBuildStepPlan[] { + return manifest.hooks.flatMap((hook) => { + if (!isHookForAgent(hook, agent)) return []; + return (hook.outputs ?? []).filter(isBuildStepOutput).map((output) => ({ + channelId: manifest.id, + kind: output.kind, + hookId: hook.id, + handler: hook.handler, + outputId: output.id, + required: output.required === true, + })); + }); +} + +export function stateUpdatesForManifest( + manifest: ChannelManifest, +): SandboxMessagingStateUpdatePlan[] { + const persistUpdates = Object.entries(manifest.state.persist ?? {}).map( + ([stateKey, inputIds]) => ({ + channelId: manifest.id, + kind: "persist-inputs" as const, + stateKey, + inputIds, + }), + ); + const hydrationUpdates = (manifest.state.rebuildHydration ?? []).map((hydration) => ({ + channelId: manifest.id, + kind: "rebuild-hydration" as const, + statePath: hydration.statePath, + env: hydration.env, + })); + return [...persistUpdates, ...hydrationUpdates]; +} + +export function healthCheckForManifest(manifest: ChannelManifest): SandboxMessagingHealthCheckPlan { + return { + channelId: manifest.id, + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: manifest.hooks.filter((hook) => hook.phase === "health-check").map((hook) => hook.id), + }; +} + +export function normalizePolicyPreset( + preset: ChannelPolicyPresetReference, +): ChannelPolicyPresetSpec { + return typeof preset === "string" ? { name: preset } : preset; +} + +export function requirePlanManifest( + manifests: PlanManifestMap, + channelId: MessagingChannelId, + path: string, +): ChannelManifest { + const manifest = manifests.get(channelId); + if (!manifest) fail(path, "entry channel is not in plan channels"); + return manifest; +} + +export function isHookForAgent(hook: ChannelHookSpec, agent: MessagingAgentId): boolean { + return !hook.agents || hook.agents.includes(agent); +} + +export function hooksEqual( + planHook: SandboxMessagingHookReferencePlan, + manifestHook: ChannelHookSpec, +): boolean { + return ( + planHook.id === manifestHook.id && + planHook.phase === manifestHook.phase && + planHook.handler === manifestHook.handler && + optionalStringArraysEqual(planHook.agents, manifestHook.agents) && + optionalStringArraysEqual(planHook.inputs, manifestHook.inputs) && + hookOutputsEqual(planHook.outputs, manifestHook.outputs) && + planHook.onFailure === manifestHook.onFailure + ); +} + +export function networkPolicyEntryMatches( + entry: SandboxMessagingNetworkPolicyEntryPlan, + expected: SandboxMessagingNetworkPolicyEntryPlan, +): boolean { + return ( + entry.channelId === expected.channelId && + entry.presetName === expected.presetName && + entry.source === expected.source && + stringArraysEqual(entry.policyKeys, expected.policyKeys) + ); +} + +export function renderEntryMatches( + render: SandboxMessagingAgentRenderPlan, + expected: SandboxMessagingAgentRenderPlan, +): boolean { + if ( + render.channelId !== expected.channelId || + render.renderId !== expected.renderId || + render.kind !== expected.kind || + render.agent !== expected.agent || + render.target !== expected.target + ) { + return false; + } + if (render.kind === "json-fragment" && expected.kind === "json-fragment") { + return ( + render.path === expected.path && + jsonEqual(render.value, expected.value) && + stringArraysEqual(render.templateRefs, expected.templateRefs) + ); + } + if (render.kind === "env-lines" && expected.kind === "env-lines") { + return ( + stringArraysEqual(render.lines, expected.lines) && + stringArraysEqual(render.templateRefs, expected.templateRefs) + ); + } + return false; +} + +export function buildStepMatches( + step: SandboxMessagingBuildStepPlan, + expected: SandboxMessagingBuildStepPlan, +): boolean { + return ( + step.channelId === expected.channelId && + step.kind === expected.kind && + step.hookId === expected.hookId && + step.handler === expected.handler && + step.outputId === expected.outputId && + step.required === expected.required + ); +} + +export function stateUpdateMatches( + update: SandboxMessagingStateUpdatePlan, + expected: SandboxMessagingStateUpdatePlan, +): boolean { + if (update.channelId !== expected.channelId || update.kind !== expected.kind) return false; + if (update.kind === "persist-inputs" && expected.kind === "persist-inputs") { + return ( + update.stateKey === expected.stateKey && stringArraysEqual(update.inputIds, expected.inputIds) + ); + } + if (update.kind === "rebuild-hydration" && expected.kind === "rebuild-hydration") { + return update.statePath === expected.statePath && update.env === expected.env; + } + return false; +} + +export function healthCheckMatches( + check: SandboxMessagingHealthCheckPlan, + expected: SandboxMessagingHealthCheckPlan, +): boolean { + return ( + check.channelId === expected.channelId && + check.phase === expected.phase && + check.requiredBefore === expected.requiredBefore && + stringArraysEqual(check.hookIds, expected.hookIds) + ); +} + +function hookOutputsEqual( + left: SandboxMessagingHookReferencePlan["outputs"], + right: ChannelHookSpec["outputs"], +): boolean { + if (left === undefined || right === undefined) return left === right; + if (left.length !== right.length) return false; + return left.every((output, index) => { + const expected = right[index]; + return ( + expected !== undefined && + output.id === expected.id && + output.kind === expected.kind && + output.required === expected.required + ); + }); +} + +function isBuildStepOutput( + output: ChannelHookOutputSpec, +): output is ChannelHookOutputSpec & { readonly kind: "build-arg" | "build-file" } { + return output.kind === "build-arg" || output.kind === "build-file"; +} diff --git a/src/lib/messaging/plan-validation/registered-hooks.ts b/src/lib/messaging/plan-validation/registered-hooks.ts new file mode 100644 index 0000000000..4186be68ef --- /dev/null +++ b/src/lib/messaging/plan-validation/registered-hooks.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistry } from "../hooks"; +import { fail } from "./assertions"; + +export function assertHookHandlerRegistered( + hooks: MessagingHookRegistry, + handler: string, + path: string, +): void { + if (!hooks.get(handler)) fail(path, "hook handler is not registered"); +} diff --git a/src/lib/messaging/plan-validation/render-build-state.ts b/src/lib/messaging/plan-validation/render-build-state.ts new file mode 100644 index 0000000000..af472b62a0 --- /dev/null +++ b/src/lib/messaging/plan-validation/render-build-state.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistry } from "../hooks"; +import type { + SandboxMessagingAgentRenderPlan, + SandboxMessagingBuildStepPlan, + SandboxMessagingPlan, + SandboxMessagingStateUpdatePlan, +} from "../manifest"; +import { + assertBoolean, + assertRecord, + assertSerializableValue, + assertString, + assertStringArray, + fail, + isAgent, +} from "./assertions"; +import { + buildStepMatches, + buildStepsForManifest, + renderEntriesForManifest, + renderEntryMatches, + requirePlanManifest, + stateUpdateMatches, + stateUpdatesForManifest, +} from "./manifest-matchers"; +import { assertHookHandlerRegistered } from "./registered-hooks"; +import type { PlanManifestMap } from "./types"; + +export function validateAgentRender(plan: SandboxMessagingPlan, manifests: PlanManifestMap): void { + plan.agentRender.forEach((render, index) => { + const path = `$.agentRender[${index}]`; + assertAgentRenderShape(render, path); + const manifest = requirePlanManifest(manifests, render.channelId, `${path}.channelId`); + const expected = renderEntriesForManifest(manifest, plan.agent).find((candidate) => + renderEntryMatches(render, candidate), + ); + if (!expected) fail(path, "render entry is not declared by the channel manifest"); + }); +} + +export function validateBuildSteps( + plan: SandboxMessagingPlan, + manifests: PlanManifestMap, + hooks: MessagingHookRegistry, +): void { + plan.buildSteps.forEach((step, index) => { + const path = `$.buildSteps[${index}]`; + assertBuildStepShape(step, path); + const manifest = requirePlanManifest(manifests, step.channelId, `${path}.channelId`); + const expected = buildStepsForManifest(manifest, plan.agent).find((candidate) => + buildStepMatches(step, candidate), + ); + if (!expected) fail(path, "build step is not declared by the channel manifest"); + assertHookHandlerRegistered(hooks, step.handler, `${path}.handler`); + }); +} + +export function validateStateUpdates(plan: SandboxMessagingPlan, manifests: PlanManifestMap): void { + plan.stateUpdates.forEach((update, index) => { + const path = `$.stateUpdates[${index}]`; + assertStateUpdateShape(update, path); + const manifest = requirePlanManifest(manifests, update.channelId, `${path}.channelId`); + const expected = stateUpdatesForManifest(manifest).find((candidate) => + stateUpdateMatches(update, candidate), + ); + if (!expected) fail(path, "state update is not declared by the channel manifest"); + }); +} + +function assertAgentRenderShape( + render: unknown, + path: string, +): asserts render is SandboxMessagingAgentRenderPlan { + const record = assertRecord(render, path); + assertString(record.channelId, `${path}.channelId`); + if (record.renderId !== undefined) assertString(record.renderId, `${path}.renderId`); + if (!isAgent(record.agent)) fail(`${path}.agent`, "expected supported messaging agent"); + assertString(record.target, `${path}.target`); + if (record.kind === "json-fragment") { + assertString(record.path, `${path}.path`); + assertSerializableValue(record.value, `${path}.value`); + assertStringArray(record.templateRefs, `${path}.templateRefs`); + return; + } + if (record.kind === "env-lines") { + assertStringArray(record.lines, `${path}.lines`); + assertStringArray(record.templateRefs, `${path}.templateRefs`); + return; + } + fail(`${path}.kind`, "expected supported render kind"); +} + +function assertBuildStepShape( + step: unknown, + path: string, +): asserts step is SandboxMessagingBuildStepPlan { + const record = assertRecord(step, path); + assertString(record.channelId, `${path}.channelId`); + if (record.kind !== "build-arg" && record.kind !== "build-file") { + fail(`${path}.kind`, "expected build-arg or build-file"); + } + assertString(record.hookId, `${path}.hookId`); + assertString(record.handler, `${path}.handler`); + assertString(record.outputId, `${path}.outputId`); + assertBoolean(record.required, `${path}.required`); +} + +function assertStateUpdateShape( + update: unknown, + path: string, +): asserts update is SandboxMessagingStateUpdatePlan { + const record = assertRecord(update, path); + assertString(record.channelId, `${path}.channelId`); + if (record.kind === "persist-inputs") { + assertString(record.stateKey, `${path}.stateKey`); + assertStringArray(record.inputIds, `${path}.inputIds`); + return; + } + if (record.kind === "rebuild-hydration") { + assertString(record.statePath, `${path}.statePath`); + assertString(record.env, `${path}.env`); + return; + } + fail(`${path}.kind`, "expected supported state update kind"); +} diff --git a/src/lib/messaging/plan-validation/types.ts b/src/lib/messaging/plan-validation/types.ts new file mode 100644 index 0000000000..803a350792 --- /dev/null +++ b/src/lib/messaging/plan-validation/types.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistry } from "../hooks"; +import type { + ChannelManifest, + ChannelManifestRegistry, + MessagingAgentId, + MessagingChannelId, +} from "../manifest"; + +export interface SandboxMessagingPlanValidationOptions { + readonly registry?: ChannelManifestRegistry; + readonly hooks?: MessagingHookRegistry; + readonly sandboxName?: string; + readonly agent?: MessagingAgentId; + readonly supportedChannelIds?: readonly MessagingChannelId[]; +} + +export type PlanManifestMap = ReadonlyMap; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 95248e8f54..87e6614a15 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -401,6 +401,8 @@ const { }: typeof import("./onboard/session-updates") = require("./onboard/session-updates"); const gatewayReuse: typeof import("./onboard/gateway-reuse") = require("./onboard/gateway-reuse"); const messagingConfig: typeof import("./onboard/messaging-config") = require("./onboard/messaging-config"); +const messagingPlanSession: typeof import("./onboard/messaging-plan-session") = + require("./onboard/messaging-plan-session"); const { detectMessagingCredentialRotation, getMessagingChannelForEnvKey, @@ -411,8 +413,8 @@ const { computeTelegramRequireMention, getStoredMessagingChannelConfig, messagingChannelConfigsEqual, - persistMessagingChannelConfigToSession, } = messagingConfig; +const { getActiveChannelsFromPlan } = messagingPlanSession; const sandboxAgent: typeof import("./onboard/sandbox-agent") = require("./onboard/sandbox-agent"); const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require("./onboard/sandbox-lifecycle"); const sandboxRegistryMetadata: typeof import("./onboard/sandbox-registry-metadata") = require("./onboard/sandbox-registry-metadata"); @@ -2923,17 +2925,8 @@ async function createSandbox( const hasPlanCredentials = currentPlan?.credentialBindings.some((b) => b.credentialAvailable) ?? false; if (hasPlanCredentials) { - const { - backfillMessagingChannels, - findChannelConflictsFromPlan, - createMessagingConflictProbe, - } = require("./messaging/applier") as typeof import("./messaging/applier"); - const probe = createMessagingConflictProbe({ - checkGatewayLiveness: () => - runOpenshell(["sandbox", "list"], { ignoreError: true, suppressOutput: true }).status === 0, - providerExists: (name) => providerExistsInGateway(name), - }); - backfillMessagingChannels(registry, probe); + const { findChannelConflictsFromPlan } = + require("./messaging/applier") as typeof import("./messaging/applier"); const conflicts = findChannelConflictsFromPlan(sandboxName, currentPlan!, registry); if (conflicts.length > 0) { for (const { channel, sandbox, reason } of conflicts) { @@ -3571,7 +3564,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 @@ -3890,16 +3882,7 @@ async function createSandbox( ...getSandboxAgentRegistryFields(agent, !fromDockerfile), imageTag: resolvedImageTag, policies: 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. - messagingChannels: - enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels, - messagingChannelConfig: messagingChannelConfig || undefined, messaging: messagingState, - disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, hermesToolGateways: hermesToolGateways.length > 0 ? [...hermesToolGateways] : undefined, ...onboardHermesDashboard.getHermesDashboardRegistryFields(finalHermesDashboardState), dashboardPort: actualDashboardPort, @@ -4554,10 +4537,9 @@ async function setupNim( // Check raw process.env — the override must apply before resolving from credentials.json. const _providerKeyHint = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim(); if (_providerKeyHint && credentialEnv) { - const existingCredentialKey = normalizeCredentialValue( - // check-direct-credential-env-ignore -- intentional: checking if env is already set before applying NEMOCLAW_PROVIDER_KEY override - process.env[credentialEnv] ?? "", - ); + // check-direct-credential-env-ignore -- intentional: checking raw env before applying NEMOCLAW_PROVIDER_KEY override + const existingCredentialValue = process.env[credentialEnv] ?? ""; + const existingCredentialKey = normalizeCredentialValue(existingCredentialValue); if (!existingCredentialKey) { process.env[credentialEnv] = _providerKeyHint; } @@ -5427,7 +5409,7 @@ function getRecordedMessagingChannelsForResume( ): string[] | null { return getRecordedMessagingChannelsForResumeFromState({ resume, - sessionMessagingChannels: session?.messagingChannels, + sessionMessagingChannels: getActiveChannelsFromPlan(session?.messagingPlan), sandboxName, channels: MESSAGING_CHANNELS, getCredential, @@ -6614,7 +6596,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { getStoredMessagingChannelConfig, hydrateMessagingChannelConfig, messagingChannelConfigsEqual, - persistMessagingChannelConfigToSession, getSandboxReuseState, computeTelegramRequireMention, hasSandboxGpuDrift, @@ -6629,9 +6610,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { configureWebSearch, startRecordedStep, getRecordedMessagingChannelsForResume, - getSandboxMessagingChannels: (name) => registry.getSandbox(name)?.messagingChannels, + getSandboxMessagingChannels: (name) => registry.getConfiguredMessagingChannels(name), 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..bb3a461ae1 100644 --- a/src/lib/onboard/channel-state.test.ts +++ b/src/lib/onboard/channel-state.test.ts @@ -3,39 +3,60 @@ import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import { resolveDisabledChannels } from "./channel-state"; +function plan(sandboxName: string, disabledChannels: readonly string[]): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "rebuild", + channels: [], + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + describe("onboard channel state helpers", () => { - it("prefers disabledChannels from the onboard session mirror", () => { + it("prefers disabledChannels from a matching env plan", () => { const getRegistryDisabledChannels = vi.fn(() => ["discord"]); expect( resolveDisabledChannels("alpha", { - loadSession: () => ({ disabledChannels: ["telegram"] }), + readMessagingPlanFromEnv: () => plan("alpha", ["telegram"]), + loadSession: () => null, getRegistryDisabledChannels, }), ).toEqual(["telegram"]); expect(getRegistryDisabledChannels).not.toHaveBeenCalled(); }); - it("falls back to the registry when the session has no mirror", () => { + it("falls back to a matching session plan when env has no matching plan", () => { + const getRegistryDisabledChannels = vi.fn(() => ["discord"]); + expect( resolveDisabledChannels("alpha", { - loadSession: () => ({ disabledChannels: null }), - getRegistryDisabledChannels: (sandboxName) => (sandboxName === "alpha" ? ["discord"] : []), + readMessagingPlanFromEnv: () => plan("other", ["slack"]), + loadSession: () => ({ sandboxName: "alpha", messagingPlan: plan("alpha", ["telegram"]) }), + getRegistryDisabledChannels, }), - ).toEqual(["discord"]); + ).toEqual(["telegram"]); + expect(getRegistryDisabledChannels).not.toHaveBeenCalled(); }); - it("treats an empty session mirror as authoritative", () => { - const getRegistryDisabledChannels = vi.fn(() => ["telegram"]); - + it("falls back to the registry when no matching plan exists", () => { expect( resolveDisabledChannels("alpha", { - loadSession: () => ({ disabledChannels: [] }), - getRegistryDisabledChannels, + readMessagingPlanFromEnv: () => null, + loadSession: () => ({ sandboxName: "other", messagingPlan: plan("other", []) }), + getRegistryDisabledChannels: (sandboxName) => (sandboxName === "alpha" ? ["discord"] : []), }), - ).toEqual([]); - expect(getRegistryDisabledChannels).not.toHaveBeenCalled(); + ).toEqual(["discord"]); }); }); diff --git a/src/lib/onboard/channel-state.ts b/src/lib/onboard/channel-state.ts index 641ffaff38..10880ae87d 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 { MessagingSetupApplier } from "../messaging/applier"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; -type DisabledChannelsSession = Pick; +type DisabledChannelsSession = Pick; export type DisabledChannelsDeps = { loadSession: () => DisabledChannelsSession | null; + readMessagingPlanFromEnv?: () => SandboxMessagingPlan | null; getRegistryDisabledChannels: (sandboxName: string) => string[]; }; @@ -15,12 +18,17 @@ export function resolveDisabledChannels( sandboxName: string, deps?: DisabledChannelsDeps, ): string[] { + const envPlan = deps?.readMessagingPlanFromEnv + ? deps.readMessagingPlanFromEnv() + : MessagingSetupApplier.readPlanFromEnv(); + if (envPlan?.sandboxName === sandboxName) return [...envPlan.disabledChannels]; + // `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; + // createSandbox, so the session plan carries paused channels across that + // destroy/recreate window. + const session = (deps?.loadSession ?? onboardSession.loadSession)(); + if (session?.messagingPlan?.sandboxName === sandboxName) { + return [...session.messagingPlan.disabledChannels]; } 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..12620b88d9 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, @@ -161,7 +160,6 @@ function createPhases( 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..6dcea8a10d 100644 --- a/src/lib/onboard/machine/events.ts +++ b/src/lib/onboard/machine/events.ts @@ -4,6 +4,7 @@ import type { JsonObject, JsonValue } from "../../core/json-types"; import { redactSensitiveText, redactUrl } from "../../security/redact"; import type { HermesAuthMethod, Session } from "../../state/onboard-session"; +import { getActiveChannelsFromPlan } from "../messaging-plan-session"; import { ONBOARD_MACHINE_STATE_DEFINITIONS, type OnboardMachineStateWithStepDefinition, @@ -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..cf237e01a3 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -3,17 +3,49 @@ import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import { handlePoliciesState, type PoliciesStateOptions } from "./policies"; type Agent = { name: string } | null; type WebSearchConfig = { fetchEnabled: true }; +function messagingPlan( + channels: readonly string[], + disabledChannels: readonly string[] = [], +): SandboxMessagingPlan { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName: "my-assistant", + 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: messagingPlan(["telegram"]) } })), mergeChannels: vi.fn( (selected: string[], recorded: string[], active: string[] | null | undefined) => selected.length > 0 ? selected : (active ?? recorded), @@ -136,9 +168,9 @@ describe("handlePoliciesState", () => { }); it("uses recorded messaging channels when no active selection exists", async () => { - const session = createSession({ messagingChannels: ["slack"] }); + const session = createSession({ messagingPlan: messagingPlan(["slack"]) }); const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ messagingChannels: null, disabledChannels: null })), + getActiveSandbox: vi.fn(() => ({})), }); setSession(session); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index e1f16d57a0..6cae41e281 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 activeSandboxPlan = activeSandbox?.messaging?.plan; + const activeMessagingChannels = getActiveChannelsFromPlan(activeSandboxPlan); + const disabledChannels = getDisabledChannelsFromPlan(activeSandboxPlan); 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-messaging-plan.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging-plan.test.ts new file mode 100644 index 0000000000..b63a429bae --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-messaging-plan.test.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { createSession } from "../../../state/onboard-session"; +import { handleSandboxState } from "./sandbox"; +import { + baseOptions, + createDeps, + makeMinimalPlan, +} from "../../../../../test/helpers/sandbox-handler-fixtures"; + +describe("handleSandboxState messaging plans", () => { + it("uses recorded messaging channels on non-interactive resume", async () => { + const getRecordedMessagingChannelsForResume = vi.fn(() => ["discord"]); + const { deps, calls } = createDeps({ + getRecordedMessagingChannelsForResume, + }); + + const result = await handleSandboxState({ + ...baseOptions(deps), + resume: true, + }); + + expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(getRecordedMessagingChannelsForResume).toHaveBeenCalledWith( + true, + expect.any(Object), + "my-assistant", + ); + expect(calls.note).toHaveBeenCalledWith( + " [non-interactive] Reusing messaging channel configuration: discord", + ); + expect(result.selectedMessagingChannels).toEqual(["discord"]); + }); + + it("persists plan from env into session after fresh messaging setup", async () => { + const mockPlan = makeMinimalPlan("my-assistant"); + const { deps, getSession } = createDeps({ + readMessagingPlanFromEnv: () => mockPlan, + }); + + await handleSandboxState({ ...baseOptions(deps) }); + + expect(getSession().messagingPlan).toEqual(mockPlan); + }); + + it("restores registry plan to env on non-interactive resume when env is empty", async () => { + const registryPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ + sandboxName: "my-assistant", + messagingPlan: makeMinimalPlan("my-assistant", "openclaw", ["telegram"]), + }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => registryPlan, + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(writePlanToEnv).toHaveBeenCalledWith(registryPlan); + }); + + it("prefers env-staged plan over registry plan on non-interactive resume", async () => { + const registryPlan = makeMinimalPlan("my-assistant"); + const rebuiltPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ + sandboxName: "my-assistant", + messagingPlan: makeMinimalPlan("my-assistant", "openclaw", ["telegram"]), + }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => rebuiltPlan, + getRegistrySandboxMessagingPlan: () => registryPlan, + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(rebuiltPlan); + }); + + it("preserves an env-staged empty plan on non-interactive resume", async () => { + const emptyPlan = makeMinimalPlan("my-assistant"); + const session = createSession({ + sandboxName: "my-assistant", + messagingPlan: emptyPlan, + }); + const getRecordedMessagingChannelsForResume = vi.fn(() => [] as string[]); + const { deps, calls, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + readMessagingPlanFromEnv: () => emptyPlan, + }); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(result.selectedMessagingChannels).toEqual([]); + expect(getSession().messagingPlan).toEqual(emptyPlan); + }); + + it("does not restore plan to env when registry has no entry", async () => { + const session = createSession({ + sandboxName: "my-assistant", + messagingPlan: makeMinimalPlan("my-assistant", "openclaw", ["telegram"]), + }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => null, + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(writePlanToEnv).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 892709b9af..ba2e090894 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -3,175 +3,20 @@ import { describe, expect, it, vi } from "vitest"; -import type { SandboxMessagingPlan } from "../../../messaging/manifest"; -import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; -import { handleSandboxState, type SandboxStateOptions } from "./sandbox"; - -function makeMinimalPlan(sandboxName: string, agent = "openclaw"): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName, - agent: agent as SandboxMessagingPlan["agent"], - workflow: "onboard", - channels: [], - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - -type Gpu = { type: string } | null; -type Agent = { displayName?: string } | null; -type WebSearchConfig = { fetchEnabled: true }; -type MessagingChannelConfig = Record; -type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; -type ResourceProfile = { cpu: string; memory: string }; - -function createDeps( - overrides: Partial< - SandboxStateOptions< - Gpu, - Agent, - WebSearchConfig, - MessagingChannelConfig, - SandboxGpuConfig, - ResourceProfile - >["deps"] - > = {}, -) { - let session = createSession(); - const calls = { - note: vi.fn(), - updateSession: vi.fn((mutator: (value: Session) => Session | void) => { - session = mutator(session) ?? session; - return session; - }), - persistMessaging: vi.fn(), - removeSandbox: vi.fn(), - repairSandbox: vi.fn(), - validateBrave: vi.fn(async () => "brave-key"), - isBackToSelection: vi.fn(() => false), - configureWebSearch: vi.fn(async () => null as WebSearchConfig | null), - startStep: vi.fn(async () => undefined), - getRecordedChannels: vi.fn(() => null), - setupMessaging: vi.fn(async () => [] as string[]), - promptName: vi.fn(async () => "my-assistant"), - selectResourceProfile: vi.fn(async () => null as ResourceProfile | null), - stopStale: vi.fn(), - createSandbox: vi.fn(async () => "my-assistant"), - updateSandbox: vi.fn(), - complete: vi.fn(async () => createSession()), - skipped: vi.fn(), - recordSkip: vi.fn(async () => createSession()), - repairEvent: vi.fn(async () => createSession()), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - return { - calls, - deps: { - resolvePath: (value: string) => `/abs/${value}`, - agentSupportsWebSearch: () => true, - note: calls.note, - updateSession: calls.updateSession, - getStoredMessagingChannelConfig: () => null, - hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, - messagingChannelConfigsEqual: () => true, - persistMessagingChannelConfigToSession: calls.persistMessaging, - getSandboxReuseState: () => "missing", - computeTelegramRequireMention: () => null, - hasSandboxGpuDrift: () => false, - hasWechatConfigDrift: () => false, - getSandboxHermesToolGateways: () => [], - normalizeHermesToolGatewaySelections: (value: unknown) => - Array.isArray(value) ? (value as string[]) : [], - stringSetsEqual: (left: string[], right: string[]) => - left.length === right.length && left.every((value) => right.includes(value)), - removeSandboxFromRegistry: calls.removeSandbox, - repairRecordedSandbox: calls.repairSandbox, - ensureValidatedBraveSearchCredential: calls.validateBrave, - isBackToSelection: calls.isBackToSelection, - configureWebSearch: calls.configureWebSearch, - startRecordedStep: calls.startStep, - getRecordedMessagingChannelsForResume: calls.getRecordedChannels, - getSandboxMessagingChannels: () => ["telegram"], - setupMessagingChannels: calls.setupMessaging, - readMessagingChannelConfigFromEnv: () => null, - readMessagingPlanFromEnv: () => null, - writePlanToEnv: () => undefined, - getRegistrySandboxMessagingPlan: () => null, - promptValidatedSandboxName: calls.promptName, - selectResourceProfileForSandbox: calls.selectResourceProfile, - stopStaleDashboardListenersForSandbox: calls.stopStale, - listRegistrySandboxes: () => ({ sandboxes: [{ name: "old" }] }), - createSandbox: calls.createSandbox, - updateSandboxRegistry: calls.updateSandbox, - getSandboxAgentRegistryFields: () => ({ agent: null }), - recordStepComplete: calls.complete, - toSessionUpdates: (updates: Record) => updates as SessionUpdates, - skippedStepMessage: calls.skipped, - recordStateSkipped: calls.recordSkip, - recordRepairEvent: calls.repairEvent, - error: calls.error, - exitProcess: calls.exit, - ...overrides, - }, - getSession: () => session, - }; -} - -function baseOptions( - deps: SandboxStateOptions< - Gpu, - Agent, - WebSearchConfig, - MessagingChannelConfig, - SandboxGpuConfig, - ResourceProfile - >["deps"], - session: Session | null = createSession(), -): SandboxStateOptions< - Gpu, - Agent, - WebSearchConfig, - MessagingChannelConfig, - SandboxGpuConfig, - ResourceProfile -> { - return { - resume: false, - fresh: false, - resumeAgentChanged: false, - session, - sandboxName: null, - model: "model", - provider: "provider", - nimContainer: null, - webSearchConfig: null, - selectedMessagingChannels: [], - fromDockerfile: null, - agent: null, - gpu: { type: "nvidia" }, - preferredInferenceApi: "openai-completions", - sandboxGpuConfig: { sandboxGpuEnabled: false, mode: "0" }, - hermesToolGateways: [], - controlUiPort: null, - rootDir: "/repo", - deps, - }; -} +import { createSession, type Session } from "../../../state/onboard-session"; +import { handleSandboxState } from "./sandbox"; +import { + baseOptions, + createDeps, + makeMinimalPlan, +} from "../../../../../test/helpers/sandbox-handler-fixtures"; describe("handleSandboxState", () => { it("creates a sandbox and records messaging/web search state", async () => { + const mockPlan = makeMinimalPlan("my-assistant", "openclaw", ["telegram"]); const { deps, calls } = createDeps({ configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), - readMessagingChannelConfigFromEnv: () => ({ telegram: "polling" }), + readMessagingPlanFromEnv: () => mockPlan, }); calls.setupMessaging.mockResolvedValue(["telegram"]); @@ -222,7 +67,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" }); @@ -377,97 +225,4 @@ describe("handleSandboxState", () => { ); expect(result.webSearchConfig).toBeNull(); }); - - it("uses recorded messaging channels on non-interactive resume", async () => { - const getRecordedMessagingChannelsForResume = vi.fn(() => ["discord"]); - const { deps, calls } = createDeps({ getRecordedMessagingChannelsForResume }); - - const result = await handleSandboxState({ ...baseOptions(deps), resume: true }); - - expect(calls.setupMessaging).not.toHaveBeenCalled(); - expect(getRecordedMessagingChannelsForResume).toHaveBeenCalledWith( - true, - expect.any(Object), - "my-assistant", - ); - expect(calls.note).toHaveBeenCalledWith( - " [non-interactive] Reusing messaging channel configuration: discord", - ); - expect(result.selectedMessagingChannels).toEqual(["discord"]); - }); - - it("persists plan from env into session after fresh messaging setup", async () => { - const mockPlan = makeMinimalPlan("my-assistant"); - const { deps, getSession } = createDeps({ - readMessagingPlanFromEnv: () => mockPlan, - }); - - await handleSandboxState({ ...baseOptions(deps) }); - - expect(getSession().messagingPlan).toEqual(mockPlan); - }); - - it("restores registry plan to env on non-interactive resume when env is empty", async () => { - const registryPlan = makeMinimalPlan("my-assistant"); - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); - const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); - const writePlanToEnv = vi.fn(); - const { deps } = createDeps({ - getRecordedMessagingChannelsForResume, - writePlanToEnv, - readMessagingPlanFromEnv: () => null, - getRegistrySandboxMessagingPlan: () => registryPlan, - }); - - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }); - - expect(writePlanToEnv).toHaveBeenCalledWith(registryPlan); - }); - - 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 getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); - const writePlanToEnv = vi.fn(); - const { deps, getSession } = createDeps({ - getRecordedMessagingChannelsForResume, - writePlanToEnv, - readMessagingPlanFromEnv: () => rebuiltPlan, - getRegistrySandboxMessagingPlan: () => registryPlan, - }); - - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }); - - expect(writePlanToEnv).not.toHaveBeenCalled(); - expect(getSession().messagingPlan).toEqual(rebuiltPlan); - }); - - it("does not restore plan to env when registry has no entry", async () => { - const session = createSession({ sandboxName: "my-assistant", messagingChannels: ["telegram"] }); - const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); - const writePlanToEnv = vi.fn(); - const { deps } = createDeps({ - getRecordedMessagingChannelsForResume, - writePlanToEnv, - readMessagingPlanFromEnv: () => null, - getRegistrySandboxMessagingPlan: () => null, - }); - - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }); - - expect(writePlanToEnv).not.toHaveBeenCalled(); - }); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 0e6b846f29..990ff7b79c 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -3,6 +3,7 @@ import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; +import { getActiveChannelsFromPlan } from "../../messaging-plan-session"; import { withSandboxPhaseTrace } from "../../tracing"; import { branchTo, type OnboardStateTransitionResult } from "../result"; @@ -52,7 +53,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; @@ -84,7 +84,6 @@ export interface SandboxStateOptions< existingChannels: string[] | null, sandboxName: string, ): Promise; - readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; readMessagingPlanFromEnv(): SandboxMessagingPlan | null; writePlanToEnv(plan: SandboxMessagingPlan): void; getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null; @@ -203,12 +202,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 +241,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 { @@ -326,6 +319,10 @@ export async function handleSandboxState< let messagingPlan: SandboxMessagingPlan | null = null; if (recordedMessagingChannels) { selectedMessagingChannels = recordedMessagingChannels; + const envPlan = deps.readMessagingPlanFromEnv(); + if (envPlan) { + messagingPlan = envPlan; + } if (selectedMessagingChannels.length > 0) { deps.note( ` [non-interactive] Reusing messaging channel configuration: ${selectedMessagingChannels.join(", ")}`, @@ -337,10 +334,7 @@ export async function handleSandboxState< // 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 { + if (!envPlan) { // 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. @@ -353,15 +347,13 @@ export async function handleSandboxState< } } else { const existing = sandboxName - ? (deps.getSandboxMessagingChannels(sandboxName) ?? session?.messagingChannels ?? null) - : (session?.messagingChannels ?? null); + ? (deps.getSandboxMessagingChannels(sandboxName) ?? + getActiveChannelsFromPlan(session?.messagingPlan)) + : getActiveChannelsFromPlan(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; }); @@ -403,7 +395,7 @@ export async function handleSandboxState< }); // Default-marking is deferred to finalization so a cancelled onboard never // leaves this sandbox registered as default (#4614). - await deps.recordStepComplete( + session = await deps.recordStepComplete( "sandbox", deps.toSessionUpdates({ sandboxName, @@ -411,7 +403,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 aae189d80b..9dc4f1eaa8 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -5,11 +5,10 @@ import { type MessagingChannelConfig, mergeMessagingChannelConfigs, resolveMessagingChannelConfigEnvValue, - sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; import type { Session } from "../state/onboard-session"; -import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; +import { getMessagingChannelConfigFromPlan } from "./messaging-plan-session"; type EnvLike = Record; @@ -107,25 +106,16 @@ export function getStoredMessagingChannelConfig( session: Session | null, ): MessagingChannelConfig | null { const registryConfig = sandboxName - ? sanitizeMessagingChannelConfig(registry.getSandbox(sandboxName)?.messagingChannelConfig) + ? getMessagingChannelConfigFromPlan(registry.getSandbox(sandboxName)?.messaging?.plan) : 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-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.test.ts b/src/lib/onboard/messaging-plan-session.test.ts new file mode 100644 index 0000000000..dc3f570703 --- /dev/null +++ b/src/lib/onboard/messaging-plan-session.test.ts @@ -0,0 +1,37 @@ +// 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 "../messaging/manifest"; +import { getActiveChannelsFromPlan, getChannelsFromPlan } from "./messaging-plan-session"; + +describe("messaging plan session helpers", () => { + it("treats an empty plan as an explicit empty channel selection", () => { + const plan: SandboxMessagingPlan = { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [], + entries: [], + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; + + expect(getChannelsFromPlan(plan)).toEqual([]); + expect(getActiveChannelsFromPlan(plan)).toEqual([]); + }); + + it("returns null only when no plan is available", () => { + expect(getChannelsFromPlan(null)).toBeNull(); + expect(getActiveChannelsFromPlan(undefined)).toBeNull(); + }); +}); diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts index 5e530ee337..a03a364271 100644 --- a/src/lib/onboard/messaging-plan-session.ts +++ b/src/lib/onboard/messaging-plan-session.ts @@ -1,61 +1,56 @@ // 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 { parseValidSandboxMessagingPlan } from "../messaging/plan-validation"; +import type { MessagingChannelConfig } from "../messaging-channel-config"; 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; + return parseValidSandboxMessagingPlan(value); } -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +/** Derive configured channel ids from a plan. */ +export function getChannelsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] | null { + const validPlan = parseSandboxMessagingPlan(plan); + if (!validPlan) return null; + return validPlan.channels.map((c) => c.channelId); } -/** Derive the equivalent of session.messagingChannels from a plan. */ -export function getChannelsFromPlan( +/** Derive active, non-disabled channels from a plan for build/provider setup. */ +export function getActiveChannelsFromPlan( plan: SandboxMessagingPlan | null | undefined, ): string[] | null { - if (!plan || plan.channels.length === 0) return null; - return plan.channels.map((c) => c.channelId); + const validPlan = parseSandboxMessagingPlan(plan); + if (!validPlan) return null; + const disabled = new Set(validPlan.disabledChannels); + return validPlan.channels + .filter((channel) => channel.active && !channel.disabled && !disabled.has(channel.channelId)) + .map((channel) => channel.channelId); } -/** Derive the equivalent of session.disabledChannels from a plan. */ +/** Derive disabled channel ids from a plan. */ export function getDisabledChannelsFromPlan( plan: SandboxMessagingPlan | null | undefined, ): string[] | null { - if (!plan) return null; - return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; + const validPlan = parseSandboxMessagingPlan(plan); + if (!validPlan) return null; + return validPlan.disabledChannels.length > 0 ? [...validPlan.disabledChannels] : null; } /** - * Derive the equivalent of session.messagingChannelConfig from a plan. - * Config inputs (kind === "config") carry their resolved env-key/value pairs - * in plan.channels[].inputs, populated at compile time from process.env. + * Derive non-secret channel config from a plan. Config inputs + * (kind === "config") carry their resolved env-key/value pairs in + * plan.channels[].inputs, populated at compile time from process.env. */ export function getMessagingChannelConfigFromPlan( plan: SandboxMessagingPlan | null | undefined, ): MessagingChannelConfig | null { - if (!plan) return null; + const validPlan = parseSandboxMessagingPlan(plan); + if (!validPlan) return null; const config: Record = {}; - for (const channel of plan.channels) { + for (const channel of validPlan.channels) { for (const input of channel.inputs) { if (input.kind === "config" && input.sourceEnv && input.value != null) { config[input.sourceEnv] = String(input.value); 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..836046ae25 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, + getSandboxConfiguredChannels: (sandboxName: string) => string[] | null | undefined, getDisabledChannels: (sandboxName: string) => string[], providerExists: (providerName: string) => boolean, nonInteractive: boolean, @@ -49,7 +48,7 @@ export function getNonInteractiveStoredMessagingChannels( } const configuredChannels = getKnownMessagingChannels( - getSandbox(sandboxName)?.messagingChannels, + getSandboxConfiguredChannels(sandboxName), messagingChannels, ); const disabledChannels = new Set(getDisabledChannels(sandboxName)); 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/policy/index.ts b/src/lib/policy/index.ts index b347d8ed94..6ca071ae26 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -789,12 +789,11 @@ function applyPresetContent( ); return false; } - const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); - const endpoints = getPresetEndpoints(presetContent); if (endpoints.length > 0) { console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`); } + const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); // Run before creating temp resources so a missing-binary exit doesn't // orphan files in $TMPDIR (the finally cleanup doesn't run on process.exit). 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..ff4b5ab364 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -48,6 +48,37 @@ function normalizeLegacySession( ); } +function makeMessagingPlan( + channels: readonly string[] = ["telegram"], + disabledChannels: readonly string[] = [], +): NonNullable { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName: "my-assistant", + 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 +570,62 @@ 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(["telegram", "slack"], ["telegram"]); session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannels).toEqual(["telegram", "slack"]); + expect(loaded.messagingPlan?.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "slack", + ]); + expect(loaded.messagingPlan?.disabledChannels).toEqual(["telegram"]); }); - it("filters non-string entries out of persisted messagingChannels", () => { + it("drops malformed persisted messagingPlan", () => { 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(["telegram"]), + channels: "telegram", + }, }), ); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannels).toEqual(["telegram", "discord"]); + expect(loaded.messagingPlan).toBeNull(); }); - it("persists disabledChannels across save/load roundtrips", () => { - // Regression: `channels stop X` followed by rebuild must carry the paused - // set through the destroy/recreate window. The Session mirror is the only - // place this can survive, because rebuild destroys the registry entry - // before `onboard --resume` reads it back. + it("persists disabled channel state inside messagingPlan", () => { const created = session.createSession(); - created.disabledChannels = ["telegram"]; + created.messagingPlan = makeMessagingPlan(["telegram"], ["telegram"]); session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.disabledChannels).toEqual(["telegram"]); + expect(loaded.messagingPlan?.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", () => { + it("defaults messagingPlan to null for fresh sessions", () => { const fresh = session.createSession(); - expect(fresh.disabledChannels).toBeNull(); + expect(fresh.messagingPlan).toBeNull(); }); - 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"]); - - session.markStepComplete("provider_selection", { disabledChannels: null }); - expect(requireLoadedSession(session.loadSession()).disabledChannels).toBeNull(); - }); - - it("defaults messagingChannels 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", + session.markStepComplete("provider_selection", { + messagingPlan: makeMessagingPlan(["discord"], ["discord"]), }); - }); - - 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", - }, - }), - ); + expect(requireLoadedSession(session.loadSession()).messagingPlan?.disabledChannels).toEqual([ + "discord", + ]); - const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123", - DISCORD_REQUIRE_MENTION: "0", - }); + session.markStepComplete("provider_selection", { messagingPlan: null }); + expect(requireLoadedSession(session.loadSession()).messagingPlan).toBeNull(); }); it("#1737: persists telegramConfig across save/load roundtrips (requireMention=true)", () => { @@ -1032,49 +1012,40 @@ 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(["telegram"]); + const created = session.createSession({ messagingPlan: plan }); + expect(created.messagingPlan?.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + ]); const saved = session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(saved.messagingChannels).toEqual(["telegram"]); - expect(loaded.messagingChannels).toEqual(["telegram"]); - }); - - it("filterSafeUpdates preserves messagingChannels field", () => { - session.saveSession(session.createSession()); - session.markStepComplete("provider_selection", { - messagingChannels: ["slack", "discord"], - }); - - const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannels).toEqual(["slack", "discord"]); + expect(saved.messagingPlan?.channels.map((channel) => channel.channelId)).toEqual(["telegram"]); + expect(loaded.messagingPlan?.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + ]); }); - it("filterSafeUpdates preserves sanitized messagingChannelConfig", () => { + it("filterSafeUpdates preserves messagingPlan field", () => { session.saveSession(session.createSession()); session.markStepComplete("provider_selection", { - messagingChannelConfig: { - TELEGRAM_ALLOWED_IDS: "123", - TELEGRAM_REQUIRE_MENTION: "1", - DISCORD_REQUIRE_MENTION: "invalid", - }, + messagingPlan: makeMessagingPlan(["slack", "discord"]), }); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123", - TELEGRAM_REQUIRE_MENTION: "1", - }); + expect(loaded.messagingPlan?.channels.map((channel) => channel.channelId)).toEqual([ + "slack", + "discord", + ]); }); it("#1737: filterSafeUpdates routes telegramConfig through markStepComplete", () => { @@ -1133,20 +1104,23 @@ 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 created = session.createSession({ + messagingPlan: makeMessagingPlan(["telegram", "slack"]), + }); + expect(created.messagingPlan?.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "slack", + ]); 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..2983d84330 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -14,10 +14,6 @@ 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 { @@ -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..383a73f9fa --- /dev/null +++ b/src/lib/state/registry-messaging.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { parseValidSandboxMessagingPlan } from "../messaging/plan-validation"; +import type { SandboxRegistry } from "./registry"; + +export interface SandboxMessagingState { + schemaVersion: 1; + plan: SandboxMessagingPlan; +} + +type MessagingEntry = { + messaging?: { schemaVersion?: number; plan?: SandboxMessagingPlan } | null; +}; + +export interface RegistryMessagingReadDeps { + load(): SandboxRegistry; +} + +export interface RegistryMessagingMutationDeps extends RegistryMessagingReadDeps { + save(data: SandboxRegistry): void; + withLock(fn: () => T): T; +} + +export function cloneSandboxMessagingState( + messaging: SandboxMessagingState | undefined, +): SandboxMessagingState | undefined { + if (!messaging || messaging.schemaVersion !== 1) return undefined; + const plan = parseValidSandboxMessagingPlan(messaging.plan); + if (!plan) return undefined; + return { + schemaVersion: 1, + plan: JSON.parse(JSON.stringify(plan)) as SandboxMessagingPlan, + }; +} + +export function getMessagingPlanFromEntry( + entry: MessagingEntry | null | undefined, +): SandboxMessagingPlan | null { + const plan = entry?.messaging?.schemaVersion === 1 ? entry.messaging.plan : null; + return parseValidSandboxMessagingPlan(plan); +} + +export function getConfiguredMessagingChannelsFromEntry( + entry: MessagingEntry | null | undefined, +): string[] { + const plan = getMessagingPlanFromEntry(entry); + if (!plan) return []; + return plan.channels.filter((channel) => channel.configured).map((channel) => channel.channelId); +} + +export function getActiveMessagingChannelsFromEntry( + entry: MessagingEntry | null | undefined, +): string[] { + const plan = getMessagingPlanFromEntry(entry); + 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 getDisabledMessagingChannelsFromEntry( + entry: MessagingEntry | null | undefined, +): string[] { + const plan = getMessagingPlanFromEntry(entry); + return plan ? [...plan.disabledChannels] : []; +} + +export function getDisabledChannels(name: string, deps: RegistryMessagingReadDeps): string[] { + const data = deps.load(); + return getDisabledMessagingChannelsFromEntry(data.sandboxes[name]); +} + +export function setChannelDisabled( + name: string, + channel: string, + disabled: boolean, + deps: RegistryMessagingMutationDeps, +): boolean { + return deps.withLock(() => { + const data = deps.load(); + const entry = data.sandboxes[name]; + if (!entry) return false; + const plan = getMessagingPlanFromEntry(entry); + if (!plan) return false; + const configuredChannels = new Set(plan.channels.map((entry) => entry.channelId)); + if (!configuredChannels.has(channel)) return false; + const current = new Set(plan.disabledChannels); + if (disabled) current.add(channel); + else current.delete(channel); + const disabledChannels = Array.from(current) + .filter((channelId) => configuredChannels.has(channelId)) + .sort(); + const disabledSet = new Set(disabledChannels); + entry.messaging = { + schemaVersion: 1, + plan: { + ...plan, + workflow: disabled ? "stop-channel" : "start-channel", + channels: plan.channels.map((channelPlan) => { + const channelDisabled = disabledSet.has(channelPlan.channelId); + return { + ...channelPlan, + disabled: channelDisabled, + active: !channelDisabled && channelPlan.configured, + }; + }), + disabledChannels, + }, + }; + deps.save(data); + return true; + }); +} + +export function getConfiguredMessagingChannels( + name: string, + deps: RegistryMessagingReadDeps, +): string[] { + const data = deps.load(); + return getConfiguredMessagingChannelsFromEntry(data.sandboxes[name]); +} diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index b2467ebdce..0fa69d8906 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; @@ -60,15 +72,12 @@ export interface SandboxEntry { agent?: string | null; agentVersion?: string | null; imageTag?: string | null; - 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 @@ -78,11 +87,6 @@ export interface SandboxEntry { gatewayPort?: number | null; } -export interface SandboxMessagingState { - schemaVersion: 1; - plan: SandboxMessagingPlan; -} - export interface SandboxRegistry { sandboxes: Record; defaultSandbox: string | null; @@ -341,11 +345,6 @@ export function registerSandbox(entry: SandboxEntry): void { agent: entry.agent || null, agentVersion: entry.agentVersion || null, imageTag: entry.imageTag || null, - 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 @@ -355,10 +354,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, @@ -370,16 +365,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(); @@ -497,20 +482,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 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 }); +} + +export function getConfiguredMessagingChannels(name: string): string[] { + return getRegistryConfiguredMessagingChannels(name, { load }); } diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index db1ed6ea42..0c0400ddcb 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -6,9 +6,9 @@ import { spawnSync } from "node:child_process"; import { getNamedGatewayLifecycleState } from "./gateway-runtime-action"; import { getLiveGatewayInference } from "./inference/live"; import type { GatewayHealth, MessagingBridgeHealth, ShowStatusCommandDeps } from "./inventory"; -import { backfillMessagingChannels, findAllOverlaps } from "./messaging/applier"; +import { findAllOverlaps } from "./messaging/applier"; import type { CaptureOpenshellResult } from "./adapters/openshell/client"; -import { captureOpenshellCommand, stripAnsi } from "./adapters/openshell/client"; +import { captureOpenshellCommand } from "./adapters/openshell/client"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import * as registry from "./state/registry"; import { resolveOpenshell } from "./adapters/openshell/resolve"; @@ -58,50 +58,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 findStoredMessagingOverlaps() { + // Non-critical path: status must remain usable even if registry reads throw, + // so any failure yields an empty overlap list. try { - backfillMessagingChannels(registry, makeConflictProbe(rootDir)); return findAllOverlaps(registry); } catch { return []; @@ -201,7 +161,7 @@ export function buildStatusCommandDeps(rootDir: string): ShowStatusCommandDeps { : undefined, checkMessagingBridgeHealth: (sandboxName, channels) => checkMessagingBridgeHealth(rootDir, sandboxName, channels), - backfillAndFindOverlaps: () => backfillAndFindOverlaps(rootDir), + backfillAndFindOverlaps: findStoredMessagingOverlaps, 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..513610e936 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -12,6 +12,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +import { makeMessagingState } from "./helpers/messaging-plan-fixtures"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -154,9 +155,12 @@ const registryUpdates = []; registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(sandboxAgent)}, - messagingChannels: [], - disabledChannels: [], + messaging: ${JSON.stringify(makeMessagingState("test-sb", [], [], sandboxAgent))}, }); +registry.getConfiguredMessagingChannels = (name) => + registry.getConfiguredMessagingChannelsFromEntry(registry.getSandbox(name)); +registry.getDisabledChannels = (name) => + registry.getDisabledMessagingChannelsFromEntry(registry.getSandbox(name)); registry.updateSandbox = (name, updates) => { registryUpdates.push({ name, updates }); return true; @@ -380,10 +384,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 }> } } }; @@ -403,7 +403,7 @@ const ctx = module.exports; assert.deepEqual(messagingStateUpdate.updates.messaging.plan.credentialBindings, []); assert.deepEqual( payload.registryUpdates.map((entry: { name: string }) => entry.name), - ["test-sb", "test-sb"], + ["test-sb"], ); assert.deepEqual( payload.appliedCalls, @@ -528,7 +528,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 persist a messaging plan; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( !payload.callOrder.includes("promptAndRebuild"), @@ -780,7 +780,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 persist a messaging plan; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( !payload.callOrder.includes("promptAndRebuild"), @@ -836,12 +836,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, + [], + `policy failure must not persist a messaging plan; 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"], @@ -908,11 +907,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, + [], + `policy failure must not persist a messaging plan even when gateway rollback has residue; got ${JSON.stringify(payload.registryUpdates)}`, ); - assert.deepEqual(payload.registryUpdates[1].updates.messagingChannels, []); assert.deepEqual( payload.deletedCredentialKeys, ["TELEGRAM_BOT_TOKEN"], @@ -937,8 +936,7 @@ process.exit = (code) => { registry.getSandbox = () => ({ name: "test-sb", agent: "openclaw", - messagingChannels: ["telegram"], - disabledChannels: [], + messaging: ${JSON.stringify(makeMessagingState("test-sb", ["telegram"]))}, }); credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; const ctx = module.exports; @@ -976,11 +974,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 keep prior telegram plan untouched; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), @@ -1006,8 +1003,7 @@ process.exit = (code) => { registry.getSandbox = () => ({ name: "test-sb", agent: "openclaw", - messagingChannels: ["telegram"], - disabledChannels: [], + messaging: ${JSON.stringify(makeMessagingState("test-sb", ["telegram"]))}, }); credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; let upsertCalls = 0; @@ -1048,11 +1044,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, + [], + `registry plan must stay untouched when gateway re-upsert fails; got ${JSON.stringify(payload.registryUpdates)}`, ); assert.ok( payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), @@ -1625,8 +1620,7 @@ const registry = require(${j("state/registry.js")}); registry.getSandbox = () => ({ name: "test-sb", agent: global.__testAgent || "openclaw", - messagingChannels: [], - disabledChannels: [], + messaging: ${JSON.stringify(makeMessagingState("test-sb", []))}, }); registry.updateSandbox = () => true; diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts index b3b4880b61..fadebd861f 100644 --- a/test/channels-remove-full-teardown.test.ts +++ b/test/channels-remove-full-teardown.test.ts @@ -16,6 +16,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +import { makeMessagingPlan, makeMessagingState } from "./helpers/messaging-plan-fixtures"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -129,9 +130,7 @@ const sessionStore = { routerPid: null, routerCredentialHash: null, policyTier: null, - messagingChannels: [${JSON.stringify(channelInRegistry)}], - messagingChannelConfig: null, - disabledChannels: [], + messagingPlan: ${JSON.stringify(makeMessagingPlan("test-sb", [channelInRegistry], [], sandboxAgent))}, hermesToolGateways: [], wechatConfig: null, }; @@ -143,10 +142,13 @@ const registryUpdates = []; registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(sandboxAgent)}, - messagingChannels: [${JSON.stringify(channelInRegistry)}], - disabledChannels: [], + messaging: ${JSON.stringify(makeMessagingState("test-sb", [channelInRegistry], [], sandboxAgent))}, policies: ${JSON.stringify(presetNamesApplied)}, }); +registry.getConfiguredMessagingChannels = (name) => + registry.getConfiguredMessagingChannelsFromEntry(registry.getSandbox(name)); +registry.getDisabledChannels = (name) => + registry.getDisabledMessagingChannelsFromEntry(registry.getSandbox(name)); registry.updateSandbox = (name, updates) => { registryUpdates.push({ name, updates }); return true; @@ -407,8 +409,7 @@ const registryOverride = require(${JSON.stringify(path.join(repoRoot, "dist", "l registryOverride.getSandbox = () => ({ name: "test-sb", agent: "openclaw", - messagingChannels: [], - disabledChannels: [], + messaging: ${JSON.stringify(makeMessagingState("test-sb", []))}, policies: [], }); const policiesOverride = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "policy/index.js"))}); @@ -536,18 +537,27 @@ 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", + ); + assert.deepEqual( + messagingPlanUpdate.updates.messaging.plan.credentialBindings.filter( + (entry: { channelId: string }) => entry.channelId === "telegram", + ), + [], + "telegram credential bindings must be removed", ); }); }); diff --git a/test/cli/connect-recovery.test.ts b/test/cli/connect-recovery.test.ts index 3a8b28e318..da3497a9fe 100644 --- a/test/cli/connect-recovery.test.ts +++ b/test/cli/connect-recovery.test.ts @@ -529,7 +529,7 @@ describe("CLI dispatch", () => { expect(openshellLog).toContain("sandbox exec --name alpha -- sh -c"); expect(openshellLog).toContain("sandbox ssh-config alpha"); expect(sshLog).toContain('OPENCLAW="$(command -v openclaw)"'); - }); + }, 30_000); it("recovers non-OpenClaw agents over SSH instead of root sandbox exec", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-probe-agent-")); @@ -701,7 +701,7 @@ describe("CLI dispatch", () => { expect(calls).toContain("sandbox get alpha"); expect(calls.filter((call) => call === "sandbox list").length).toBeGreaterThanOrEqual(2); expect(calls).toContain("sandbox connect alpha"); - }); + }, 30_000); it( "fails fast with gateway recovery guidance when connect readiness sees a disconnected gateway", diff --git a/test/cli/doctor-gateway-token.test.ts b/test/cli/doctor-gateway-token.test.ts index 94ba686edb..7f949f7d7a 100644 --- a/test/cli/doctor-gateway-token.test.ts +++ b/test/cli/doctor-gateway-token.test.ts @@ -123,7 +123,7 @@ describe("CLI dispatch", () => { detail: expect.stringContaining("Creating"), }), ); - }); + }, 30_000); it("doctor does not inspect the legacy k3s gateway container in Docker-driver mode", () => { const setup = createDoctorTestSetup("nemoclaw-cli-doctor-docker-driver-", [ @@ -182,7 +182,7 @@ describe("CLI dispatch", () => { expect(report.checks.filter((c) => c.group === "Gateway" && c.status === "fail")).toEqual([]); expect(report.status).toBe("ok"); expect(r.code).toBe(0); - }); + }, 30_000); it("doctor still inspects the legacy k3s gateway container for the kubernetes driver", () => { const setup = createDoctorTestSetup("nemoclaw-cli-doctor-k8s-driver-", [ @@ -208,7 +208,7 @@ describe("CLI dispatch", () => { detail: expect.stringContaining("openshell-cluster-nemoclaw"), }), ); - }); + }, 30_000); it("doctor accepts a local openshell-gateway process when legacy inspect fails", () => { const setup = createDoctorTestSetup("nemoclaw-cli-doctor-local-gateway-", [ @@ -250,7 +250,7 @@ describe("CLI dispatch", () => { expect(calls).toContain("pgrep:-f ^(/[^ ]*/)?openshell-gateway( |$)"); expect(calls).not.toContain("pgrep:-af openshell-gateway"); expect(calls).not.toContain("docker:port"); - }); + }, 30_000); it("doctor treats local gateway process evidence as informational until OpenShell verifies the named gateway", () => { const setup = createDoctorTestSetup("nemoclaw-cli-doctor-unverified-local-gateway-", [ @@ -284,7 +284,7 @@ describe("CLI dispatch", () => { expect(report.checks.find((check) => check.label === "OpenShell status")).toEqual( expect.objectContaining({ group: "Gateway", status: "fail" }), ); - }); + }, 30_000); it("doctor reports unavailable local gateway probe tools while trusting a verified named gateway", () => { const setup = createDoctorTestSetup("nemoclaw-cli-doctor-missing-local-probe-tools-", [ @@ -321,7 +321,7 @@ describe("CLI dispatch", () => { ); expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); expect(report.status).toBe("ok"); - }); + }, 30_000); it( "doctor reports fresh shields state as not configured instead of down", @@ -369,7 +369,7 @@ describe("CLI dispatch", () => { expect(r.out).toContain("OpenShell status"); expect(r.out).toContain("Gateway: other"); expect(setup.readCalls().some((call) => /^sandbox list(\s|$)/.test(call))).toBe(false); - }); + }, 30_000); it("doctor treats a live non-cloudflared PID as stale", () => { const { sandboxName, serviceDir } = createCloudflaredServiceDir("doctorpid-"); diff --git a/test/cli/list-share-live-inference.test.ts b/test/cli/list-share-live-inference.test.ts index a5e632af91..47aa49a8ef 100644 --- a/test/cli/list-share-live-inference.test.ts +++ b/test/cli/list-share-live-inference.test.ts @@ -448,7 +448,7 @@ describe("list shows live gateway inference", () => { // Will fail because sshfs/sandbox isn't running, but should NOT say "Unknown action" expect(r.code).not.toBe(0); expect(r.out).not.toContain("Unknown action"); - }); + }, 30_000); it("unknown share subcommands fail before action dispatch", () => { const env = createShareTestEnv("nemoclaw-cli-share-unknown-"); diff --git a/test/cli/logs.test.ts b/test/cli/logs.test.ts index 78454d4205..3f4f2bd610 100644 --- a/test/cli/logs.test.ts +++ b/test/cli/logs.test.ts @@ -21,6 +21,36 @@ import { writeSandboxRegistry, } from "./helpers"; +function messagingState(sandboxName: string, channels: readonly string[], agent = "openclaw") { + return { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName, + agent, + 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: channels, entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }; +} + describe("CLI dispatch", () => { it("routes logs to OpenClaw and OpenShell log sources", () => { const setup = createLogsTestSetup("nemoclaw-cli-logs-routing-"); @@ -86,42 +116,46 @@ describe("CLI dispatch", () => { expect(r.out).toContain(FAKE_OPENSHELL_LOG_LINE); }); - it("starts OpenClaw logs before enabling audit logs for logs --follow", () => { - const gatewayStartedMarker = "gateway-started"; - const auditCompleteMarker = "audit-enabled"; - const setup = createLogsTestSetup( - "nemoclaw-cli-logs-follow-audit-slow-", - [ - 'if [ "$1" = "settings" ]; then', - " sleep 0.05", - ` printf '%s\\n' ${JSON.stringify(auditCompleteMarker)} >> "$marker_file"`, - " exit 0", - "fi", - ], - { gatewayStartedMarker }, - ); + it( + "starts OpenClaw logs before enabling audit logs for logs --follow", + testTimeoutOptions(30_000), + () => { + const gatewayStartedMarker = "gateway-started"; + const auditCompleteMarker = "audit-enabled"; + const setup = createLogsTestSetup( + "nemoclaw-cli-logs-follow-audit-slow-", + [ + 'if [ "$1" = "settings" ]; then', + " sleep 0.05", + ` printf '%s\\n' ${JSON.stringify(auditCompleteMarker)} >> "$marker_file"`, + " exit 0", + "fi", + ], + { gatewayStartedMarker }, + ); - const start = Date.now(); - const r = setup.runLogs("alpha logs --follow", { NEMOCLAW_LOGS_PROBE_TIMEOUT_MS: "2000" }); - const calls = setup.readCalls(); + const start = Date.now(); + const r = setup.runLogs("alpha logs --follow", { NEMOCLAW_LOGS_PROBE_TIMEOUT_MS: "10000" }); + const calls = setup.readCalls(); - expect(Date.now() - start).toBeGreaterThanOrEqual(40); - expect(r.code).toBe(0); - // All three calls must happen: OpenClaw log stream, audit enable, OpenShell log stream. - expect(calls).toContain("sandbox exec -n alpha -- tail -n 200 -f /tmp/gateway.log"); - expect(calls).toContain("settings set alpha --key ocsf_json_enabled --value true"); - expect(calls).toContain("logs alpha -n 200 --source all --tail"); - expect(calls).toContain(gatewayStartedMarker); - expect(calls).toContain(auditCompleteMarker); - const gatewayStartedIdx = calls.indexOf(gatewayStartedMarker); - const auditIdx = calls.indexOf("settings set alpha --key ocsf_json_enabled --value true"); - const auditCompleteIdx = calls.indexOf(auditCompleteMarker); - const openshellIdx = calls.indexOf("logs alpha -n 200 --source all --tail"); - expect(gatewayStartedIdx).toBeLessThan(auditCompleteIdx); - expect(auditIdx).toBeLessThan(openshellIdx); - expect(r.out).toContain(FAKE_OPENCLAW_LOG_LINE); - expect(r.out).toContain(FAKE_OPENSHELL_LOG_LINE); - }); + expect(Date.now() - start).toBeGreaterThanOrEqual(40); + expect(r.code).toBe(0); + // All three calls must happen: OpenClaw log stream, audit enable, OpenShell log stream. + expect(calls).toContain("sandbox exec -n alpha -- tail -n 200 -f /tmp/gateway.log"); + expect(calls).toContain("settings set alpha --key ocsf_json_enabled --value true"); + expect(calls).toContain("logs alpha -n 200 --source all --tail"); + expect(calls).toContain(gatewayStartedMarker); + expect(calls).toContain(auditCompleteMarker); + const gatewayStartedIdx = calls.indexOf(gatewayStartedMarker); + const auditIdx = calls.indexOf("settings set alpha --key ocsf_json_enabled --value true"); + const auditCompleteIdx = calls.indexOf(auditCompleteMarker); + const openshellIdx = calls.indexOf("logs alpha -n 200 --source all --tail"); + expect(gatewayStartedIdx).toBeLessThan(auditCompleteIdx); + expect(auditIdx).toBeLessThan(openshellIdx); + expect(r.out).toContain(FAKE_OPENCLAW_LOG_LINE); + expect(r.out).toContain(FAKE_OPENSHELL_LOG_LINE); + }, + ); it( "keeps logs --follow running when one log source exits", @@ -310,7 +344,7 @@ describe("CLI dispatch", () => { provider: "nvidia-prod", gpuEnabled: false, policies: [], - messagingChannels: ["telegram"], + messaging: messagingState("alpha", ["telegram"], "hermes"), agent: "hermes", }, }, diff --git a/test/cli/sandbox-mutations.test.ts b/test/cli/sandbox-mutations.test.ts index 3c4cbfce1c..e0430ffafd 100644 --- a/test/cli/sandbox-mutations.test.ts +++ b/test/cli/sandbox-mutations.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { makeMessagingState } from "../helpers/messaging-plan-fixtures"; import { runWithEnv, testTimeoutOptions, writeSandboxRegistry } from "./helpers"; function readSandboxPolicies(home: string, sandboxName = "alpha"): string[] { @@ -161,4 +162,26 @@ describe("CLI dispatch", () => { expect(stopMissing.code).toBe(1); expect(stopMissing.out).toContain("Sandbox 'does-not-exist' not found in the registry."); }); + + it("sandbox channels start rejects a registry entry without a messaging plan", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-channels-start-no-plan-")); + writeSandboxRegistry(home); + + const result = runWithEnv("sandbox channels start alpha telegram", { HOME: home }); + + expect(result.code).toBe(1); + expect(result.out).toContain("Messaging plan for 'alpha' does not include channel 'telegram'."); + expect(result.out).not.toContain("already enabled"); + }); + + it("sandbox channels start rejects a channel missing from the messaging plan", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-channels-start-unplanned-")); + writeSandboxRegistry(home, "alpha", { messaging: makeMessagingState("alpha", ["slack"]) }); + + const result = runWithEnv("sandbox channels start alpha telegram", { HOME: home }); + + expect(result.code).toBe(1); + expect(result.out).toContain("Messaging plan for 'alpha' does not include channel 'telegram'."); + expect(result.out).not.toContain("already enabled"); + }); }); diff --git a/test/cli/sandbox-status-json.test.ts b/test/cli/sandbox-status-json.test.ts index e1400ad716..88ec1559e5 100644 --- a/test/cli/sandbox-status-json.test.ts +++ b/test/cli/sandbox-status-json.test.ts @@ -210,7 +210,7 @@ describe("CLI sandbox status JSON output", () => { expect(parsed.provider).toBe("unknown"); expect(parsed.openshellDriver).toBe("unknown"); expect(parsed.openshellVersion).toBe("unknown"); - }); + }, 15_000); it("sandbox status --json reports gatewayState!=present and exits 1 when sandbox is registered but gateway lookup is missing", () => { const home = fs.mkdtempSync( diff --git a/test/e2e/docs/parity-inventory.generated.json b/test/e2e/docs/parity-inventory.generated.json index f42dff2ee9..6b3f85f580 100644 --- a/test/e2e/docs/parity-inventory.generated.json +++ b/test/e2e/docs/parity-inventory.generated.json @@ -1,4 +1,6 @@ { + "SPDX-FileCopyrightText": "Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.", + "SPDX-License-Identifier": "Apache-2.0", "generated_by": "scripts/e2e/extract-legacy-assertions.ts", "entrypoints": [ { @@ -7986,17 +7988,17 @@ { "script": "test/e2e/test-messaging-providers.sh", "line": 477, - "text": "M-WA2: registry.messagingChannels contains whatsapp after channel add", + "text": "M-WA2: registry.messaging.plan.channels contains whatsapp after channel add", "polarity": "pass", - "normalized_id": "m.wa2.registry.messagingchannels.contains.whatsapp.after.channel.add", + "normalized_id": "m.wa2.registry.messaging.plan.channels.contains.whatsapp.after.channel.add", "mapping_status": "deferred" }, { "script": "test/e2e/test-messaging-providers.sh", "line": 479, - "text": "M-WA2: registry.messagingChannels missing whatsapp after channel add ($(registry_field messagingChannels))", + "text": "M-WA2: registry.messaging.plan.channels missing whatsapp after channel add ($(registry_field messaging))", "polarity": "fail", - "normalized_id": "m.wa2.registry.messagingchannels.missing.whatsapp.after.channel.add.registry.field.messagingchannels", + "normalized_id": "m.wa2.registry.messaging.plan.channels.missing.whatsapp.after.channel.add.registry.field.messaging", "mapping_status": "deferred" }, { diff --git a/test/e2e/test-channels-add-remove.sh b/test/e2e/test-channels-add-remove.sh index 6dbc8a4196..9e32cb6141 100755 --- a/test/e2e/test-channels-add-remove.sh +++ b/test/e2e/test-channels-add-remove.sh @@ -175,10 +175,17 @@ 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 channel = Array.isArray(plan.channels) + ? plan.channels.find((item) => item?.channelId === "telegram") + : null; +if (!channel) fail("telegram channel missing from messaging.plan.channels"); +const config = Object.fromEntries( + (Array.isArray(channel.inputs) ? channel.inputs : []) + .filter((input) => input?.kind === "config" && typeof input.sourceEnv === "string") + .map((input) => [input.sourceEnv, input.value]), +); if (config.TELEGRAM_ALLOWED_IDS !== allowedIds) { fail("TELEGRAM_ALLOWED_IDS expected " + allowedIds + ", got " + JSON.stringify(config.TELEGRAM_ALLOWED_IDS)); } @@ -186,9 +193,9 @@ if (config.TELEGRAM_REQUIRE_MENTION !== requireMention) { fail("TELEGRAM_REQUIRE_MENTION expected " + requireMention + ", got " + JSON.stringify(config.TELEGRAM_REQUIRE_MENTION)); } ' "$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 +574,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 +620,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 eb6aa6ac08..5e81116074 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -188,12 +188,60 @@ process.stdout.write(JSON.stringify(v ?? 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_channels() { + if [ ! -f "$REGISTRY" ]; then + echo "[]" + return + fi + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName] = process.argv.slice(1); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const channels = registry.sandboxes?.[sandboxName]?.messaging?.plan?.channels; +process.stdout.write(JSON.stringify(Array.isArray(channels) ? channels.map((channel) => channel?.channelId).filter(Boolean) : [])); +' "$REGISTRY" "$ACTIVE_SANDBOX" 2>/dev/null || echo "[]" +} + +registry_plan_disabled_channels() { + if [ ! -f "$REGISTRY" ]; then + echo "[]" + return + fi + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName] = process.argv.slice(1); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const disabled = registry.sandboxes?.[sandboxName]?.messaging?.plan?.disabledChannels; +process.stdout.write(JSON.stringify(Array.isArray(disabled) ? disabled : [])); +' "$REGISTRY" "$ACTIVE_SANDBOX" 2>/dev/null || echo "[]" +} + +registry_plan_contains_channel() { + local item="$1" + if [ ! -f "$REGISTRY" ]; then + return 1 + fi + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, item] = process.argv.slice(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 === item) ? 0 : 1); +' "$REGISTRY" "$ACTIVE_SANDBOX" "$item" 2>/dev/null +} + +registry_plan_disabled_contains() { + local item="$1" + if [ ! -f "$REGISTRY" ]; then + return 1 + fi + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, item] = process.argv.slice(1); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const disabled = registry.sandboxes?.[sandboxName]?.messaging?.plan?.disabledChannels; +process.exit(Array.isArray(disabled) && disabled.includes(item) ? 0 : 1); +' "$REGISTRY" "$ACTIVE_SANDBOX" "$item" 2>/dev/null } provider_names_for_channel() { @@ -250,8 +298,8 @@ channel_presence() { } dump_channel_state() { - info "registry.messagingChannels: $(registry_field messagingChannels)" - info "registry.disabledChannels: $(registry_field disabledChannels)" + info "registry.messaging.plan.channels: $(registry_plan_channels)" + info "registry.messaging.plan.disabledChannels: $(registry_plan_disabled_channels)" info "registry.providerCredentialHashes: $(registry_field providerCredentialHashes)" if [ "$ACTIVE_AGENT" = "openclaw" ]; then info "openclaw.json channels:" @@ -287,14 +335,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_contains_channel "$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_contains_channel "$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}, got $(registry_plan_channels)" fail_msg "$msg" fi done @@ -304,16 +352,16 @@ assert_disabled_channels() { local expected="$1" local context="$2" local channel msg value - value="$(registry_field disabledChannels)" + value="$(registry_plan_disabled_channels)" 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}, got ${value}" fail_msg "$msg" fi done @@ -333,9 +381,15 @@ 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 config = {}; +for (const channel of Array.isArray(plan.channels) ? plan.channels : []) { + for (const input of Array.isArray(channel?.inputs) ? channel.inputs : []) { + if (input?.kind === "config" && typeof input.sourceEnv === "string") { + config[input.sourceEnv] = input.value; + } + } } for (let i = 0; i < pairs.length; i += 2) { const key = pairs[i]; @@ -352,10 +406,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 +659,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_contains_channel "$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 da94723ffb..4ebe75a7be 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -161,12 +161,18 @@ process.stdout.write(JSON.stringify(v ?? 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_contains_channel() { + local item="$1" + if [ ! -f "$REGISTRY" ]; then + return 1 + fi + node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, item] = process.argv.slice(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 === item) ? 0 : 1); +' "$REGISTRY" "$SANDBOX_NAME" "$item" 2>/dev/null } assert_openclaw_config_activation() { @@ -899,10 +905,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_contains_channel "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 ($(registry_field messaging))" 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..5efa5cba46 100755 --- a/test/e2e/test-rebuild-hermes.sh +++ b/test/e2e/test-rebuild-hermes.sh @@ -247,6 +247,85 @@ 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() +messaging_plan = { + 'schemaVersion': 1, + 'sandboxName': '${SANDBOX_NAME}', + 'agent': 'hermes', + 'workflow': 'rebuild', + 'channels': [{ + 'channelId': 'discord', + 'displayName': 'Discord', + 'authMode': 'token-paste', + 'active': True, + 'selected': True, + 'configured': True, + 'disabled': False, + 'inputs': [{ + 'channelId': 'discord', + 'inputId': 'botToken', + 'kind': 'secret', + 'required': True, + 'sourceEnv': 'DISCORD_BOT_TOKEN', + 'credentialAvailable': True, + }], + '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': [{ + 'channelId': 'discord', + 'presetName': 'discord', + 'policyKeys': ['discord'], + 'source': 'manifest', + }], + }, + 'agentRender': [ + { + 'channelId': 'discord', + 'renderId': 'discord-hermes-env', + 'agent': 'hermes', + 'target': '~/.hermes/.env', + 'kind': 'env-lines', + 'lines': [ + 'DISCORD_BOT_TOKEN=${DISCORD_PLACEHOLDER}', + 'DISCORD_ALLOW_ALL_USERS=true', + ], + 'templateRefs': [], + }, + { + 'channelId': 'discord', + 'renderId': 'discord-hermes-config', + 'agent': 'hermes', + 'target': '~/.hermes/config.yaml', + 'kind': 'json-fragment', + 'path': 'discord', + 'value': { + 'require_mention': False, + 'free_response_channels': '', + 'allowed_channels': '', + 'auto_thread': True, + 'reactions': True, + 'channel_prompts': {}, + }, + 'templateRefs': [], + }, + ], + 'buildSteps': [], + 'stateUpdates': [], + 'healthChecks': [], +} reg = {'sandboxes': {'${SANDBOX_NAME}': { 'name': '${SANDBOX_NAME}', 'createdAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)', @@ -257,9 +336,9 @@ reg = {'sandboxes': {'${SANDBOX_NAME}': { 'policyTier': None, 'agent': 'hermes', 'agentVersion': '${OLD_HERMES_REGISTRY_VERSION}', - 'messagingChannels': ['discord'], + 'messaging': {'schemaVersion': 1, 'plan': messaging_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 +353,7 @@ except Exception: sess['sandboxName'] = '${SANDBOX_NAME}' sess['agent'] = 'hermes' sess['status'] = 'complete' -sess['messagingChannels'] = ['discord'] +sess['messagingPlan'] = messaging_plan with open(sess_path, 'w') as f: json.dump(sess, f, indent=2) print('Registry and session updated') diff --git a/test/helpers/messaging-plan-fixtures.ts b/test/helpers/messaging-plan-fixtures.ts new file mode 100644 index 0000000000..c7e69e30e6 --- /dev/null +++ b/test/helpers/messaging-plan-fixtures.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingAgentId, + MessagingCompilerWorkflow, + SandboxMessagingPlan, +} from "../../src/lib/messaging/manifest"; +import type { SandboxMessagingState } from "../../src/lib/state/registry"; + +export function makeMessagingPlan( + sandboxName: string, + channels: readonly string[], + disabledChannels: readonly string[] = [], + agent: string = "openclaw", + workflow: string = "onboard", + config: Record = {}, +): SandboxMessagingPlan { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + sandboxName, + agent: agent as MessagingAgentId, + workflow: workflow as MessagingCompilerWorkflow, + channels: channels.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: + channelId === "telegram" + ? Object.entries(config).map(([sourceEnv, value]) => ({ + channelId, + inputId: sourceEnv, + kind: "config", + required: false, + sourceEnv, + value, + })) + : [], + hooks: [], + })), + disabledChannels, + credentialBindings: [], + networkPolicy: { presets: channels.filter((channel) => !disabled.has(channel)), entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +export function makeMessagingState( + sandboxName: string, + channels: readonly string[], + disabledChannels: readonly string[] = [], + agent: string = "openclaw", + workflow: string = "onboard", + config: Record = {}, +): SandboxMessagingState { + return { + schemaVersion: 1, + plan: makeMessagingPlan(sandboxName, channels, disabledChannels, agent, workflow, config), + }; +} + +export function encodedMessagingPlan(plan: unknown): string { + return JSON.stringify(Buffer.from(JSON.stringify(plan), "utf8").toString("base64")); +} + +export function registeredChannels( + entry: { messaging?: { plan?: { channels?: Array<{ channelId: string }> } } } | undefined, +) { + return entry?.messaging?.plan?.channels?.map((channel) => channel.channelId); +} + +export function registeredDisabledChannels( + entry: { messaging?: { plan?: { disabledChannels?: string[] } } } | undefined, +) { + return entry?.messaging?.plan?.disabledChannels; +} diff --git a/test/helpers/sandbox-handler-fixtures.ts b/test/helpers/sandbox-handler-fixtures.ts new file mode 100644 index 0000000000..ddee12c64a --- /dev/null +++ b/test/helpers/sandbox-handler-fixtures.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; +import { makeMessagingPlan } from "./messaging-plan-fixtures"; +import { + createSession, + type Session, + type SessionUpdates, +} from "../../src/lib/state/onboard-session"; +import type { SandboxStateOptions } from "../../src/lib/onboard/machine/handlers/sandbox"; + +export type Gpu = { type: string } | null; +export type Agent = { displayName?: string } | null; +export type WebSearchConfig = { fetchEnabled: true }; +export type MessagingChannelConfig = Record; +export type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; +export type ResourceProfile = { cpu: string; memory: string }; + +export function makeMinimalPlan( + sandboxName: string, + agent: "openclaw" | "hermes" = "openclaw", + channelIds: readonly string[] = [], +) { + return makeMessagingPlan(sandboxName, channelIds, [], agent); +} + +export function createDeps( + overrides: Partial< + SandboxStateOptions< + Gpu, + Agent, + WebSearchConfig, + MessagingChannelConfig, + SandboxGpuConfig, + ResourceProfile + >["deps"] + > = {}, +) { + let session = createSession(); + const calls = { + note: vi.fn(), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + session = mutator(session) ?? session; + return session; + }), + removeSandbox: vi.fn(), + repairSandbox: vi.fn(), + validateBrave: vi.fn(async () => "brave-key"), + isBackToSelection: vi.fn(() => false), + configureWebSearch: vi.fn(async () => null as WebSearchConfig | null), + startStep: vi.fn(async () => undefined), + getRecordedChannels: vi.fn(() => null), + setupMessaging: vi.fn(async () => [] as string[]), + promptName: vi.fn(async () => "my-assistant"), + selectResourceProfile: vi.fn(async () => null as ResourceProfile | null), + stopStale: vi.fn(), + createSandbox: vi.fn(async () => "my-assistant"), + updateSandbox: vi.fn(), + complete: vi.fn(async () => createSession()), + skipped: vi.fn(), + recordSkip: vi.fn(async () => createSession()), + repairEvent: vi.fn(async () => createSession()), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; + return { + calls, + deps: { + resolvePath: (value: string) => `/abs/${value}`, + agentSupportsWebSearch: () => true, + note: calls.note, + updateSession: calls.updateSession, + getStoredMessagingChannelConfig: () => null, + hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, + messagingChannelConfigsEqual: () => true, + getSandboxReuseState: () => "missing", + computeTelegramRequireMention: () => null, + hasSandboxGpuDrift: () => false, + hasWechatConfigDrift: () => false, + getSandboxHermesToolGateways: () => [], + normalizeHermesToolGatewaySelections: (value: unknown) => + Array.isArray(value) ? (value as string[]) : [], + stringSetsEqual: (left: string[], right: string[]) => + left.length === right.length && left.every((value) => right.includes(value)), + removeSandboxFromRegistry: calls.removeSandbox, + repairRecordedSandbox: calls.repairSandbox, + ensureValidatedBraveSearchCredential: calls.validateBrave, + isBackToSelection: calls.isBackToSelection, + configureWebSearch: calls.configureWebSearch, + startRecordedStep: calls.startStep, + getRecordedMessagingChannelsForResume: calls.getRecordedChannels, + getSandboxMessagingChannels: () => ["telegram"], + setupMessagingChannels: calls.setupMessaging, + readMessagingPlanFromEnv: () => null, + writePlanToEnv: () => undefined, + getRegistrySandboxMessagingPlan: () => null, + promptValidatedSandboxName: calls.promptName, + selectResourceProfileForSandbox: calls.selectResourceProfile, + stopStaleDashboardListenersForSandbox: calls.stopStale, + listRegistrySandboxes: () => ({ sandboxes: [{ name: "old" }] }), + createSandbox: calls.createSandbox, + updateSandboxRegistry: calls.updateSandbox, + getSandboxAgentRegistryFields: () => ({ agent: null }), + recordStepComplete: calls.complete, + toSessionUpdates: (updates: Record) => updates as SessionUpdates, + skippedStepMessage: calls.skipped, + recordStateSkipped: calls.recordSkip, + recordRepairEvent: calls.repairEvent, + error: calls.error, + exitProcess: calls.exit, + ...overrides, + }, + getSession: () => session, + }; +} + +export function baseOptions( + deps: SandboxStateOptions< + Gpu, + Agent, + WebSearchConfig, + MessagingChannelConfig, + SandboxGpuConfig, + ResourceProfile + >["deps"], + session: Session | null = createSession(), +): SandboxStateOptions< + Gpu, + Agent, + WebSearchConfig, + MessagingChannelConfig, + SandboxGpuConfig, + ResourceProfile +> { + return { + resume: false, + fresh: false, + resumeAgentChanged: false, + session, + sandboxName: null, + model: "model", + provider: "provider", + nimContainer: null, + webSearchConfig: null, + selectedMessagingChannels: [], + fromDockerfile: null, + agent: null, + gpu: { type: "nvidia" }, + preferredInferenceApi: "openai-completions", + sandboxGpuConfig: { sandboxGpuEnabled: false, mode: "0" }, + hermesToolGateways: [], + controlUiPort: null, + rootDir: "/repo", + deps, + }; +} diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index a1fdb66e61..ceabc7c712 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2171,8 +2171,8 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", - NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", + NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.05", + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "3", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "0.05", NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "0.25", }, diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 85a2d89907..f36c5126f3 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -10,6 +10,7 @@ import { pathToFileURL } from "node:url"; import { describe, it } from "vitest"; import YAML from "yaml"; +import * as messagingFixtures from "./helpers/messaging-plan-fixtures"; type CommandEntry = { command: string; @@ -122,6 +123,7 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token-value"; process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; + process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; process.env.KUBECONFIG = "/tmp/host-kubeconfig"; process.env.SSH_AUTH_SOCK = "/tmp/host-ssh-agent.sock"; await setupMessagingChannels(null, null, "my-assistant"); @@ -520,7 +522,7 @@ const commands = []; const registerCalls = []; registry.registerSandbox({ name: "my-assistant", - messagingChannels: ["discord", "slack"], + messaging: ${JSON.stringify(messagingFixtures.makeMessagingState("my-assistant", ["discord", "slack"]))}, }); runner.run = (command, opts = {}) => { const normalized = _n(command); @@ -583,6 +585,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 = ${messagingFixtures.encodedMessagingPlan(messagingFixtures.makeMessagingPlan("my-assistant", ["discord", "slack"]))}; const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["discord", "slack"], ); @@ -643,7 +646,10 @@ const { createSandbox } = require(${onboardPath}); assert.ok(channelsLine, "expected messaging build arg in Dockerfile"); const channels = JSON.parse(Buffer.from(channelsLine.split("=")[1], "base64").toString()); assert.deepEqual(channels, ["discord", "slack"]); - assert.deepEqual(payload.registerCalls[0]?.messagingChannels, ["discord", "slack"]); + assert.deepEqual(messagingFixtures.registeredChannels(payload.registerCalls[0]), [ + "discord", + "slack", + ]); }); it("preserves disabled channels in the registry after a recreate so `channels start` can re-enable them (#3381)", { @@ -684,8 +690,7 @@ const commands = []; const registerCalls = []; registry.registerSandbox({ name: "my-assistant", - messagingChannels: ["telegram"], - disabledChannels: ["telegram"], + messaging: ${JSON.stringify(messagingFixtures.makeMessagingState("my-assistant", ["telegram"], ["telegram"]))}, }); runner.run = (command, opts = {}) => { const normalized = _n(command); @@ -743,6 +748,7 @@ const { createSandbox } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; delete process.env.TELEGRAM_BOT_TOKEN; + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = ${messagingFixtures.encodedMessagingPlan(messagingFixtures.makeMessagingPlan("my-assistant", ["telegram"], ["telegram"]))}; const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["telegram"], ); @@ -794,16 +800,10 @@ const { createSandbox } = require(${onboardPath}); "disabled channel's bridge must not be attached to the new sandbox", ); - assert.deepEqual( - payload.registerCalls[0]?.messagingChannels, - ["telegram"], - "registry.messagingChannels must keep the disabled-but-configured channel so `channels start` can recover it", - ); - assert.deepEqual( - payload.registerCalls[0]?.disabledChannels, - ["telegram"], - "registry.disabledChannels must round-trip through the rebuild", - ); + assert.deepEqual(messagingFixtures.registeredChannels(payload.registerCalls[0]), ["telegram"]); + assert.deepEqual(messagingFixtures.registeredDisabledChannels(payload.registerCalls[0]), [ + "telegram", + ]); }); it("bakes WhatsApp into the sandbox image without bridge providers when no messaging tokens are set", { @@ -902,6 +902,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env[key]; } } + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = ${messagingFixtures.encodedMessagingPlan(messagingFixtures.makeMessagingPlan("my-assistant", ["whatsapp"]))}; const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["whatsapp"], ); @@ -956,7 +957,9 @@ const { createSandbox } = require(${onboardPath}); assert.ok(channelsLine, "expected messaging build arg in Dockerfile"); const channels = JSON.parse(Buffer.from(channelsLine.split("=")[1], "base64").toString()); assert.deepEqual(channels, ["whatsapp"]); - assert.deepEqual(payload.registerCalls[0]?.messagingChannels, ["whatsapp"]); + assert.deepEqual(messagingFixtures.registeredChannels(payload.registerCalls[0]), [ + "whatsapp", + ]); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -999,7 +1002,7 @@ const fs = require("node:fs"); registry.registerSandbox({ name: "my-assistant", - disabledChannels: ["whatsapp"], + messaging: ${JSON.stringify(messagingFixtures.makeMessagingState("my-assistant", ["whatsapp"], ["whatsapp"]))}, }); const commands = []; @@ -1063,6 +1066,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env[key]; } } + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = ${messagingFixtures.encodedMessagingPlan(messagingFixtures.makeMessagingPlan("my-assistant", ["whatsapp"], ["whatsapp"]))}; const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["whatsapp"], ); @@ -1107,16 +1111,12 @@ const { createSandbox } = require(${onboardPath}); assert.ok(channelsLine, "expected messaging build arg in Dockerfile"); const channels = JSON.parse(Buffer.from(channelsLine.split("=")[1], "base64").toString()); assert.deepEqual(channels, [], "disabled QR channel must not be baked into the image"); - assert.deepEqual( - payload.registerCalls[0]?.messagingChannels, - ["whatsapp"], - "registry.messagingChannels must keep the disabled QR channel so `channels start` can recover it (mirrors #3381)", - ); - assert.deepEqual( - payload.registerCalls[0]?.disabledChannels, - ["whatsapp"], - "registry.disabledChannels must round-trip through the rebuild", - ); + assert.deepEqual(messagingFixtures.registeredChannels(payload.registerCalls[0]), [ + "whatsapp", + ]); + assert.deepEqual(messagingFixtures.registeredDisabledChannels(payload.registerCalls[0]), [ + "whatsapp", + ]); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 73f4e9ecfa..70b9eb6b5a 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1172,7 +1172,6 @@ registry.getSandbox = (name) => provider: "hermes-provider", model: "moonshotai/kimi-k2.6", hermesToolGateways: [], - messagingChannels: [], policies: ["nous-web"], } : null; diff --git a/test/policies.test.ts b/test/policies.test.ts index 2d4f4cc4c7..2c11b6eae4 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -732,24 +732,17 @@ exit 1 describe("applyPreset disclosure logging", () => { it("logs egress endpoints before applying", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation((message) => { + if (String(message).includes("Widening sandbox egress")) + throw new Error("__disclosure_logged__"); + }); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); }); try { - try { - policies.applyPreset("test-sandbox", "npm"); - } catch { - /* applyPreset may throw if sandbox not running — we only care about the log */ - } - const messages = logSpy.mock.calls.map((call) => - typeof call[0] === "string" ? call[0] : undefined, - ); - expect( - messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), - ).toBe(true); + expect(() => policies.applyPreset("test-sandbox", "npm")).toThrow("__disclosure_logged__"); } finally { logSpy.mockRestore(); errSpy.mockRestore(); @@ -1070,7 +1063,7 @@ exit 1 }); expect(result).toBe(false); expect(errs.join("\n")).toMatch(/[Cc]ould not read the current policy/); - expect(logs.join("\n")).not.toContain("Applied preset:"); + expect(logs.join("\n")).not.toMatch(/Widening sandbox egress|Applied preset:/); } finally { errSpy.mockRestore(); logSpy.mockRestore(); diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index aa8a61eaea..369dd63f9b 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -18,6 +18,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { makeMessagingPlan } from "./helpers/messaging-plan-fixtures"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const NODE_BIN = path.dirname(process.execPath); @@ -50,7 +51,7 @@ function createFixture(opts: { providerSelectionStatus?: string; agent?: string | null; hermesAuthMethod?: string | null; - messagingChannels?: string[] | null; + registryMessagingChannels?: string[] | null; dockerBuildExitCode?: number; providerRegistered?: boolean; }) { @@ -62,7 +63,7 @@ function createFixture(opts: { providerSelectionStatus = "complete", agent = null, hermesAuthMethod = null, - messagingChannels = null, + registryMessagingChannels = null, dockerBuildExitCode = 0, providerRegistered = true, } = opts; @@ -84,7 +85,18 @@ function createFixture(opts: { gpuEnabled: false, policies: [], agent, - messagingChannels, + messaging: registryMessagingChannels + ? { + schemaVersion: 1, + plan: makeMessagingPlan( + sandboxName, + registryMessagingChannels, + [], + agent ?? "openclaw", + "rebuild", + ), + } + : undefined, }, }, }), @@ -116,7 +128,7 @@ function createFixture(opts: { nimContainer: null, webSearchConfig: null, policyPresets: [], - messagingChannels: null, + messagingPlan: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, steps: { preflight: { @@ -352,7 +364,7 @@ describe("Issue #2273: atomic rebuild", () => { }, () => { const f = createFixture({ agent: "hermes", - messagingChannels: ["discord"], + registryMessagingChannels: ["discord"], credentialEnv: "NVIDIA_API_KEY", savedCredential: { key: "NVIDIA_API_KEY", @@ -368,7 +380,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/registry.test.ts b/test/registry.test.ts index 2b0c59f165..7c2c75ee45 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -18,6 +18,37 @@ const registry = require("../dist/lib/state/registry"); const regFile = path.join(tmpDir, ".nemoclaw", "sandboxes.json"); +function makeMessagingState(channels: string[] = ["telegram"], disabledChannels: string[] = []) { + const disabled = new Set(disabledChannels); + return { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName: "s1", + 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 +227,21 @@ describe("registry", () => { expect(data.sandboxes.tagged.imageTag).toBe("openshell/sandbox-from:1776766054"); }); - it("stores messaging channel config at registration time", () => { + it("stores messaging plan at registration time", () => { + const messaging = makeMessagingState(["telegram"]); + messaging.plan.sandboxName = "messaging"; registry.registerSandbox({ name: "messaging", - messagingChannels: ["telegram"], - messagingChannelConfig: { - TELEGRAM_ALLOWED_IDS: "123,456", - TELEGRAM_REQUIRE_MENTION: "1", - }, + messaging, }); const sb = registry.getSandbox("messaging"); - expect(sb.messagingChannelConfig).toEqual({ - TELEGRAM_ALLOWED_IDS: "123,456", - TELEGRAM_REQUIRE_MENTION: "1", - }); + expect(sb.messaging.plan.channels[0].channelId).toBe("telegram"); + 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.plan.channels[0].channelId).toBe("telegram"); + expect(data.sandboxes.messaging.messagingChannels).toBeUndefined(); + expect(data.sandboxes.messaging.messagingChannelConfig).toBeUndefined(); }); it("imageTag defaults to null when not provided", () => { @@ -239,7 +265,10 @@ describe("registry", () => { }); it("setChannelDisabled toggles a channel on and off for a sandbox", () => { - registry.registerSandbox({ name: "s1" }); + registry.registerSandbox({ + name: "s1", + messaging: makeMessagingState(["telegram", "discord"]), + }); expect(registry.getDisabledChannels("s1")).toEqual([]); expect(registry.setChannelDisabled("s1", "telegram", true)).toBe(true); @@ -252,32 +281,33 @@ describe("registry", () => { expect(registry.getDisabledChannels("s1")).toEqual(["discord"]); }); - it("setChannelDisabled clears the disabledChannels field when empty", () => { - registry.registerSandbox({ name: "s1" }); + it("setChannelDisabled clears messaging plan disabledChannels when empty", () => { + registry.registerSandbox({ name: "s1", messaging: makeMessagingState(["telegram"]) }); registry.setChannelDisabled("s1", "telegram", true); registry.setChannelDisabled("s1", "telegram", false); const persisted = JSON.parse(fs.readFileSync(regFile, "utf-8")); expect(persisted.sandboxes.s1.disabledChannels).toBeUndefined(); + expect(persisted.sandboxes.s1.messaging.plan.disabledChannels).toEqual([]); }); - it("updateSandbox clears disabledChannels when explicitly set to undefined", () => { - registry.registerSandbox({ name: "s1" }); + it("updateSandbox can clear messaging state when explicitly set to undefined", () => { + registry.registerSandbox({ name: "s1", messaging: makeMessagingState(["telegram"]) }); registry.setChannelDisabled("s1", "telegram", true); - expect(registry.updateSandbox("s1", { disabledChannels: undefined })).toBe(true); + expect(registry.updateSandbox("s1", { messaging: undefined })).toBe(true); const persisted = JSON.parse(fs.readFileSync(regFile, "utf-8")); - expect(persisted.sandboxes.s1.disabledChannels).toBeUndefined(); + expect(persisted.sandboxes.s1.messaging).toBeUndefined(); }); it("setChannelDisabled returns false when sandbox is missing", () => { expect(registry.setChannelDisabled("missing", "telegram", true)).toBe(false); }); - it("registerSandbox preserves disabledChannels when re-registering", () => { - registry.registerSandbox({ name: "s1" }); + it("registerSandbox preserves paused channels through messaging plan when re-registering", () => { + registry.registerSandbox({ name: "s1", messaging: makeMessagingState(["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..77360c41a0 100644 --- a/test/repro-2201.test.ts +++ b/test/repro-2201.test.ts @@ -30,6 +30,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { makeMessagingPlan } from "./helpers/messaging-plan-fixtures"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const NODE_BIN = path.dirname(process.execPath); // need node on PATH for shebangs @@ -64,12 +65,12 @@ function createFixture({ rebuildTarget: { name: string; agent: string | null; - messagingChannelConfig?: Record | null; + messagingConfig?: Record | null; }; lastOnboarded: { name: string; agent: string | null; - messagingChannelConfig?: Record | null; + messagingConfig?: Record | null; }; fromDockerfile?: string | null; }) { @@ -91,8 +92,20 @@ function createFixture({ gpuEnabled: false, policies: [], agent: rebuildTarget.agent, - ...(rebuildTarget.messagingChannelConfig - ? { messagingChannelConfig: rebuildTarget.messagingChannelConfig } + ...(rebuildTarget.messagingConfig + ? { + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan( + rebuildTarget.name, + ["telegram"], + [], + rebuildTarget.agent ?? "openclaw", + "onboard", + rebuildTarget.messagingConfig, + ), + }, + } : {}), }, [lastOnboarded.name]: { @@ -102,8 +115,20 @@ function createFixture({ gpuEnabled: false, policies: [], agent: lastOnboarded.agent, - ...(lastOnboarded.messagingChannelConfig - ? { messagingChannelConfig: lastOnboarded.messagingChannelConfig } + ...(lastOnboarded.messagingConfig + ? { + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan( + lastOnboarded.name, + ["telegram"], + [], + lastOnboarded.agent ?? "openclaw", + "onboard", + lastOnboarded.messagingConfig, + ), + }, + } : {}), }, }, @@ -135,8 +160,16 @@ function createFixture({ nimContainer: null, webSearchConfig: null, policyPresets: [], - messagingChannels: null, - messagingChannelConfig: lastOnboarded.messagingChannelConfig ?? null, + messagingPlan: lastOnboarded.messagingConfig + ? makeMessagingPlan( + lastOnboarded.name, + ["telegram"], + [], + lastOnboarded.agent ?? "openclaw", + "onboard", + lastOnboarded.messagingConfig, + ) + : null, metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile }, steps: { preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -249,7 +282,7 @@ function runRebuild(fixture: ReturnType) { type SessionFixture = { agent?: string | null; - messagingChannelConfig?: Record | null; + messagingPlan?: { sandboxName?: string | null } | null; }; /** @@ -268,12 +301,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 +338,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 config from a stale session for another sandbox", { timeout: 60_000, }, () => { const f = createFixture({ @@ -313,14 +346,14 @@ describe("Issue #2201: rebuild syncs agent from registry, not stale session", () lastOnboarded: { name: "hermes", agent: "hermes", - messagingChannelConfig: { + messagingConfig: { TELEGRAM_ALLOWED_IDS: "999", TELEGRAM_REQUIRE_MENTION: "1", }, }, }); runRebuild(f); - expect(readSessionMessagingChannelConfig(f)).toBeNull(); + expect(readSessionMessagingPlan(f)).toBeNull(); }); }); diff --git a/test/secret-redaction.test.ts b/test/secret-redaction.test.ts index 54994db83f..03ec07a6fa 100644 --- a/test/secret-redaction.test.ts +++ b/test/secret-redaction.test.ts @@ -84,6 +84,7 @@ describe("secret redaction consistency (#1736)", () => { encoding: "utf-8", env: { ...process.env, + HOME: tmp, NEMOCLAW_NODE: process.execPath, TMPDIR: tmp, PATH: `${fakeBin}:${process.env.PATH || ""}`, @@ -139,6 +140,7 @@ describe("secret redaction consistency (#1736)", () => { encoding: "utf-8", env: { ...process.env, + HOME: tmp, NEMOCLAW_NODE: process.execPath, TMPDIR: tmp, PATH: fakeBin,