diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 4452a6974a..6960adb367 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/generate-openclaw-config.test.ts": 1941, "test/install-preflight.test.ts": 3921, "test/nemoclaw-start.test.ts": 4791, - "test/onboard-messaging.test.ts": 2043, + "test/onboard-messaging.test.ts": 2036, "test/onboard-selection.test.ts": 4767 } } diff --git a/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts b/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts index 69a26ddc5d..f0d69555c1 100644 --- a/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-credential-preflight.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; +import { makeMessagingPlan } from "../../../../test/helpers/messaging-plan-fixtures"; import { expectNoSandboxDelete } from "../../../../test/helpers/rebuild-delete-assertions"; import { createRebuildFlowHarness, @@ -57,26 +58,11 @@ function diagnostics(harness: Harness): string { return harness.errorSpy.mock.calls.flat().map(String).join("\n"); } -function makeMessagingPlan() { - return { - schemaVersion: 1, +function makeStagedHermesMessagingPlan() { + return makeMessagingPlan({ sandboxName: "alpha", agent: "hermes", - workflow: "onboard", - channels: [ - { - channelId: "discord", - displayName: "discord", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - disabledChannels: [], + channels: ["discord"], credentialBindings: [ { channelId: "discord", @@ -89,12 +75,7 @@ function makeMessagingPlan() { credentialHash: "discord-bot-token-hash", }, ], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; + }); } describe("rebuildSandbox flow: credential preflight", () => { @@ -349,7 +330,7 @@ describe("rebuildSandbox flow: credential preflight", () => { }); it("copies the staged Hermes messaging plan into the rebuild resume session", async () => { - const plan = makeMessagingPlan(); + const plan = makeStagedHermesMessagingPlan(); const harness = createRebuildFlowHarness({ sandboxEntry: { agent: "hermes", diff --git a/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts b/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts index 4c39de5d26..a66191541a 100644 --- a/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts +++ b/src/lib/onboard/machine/handlers/policies-restricted-resume.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest"; +import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; import { createSession } from "../../../state/onboard-session"; import { handlePoliciesState } from "./policies"; import { basePolicyHandlerOptions as baseOptions, createPolicyHandlerDeps as createDeps, - makeMessagingPlan, -} from "./policies-test-fixtures"; +} from "./policies-test-fixture"; // Handler-level fallback for the runtime check the advisor calls out: the // narrowest live assertion (read the actual OpenShell-applied preset list @@ -41,7 +41,7 @@ describe("handlePoliciesState — restricted resume reconciliation", () => { preparePolicyPresetResumeSelection: prepareResume, arePolicyPresetsApplied: vi.fn(() => true), getActiveSandbox: vi.fn(() => ({ - messaging: { plan: makeMessagingPlan("my-assistant", []) }, + messaging: { plan: makeMessagingPlan() }, policyTier: "restricted", })), }); diff --git a/src/lib/onboard/machine/handlers/policies-test-fixtures.ts b/src/lib/onboard/machine/handlers/policies-test-fixture.ts similarity index 77% rename from src/lib/onboard/machine/handlers/policies-test-fixtures.ts rename to src/lib/onboard/machine/handlers/policies-test-fixture.ts index 6c8aa76f57..4c5666c37e 100644 --- a/src/lib/onboard/machine/handlers/policies-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/policies-test-fixture.ts @@ -3,45 +3,12 @@ import { vi } from "vitest"; +import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import type { PoliciesStateOptions } from "./policies"; export type PolicyTestAgent = { name: string } | null; export type PolicyTestWebSearchConfig = { fetchEnabled: true }; -type MessagingPlan = NonNullable; -type MessagingChannelId = MessagingPlan["channels"][number]["channelId"]; - -export function makeMessagingPlan( - sandboxName: string, - channels: readonly MessagingChannelId[], - disabledChannels: readonly MessagingChannelId[] = [], -): MessagingPlan { - const disabled = new Set(disabledChannels); - return { - schemaVersion: 1, - sandboxName, - agent: "openclaw", - workflow: "onboard", - channels: channels.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: "token-paste", - active: !disabled.has(channelId), - selected: true, - configured: true, - disabled: disabled.has(channelId), - inputs: [], - hooks: [], - })), - disabledChannels, - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} export function createPolicyHandlerDeps( overrides: Partial["deps"]> = {}, @@ -50,7 +17,7 @@ export function createPolicyHandlerDeps( const calls = { load: vi.fn(() => session), activeSandbox: vi.fn(() => ({ - messaging: { plan: makeMessagingPlan("my-assistant", ["telegram"]) }, + messaging: { plan: makeMessagingPlan({ channels: ["telegram"] }) }, })), mergeChannels: vi.fn( (selected: string[], recorded: string[], active: string[] | null | undefined) => diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index e80cf04715..ffe307876b 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest"; +import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; import { createSession } from "../../../state/onboard-session"; import { mergePolicyMessagingChannels } from "../../messaging-policy-presets"; import { handlePoliciesState } from "./policies"; import { basePolicyHandlerOptions as baseOptions, createPolicyHandlerDeps, - makeMessagingPlan, -} from "./policies-test-fixtures"; +} from "./policies-test-fixture"; function createDeps(overrides: Parameters[0] = {}) { return createPolicyHandlerDeps({ @@ -63,7 +63,7 @@ describe("handlePoliciesState", () => { }); it("uses recorded messaging channels when no active selection exists", async () => { - const session = createSession({ messagingPlan: makeMessagingPlan("my-assistant", ["slack"]) }); + const session = createSession({ messagingPlan: makeMessagingPlan({ channels: ["slack"] }) }); const { deps, calls, setSession } = createDeps({ getActiveSandbox: vi.fn(() => ({ messaging: null })), }); diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 5946df66a5..10ed03abf4 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -8,6 +8,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeMessagingPlan } from "../../../test/helpers/messaging-plan-fixtures"; + const require = createRequire(import.meta.url); const distPath = require.resolve("./onboard-session"); const eventsDistPath = require.resolve("../onboard/machine/events"); @@ -18,8 +20,6 @@ type OnboardMachineEvent = import("../onboard/machine/events").OnboardMachineEve type LoadedSession = NonNullable>; type DebugSummary = NonNullable>; type NullableSessionUpdateKey = import("./onboard-session").NullableSessionUpdateKey; -type MessagingPlan = NonNullable; -type MessagingChannelId = MessagingPlan["channels"][number]["channelId"]; let session: OnboardSessionModule; let machineEvents: OnboardMachineEventsModule; let tmpDir: string; @@ -67,38 +67,6 @@ function normalizeLegacySession( ); } -function makeMessagingPlan( - sandboxName: string, - channels: readonly MessagingChannelId[] = [], - disabledChannels: readonly MessagingChannelId[] = [], -): MessagingPlan { - const disabled = new Set(disabledChannels); - return { - schemaVersion: 1, - sandboxName, - agent: "openclaw", - workflow: "onboard", - channels: channels.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: "token-paste", - active: !disabled.has(channelId), - selected: true, - configured: true, - disabled: disabled.has(channelId), - inputs: [], - hooks: [], - })), - disabledChannels: [...disabledChannels], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - beforeEach(() => { // Recreate tmpDir per test so lock artifacts (and any other on-disk state) // from a previous test cannot leak into this one. Without this, malformed @@ -740,7 +708,10 @@ describe("onboard session", () => { it("persists messagingPlan across save/load roundtrips", () => { const created = session.createSession(); - created.messagingPlan = makeMessagingPlan("my-assistant", ["telegram", "slack"], ["slack"]); + created.messagingPlan = makeMessagingPlan({ + channels: ["telegram", "slack"], + disabledChannels: ["slack"], + }); session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); @@ -763,10 +734,10 @@ describe("onboard session", () => { it("writes compact messagingPlan derived fields to onboard-session.json", () => { const created = session.createSession(); created.messagingPlan = { - ...makeMessagingPlan("my-assistant", ["telegram"]), + ...makeMessagingPlan({ channels: ["telegram"] }), channels: [ { - ...makeMessagingPlan("my-assistant", ["telegram"]).channels[0], + ...makeMessagingPlan({ channels: ["telegram"] }).channels[0], hooks: [ { channelId: "telegram", @@ -820,7 +791,7 @@ describe("onboard session", () => { JSON.stringify({ ...created, messagingPlan: { - ...makeMessagingPlan("my-assistant", ["telegram"]), + ...makeMessagingPlan({ channels: ["telegram"] }), disabledChannels: ["telegram", 42, null], }, }), @@ -836,7 +807,10 @@ describe("onboard session", () => { // place this can survive, because rebuild destroys the registry entry // before `onboard --resume` reads it back. const created = session.createSession(); - created.messagingPlan = makeMessagingPlan("my-assistant", ["telegram"], ["telegram"]); + created.messagingPlan = makeMessagingPlan({ + channels: ["telegram"], + disabledChannels: ["telegram"], + }); session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); @@ -850,7 +824,7 @@ describe("onboard session", () => { it("filterSafeUpdates passes through messagingPlan and accepts explicit null clear", () => { session.saveSession(session.createSession()); - const plan = makeMessagingPlan("my-assistant", ["discord"]); + const plan = makeMessagingPlan({ channels: ["discord"] }); session.markStepComplete("provider_selection", { messagingPlan: plan }); expect(requireLoadedSession(session.loadSession()).messagingPlan).toMatchObject({ sandboxName: "my-assistant", @@ -1291,7 +1265,7 @@ describe("onboard session", () => { }); it("round-trips messagingPlan through normalizeSession", () => { - const plan = makeMessagingPlan("my-assistant", ["telegram"]); + const plan = makeMessagingPlan({ channels: ["telegram"] }); const created = session.createSession({ messagingPlan: plan }); expect(created.messagingPlan).toEqual(plan); const saved = session.saveSession(created); @@ -1305,7 +1279,7 @@ describe("onboard session", () => { it("filterSafeUpdates preserves messagingPlan field", () => { session.saveSession(session.createSession()); - const plan = makeMessagingPlan("my-assistant", ["slack", "discord"]); + const plan = makeMessagingPlan({ channels: ["slack", "discord"] }); session.markStepComplete("provider_selection", { messagingPlan: plan, }); @@ -1387,7 +1361,7 @@ describe("onboard session", () => { }); it("creates a session with a messagingPlan override", () => { - const plan = makeMessagingPlan("my-assistant", ["telegram", "slack"]); + const plan = makeMessagingPlan({ channels: ["telegram", "slack"] }); const created = session.createSession({ messagingPlan: plan }); expect(created.messagingPlan).toEqual(plan); expect(created.provider).toBeNull(); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 562d982936..9792fe7b3e 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -12,7 +12,11 @@ import * as httpProbe from "../src/lib/adapters/http/probe"; import * as runtime from "../src/lib/adapters/openshell/runtime"; import * as store from "../src/lib/credentials/store"; import * as gatewayRuntime from "../src/lib/gateway-runtime-action"; -import { MessagingWorkflowPlanner, type SandboxMessagingPlan } from "../src/lib/messaging"; +import { + type MessagingAgentId, + MessagingWorkflowPlanner, + type SandboxMessagingPlan, +} from "../src/lib/messaging"; import { getMessagingChannelConfigEnvKeys, MESSAGING_CHANNEL_CONFIG_ENV_KEYS, @@ -22,6 +26,7 @@ import { getChannelTokenKeys, knownChannelNames, listChannels } from "../src/lib import * as onboardSession from "../src/lib/state/onboard-session"; import type { SandboxEntry } from "../src/lib/state/registry"; import * as registry from "../src/lib/state/registry"; +import { makeMessagingPlan } from "./helpers/messaging-plan-fixtures"; class ExitError extends Error { constructor(public readonly code: number | undefined) { @@ -41,41 +46,8 @@ const TEST_ENV_KEYS = new Set([ ]); const originalProcessEnv = { ...process.env }; -function makeMessagingPlan( - sandboxName: string, - channelIds: string[] = [], - disabledChannels: string[] = [], - agent = "openclaw", -): SandboxMessagingPlan { - const disabled = new Set(disabledChannels); - return { - schemaVersion: 1, - sandboxName, - agent: agent as SandboxMessagingPlan["agent"], - workflow: "onboard", - channels: channelIds.map((channelId) => ({ - channelId: channelId as SandboxMessagingPlan["channels"][number]["channelId"], - displayName: channelId, - authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste", - active: !disabled.has(channelId), - selected: true, - configured: true, - disabled: disabled.has(channelId), - inputs: [], - hooks: [], - })), - disabledChannels: disabledChannels as SandboxMessagingPlan["disabledChannels"], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - function makeTelegramConfigPlan(requireMention: "0" | "1"): SandboxMessagingPlan { - const plan = makeMessagingPlan("test-sb", ["telegram"]); + const plan = makeMessagingPlan({ sandboxName: "test-sb", channels: ["telegram"] }); return { ...plan, channels: plan.channels.map((channel) => ({ @@ -107,7 +79,12 @@ function makeRegistryEntry( ? { messaging: { schemaVersion: 1, - plan: makeMessagingPlan("test-sb", channelIds, disabledChannels, agent), + plan: makeMessagingPlan({ + sandboxName: "test-sb", + channels: channelIds, + disabledChannels, + agent, + }), }, } : {}), @@ -154,7 +131,7 @@ let curlProbeSpy: MockInstance; let execSpy: MockInstance; let buildPlanSpy: MockInstance; -let sandboxAgent: string; +let sandboxAgent: MessagingAgentId; let registryEntry: SandboxEntry; let appliedPresets: string[]; let presetContent: string | null; diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts index 4c276de741..30ba170b4f 100644 --- a/test/channels-remove-full-teardown.test.ts +++ b/test/channels-remove-full-teardown.test.ts @@ -17,6 +17,9 @@ import os from "node:os"; import path from "node:path"; import { describe, it } from "vitest"; +import type { MessagingAgentId } from "../src/lib/messaging"; +import { makeMessagingPlan } from "./helpers/messaging-plan-fixtures"; + const repoRoot = path.join(import.meta.dirname, ".."); // Strip messaging-channel env vars from the parent process before spawning @@ -71,13 +74,21 @@ function buildPreamble({ sshFallbackResult = null as { status: number; stdout: string; stderr: string } | null, }: { presetNamesApplied?: string[]; - sandboxAgent?: string; + sandboxAgent?: MessagingAgentId; channelInRegistry?: string; sandboxExecResult?: { status: number; stdout: string; stderr: string } | null; sshFallbackResult?: { status: number; stdout: string; stderr: string } | null; } = {}): string { const j = (p: string) => JSON.stringify(path.join(repoRoot, "src", "lib", p.replace(/\.js$/, ".ts"))); + const messagingPlanLiteral = () => + JSON.stringify( + makeMessagingPlan({ + sandboxName: "test-sb", + agent: sandboxAgent, + channels: channelInRegistry ? [channelInRegistry] : [], + }), + ); return String.raw` const resolver = require(${j("adapters/openshell/resolve.js")}); resolver.resolveOpenshell = () => "/fake/openshell"; @@ -113,35 +124,6 @@ credentials.prompt = async (msg) => { throw new Error("unexpected prompt: " + ms const onboard = require(${j("onboard.js")}); onboard.isNonInteractive = () => true; -const initialChannel = ${JSON.stringify(channelInRegistry)}; -function makeMessagingPlan(channelIds = initialChannel ? [initialChannel] : [], disabledChannels = []) { - const disabled = new Set(disabledChannels); - return { - schemaVersion: 1, - sandboxName: "test-sb", - agent: ${JSON.stringify(sandboxAgent)}, - workflow: "onboard", - channels: channelIds.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste", - active: !disabled.has(channelId), - selected: true, - configured: true, - disabled: disabled.has(channelId), - inputs: [], - hooks: [], - })), - disabledChannels, - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - const onboardSession = require(${j("state/onboard-session.js")}); const sessionStore = { sandboxName: "test-sb", @@ -159,7 +141,7 @@ const sessionStore = { routerPid: null, routerCredentialHash: null, policyTier: null, - messagingPlan: makeMessagingPlan(), + messagingPlan: ${messagingPlanLiteral()}, hermesToolGateways: [], wechatConfig: null, }; @@ -171,7 +153,7 @@ const registryUpdates = []; registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(sandboxAgent)}, - messaging: { schemaVersion: 1, plan: makeMessagingPlan() }, + messaging: { schemaVersion: 1, plan: ${messagingPlanLiteral()} }, policies: ${JSON.stringify(presetNamesApplied)}, }); registry.updateSandbox = (name, updates) => { diff --git a/test/helpers/messaging-plan-fixtures.test.ts b/test/helpers/messaging-plan-fixtures.test.ts new file mode 100644 index 0000000000..8c6a82bedb --- /dev/null +++ b/test/helpers/messaging-plan-fixtures.test.ts @@ -0,0 +1,45 @@ +// 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 { + MessagingChannelId, + SandboxMessagingCredentialBindingPlan, +} from "../../src/lib/messaging"; +import { makeMessagingPlan } from "./messaging-plan-fixtures"; + +describe("makeMessagingPlan", () => { + it("isolates nested plan state from later results and caller inputs (#8357)", () => { + const channels: MessagingChannelId[] = ["telegram"]; + const disabledChannels: MessagingChannelId[] = ["telegram"]; + const credentialBindings: SandboxMessagingCredentialBindingPlan[] = [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "my-assistant-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "telegram-bot-token-hash", + }, + ]; + const options = { channels, disabledChannels, credentialBindings }; + + const first = makeMessagingPlan(options); + const second = makeMessagingPlan(options); + + (first.disabledChannels as MessagingChannelId[]).push("discord"); + (first.channels[0] as { displayName: string }).displayName = "mutated"; + (first.channels[0].inputs as unknown[]).push({ inputId: "mutated" }); + (first.credentialBindings[0] as { providerName: string }).providerName = "mutated"; + + expect(second.disabledChannels).toEqual(["telegram"]); + expect(second.channels[0]).toMatchObject({ displayName: "telegram", inputs: [] }); + expect(second.credentialBindings[0].providerName).toBe("my-assistant-telegram-bridge"); + expect(channels).toEqual(["telegram"]); + expect(disabledChannels).toEqual(["telegram"]); + expect(credentialBindings[0].providerName).toBe("my-assistant-telegram-bridge"); + }); +}); diff --git a/test/helpers/messaging-plan-fixtures.ts b/test/helpers/messaging-plan-fixtures.ts index ba1d058c76..fe42a80052 100644 --- a/test/helpers/messaging-plan-fixtures.ts +++ b/test/helpers/messaging-plan-fixtures.ts @@ -3,74 +3,156 @@ import assert from "node:assert/strict"; -type MessagingPlanChannel = { +import type { + ChannelAuthMode, + MessagingAgentId, + MessagingChannelId, + MessagingCompilerWorkflow, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingPlan, +} from "../../src/lib/messaging"; + +type DockerfilePlanChannel = { channelId?: unknown; active?: unknown; }; -type MessagingPlan = { - channels?: MessagingPlanChannel[]; +type DockerfilePlan = { + channels?: DockerfilePlanChannel[]; }; -export const inlineMessagingPlanHelper = String.raw` -function makeMessagingPlan(channelIds, disabledChannels = []) { - const disabled = new Set(disabledChannels); - const credentialBindings = { - discord: [{ channelId: "discord", credentialId: "discordBotToken", sourceInput: "botToken", providerName: "my-assistant-discord-bridge", providerEnvKey: "DISCORD_BOT_TOKEN", placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", credentialAvailable: true, credentialHash: "discord-bot-token-hash" }], - slack: [ - { channelId: "slack", credentialId: "slackBotToken", sourceInput: "botToken", providerName: "my-assistant-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", credentialAvailable: true, credentialHash: "slack-bot-token-hash" }, - { channelId: "slack", credentialId: "slackAppToken", sourceInput: "appToken", providerName: "my-assistant-slack-app", providerEnvKey: "SLACK_APP_TOKEN", placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", credentialAvailable: true, credentialHash: "slack-app-token-hash" }, - ], - telegram: [{ channelId: "telegram", credentialId: "telegramBotToken", sourceInput: "botToken", providerName: "my-assistant-telegram-bridge", providerEnvKey: "TELEGRAM_BOT_TOKEN", placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", credentialAvailable: true, credentialHash: "telegram-bot-token-hash" }], - }; - return { schemaVersion: 1, sandboxName: "my-assistant", agent: "openclaw", workflow: "onboard", channels: channelIds.map((channelId) => ({ channelId, displayName: channelId, authMode: channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste", active: !disabled.has(channelId), selected: true, configured: true, disabled: disabled.has(channelId), inputs: [], hooks: [] })), disabledChannels, credentialBindings: channelIds.flatMap((channelId) => credentialBindings[channelId] || []), networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], stateUpdates: [], healthChecks: [] }; -} -`.trim(); - -function readMessagingPlanFromDockerfile(dockerfileContent: string | undefined): MessagingPlan { - assert.ok(dockerfileContent, "expected Dockerfile content"); - const prefix = "ARG NEMOCLAW_MESSAGING_PLAN_B64="; - const line = dockerfileContent.split("\n").find((entry) => entry.startsWith(prefix)); - assert.ok(line, "expected messaging plan build arg in Dockerfile"); - return JSON.parse(Buffer.from(line.slice(prefix.length), "base64").toString("utf8")); +export interface TestMessagingPlanOptions { + readonly sandboxName?: string; + readonly channels?: readonly MessagingChannelId[]; + readonly disabledChannels?: readonly MessagingChannelId[]; + readonly agent?: MessagingAgentId; + readonly workflow?: MessagingCompilerWorkflow; + readonly authMode?: ChannelAuthMode; + readonly credentialBindings?: readonly SandboxMessagingCredentialBindingPlan[]; } -export function activeChannelsFromDockerfile(dockerfileContent: string | undefined): string[] { - const plan = readMessagingPlanFromDockerfile(dockerfileContent); - return (plan.channels ?? []) - .filter((channel) => channel.active === true && typeof channel.channelId === "string") - .map((channel) => String(channel.channelId)) - .sort(); -} - -export function encodeTestMessagingPlan( - channels: ReadonlyArray<{ readonly channelId: string; readonly active: boolean }>, -): string { - const plan = { +export function makeMessagingPlan(options: TestMessagingPlanOptions = {}): SandboxMessagingPlan { + const { + sandboxName = "my-assistant", + channels = [], + disabledChannels = [], + agent = "openclaw", + workflow = "onboard", + authMode, + credentialBindings = [], + } = options; + const disabled = new Set(disabledChannels); + return { schemaVersion: 1, - sandboxName: "my-assistant", - agent: "openclaw", - workflow: "onboard", - channels: channels.map(({ channelId, active }) => ({ + sandboxName, + agent, + workflow, + channels: channels.map((channelId) => ({ channelId, displayName: channelId, - authMode: "none", - active, + authMode: authMode ?? (channelId === "whatsapp" ? "in-sandbox-qr" : "token-paste"), + active: !disabled.has(channelId), selected: true, configured: true, - disabled: !active, + disabled: disabled.has(channelId), inputs: [], hooks: [], })), - disabledChannels: channels - .filter((channel) => !channel.active) - .map((channel) => channel.channelId), - credentialBindings: [], + disabledChannels: [...disabledChannels], + credentialBindings: credentialBindings.map((binding) => ({ ...binding })), networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], stateUpdates: [], healthChecks: [], }; +} + +export function encodeMessagingPlan(plan: SandboxMessagingPlan): string { return Buffer.from(JSON.stringify(plan), "utf8").toString("base64"); } + +const CREDENTIAL_BINDINGS: Record = { + discord: [ + { + channelId: "discord", + credentialId: "discordBotToken", + sourceInput: "botToken", + providerName: "my-assistant-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "discord-bot-token-hash", + }, + ], + slack: [ + { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "my-assistant-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "slack-bot-token-hash", + }, + { + channelId: "slack", + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: "my-assistant-slack-app", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + credentialAvailable: true, + credentialHash: "slack-app-token-hash", + }, + ], + telegram: [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "my-assistant-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "telegram-bot-token-hash", + }, + ], +}; + +export function encodeMessagingPlanForChannels( + channels: readonly MessagingChannelId[], + disabledChannels: readonly MessagingChannelId[] = [], +): string { + return encodeMessagingPlan(makeMessagingPlan({ channels, disabledChannels, authMode: "none" })); +} + +export function messagingPlanLiteral( + channels: readonly MessagingChannelId[], + disabledChannels: readonly MessagingChannelId[] = [], +): string { + return JSON.stringify( + makeMessagingPlan({ + channels, + disabledChannels, + credentialBindings: channels.flatMap((channelId) => CREDENTIAL_BINDINGS[channelId] ?? []), + }), + ); +} + +function readMessagingPlanFromDockerfile(dockerfileContent: string | undefined): DockerfilePlan { + assert.ok(dockerfileContent, "expected Dockerfile content"); + const prefix = "ARG NEMOCLAW_MESSAGING_PLAN_B64="; + const line = dockerfileContent.split("\n").find((entry) => entry.startsWith(prefix)); + assert.ok(line, "expected messaging plan build arg in Dockerfile"); + return JSON.parse(Buffer.from(line.slice(prefix.length), "base64").toString("utf8")); +} + +export function activeChannelsFromDockerfile(dockerfileContent: string | undefined): string[] { + const plan = readMessagingPlanFromDockerfile(dockerfileContent); + return (plan.channels ?? []) + .filter((channel) => channel.active === true && typeof channel.channelId === "string") + .map((channel) => String(channel.channelId)) + .sort(); +} diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 33e2491530..8d71f0130b 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -14,8 +14,8 @@ import YAML from "yaml"; import { activeChannelsFromDockerfile, - encodeTestMessagingPlan, - inlineMessagingPlanHelper, + encodeMessagingPlanForChannels, + messagingPlanLiteral, } from "./helpers/messaging-plan-fixtures"; import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; @@ -498,10 +498,7 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify( path.join(repoRoot, "src", "lib", "credentials", "store.ts"), ); - const messagingPlanB64 = encodeTestMessagingPlan([ - { channelId: "discord", active: true }, - { channelId: "slack", active: true }, - ]); + const messagingPlanB64 = encodeMessagingPlanForChannels(["discord", "slack"]); fs.mkdirSync(fakeBin, { recursive: true }); writeOkOpenshell(fakeBin); @@ -518,10 +515,9 @@ const fs = require("node:fs"); const commands = []; const registerCalls = []; -${inlineMessagingPlanHelper} registry.registerSandbox({ name: "my-assistant", - messaging: { schemaVersion: 1, plan: makeMessagingPlan(["discord", "slack"]) }, + messaging: { schemaVersion: 1, plan: ${messagingPlanLiteral(["discord", "slack"])} }, }); runner.run = (command, opts = {}) => { const normalized = _n(command); @@ -584,7 +580,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; - process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["discord", "slack"]))).toString("base64"); + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(${messagingPlanLiteral(["discord", "slack"])})).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["discord", "slack"], ); @@ -663,7 +659,7 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify( path.join(repoRoot, "src", "lib", "credentials", "store.ts"), ); - const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "telegram", active: false }]); + const messagingPlanB64 = encodeMessagingPlanForChannels(["telegram"], ["telegram"]); fs.mkdirSync(fakeBin, { recursive: true }); writeOkOpenshell(fakeBin); @@ -680,10 +676,9 @@ const fs = require("node:fs"); const commands = []; const registerCalls = []; -${inlineMessagingPlanHelper} registry.registerSandbox({ name: "my-assistant", - messaging: { schemaVersion: 1, plan: makeMessagingPlan(["telegram"], ["telegram"]) }, + messaging: { schemaVersion: 1, plan: ${messagingPlanLiteral(["telegram"], ["telegram"])} }, }); runner.run = (command, opts = {}) => { const normalized = _n(command); @@ -741,7 +736,7 @@ const { createSandbox } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; delete process.env.TELEGRAM_BOT_TOKEN; - process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["telegram"], ["telegram"]))).toString("base64"); + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(${messagingPlanLiteral(["telegram"], ["telegram"])})).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["telegram"], ); @@ -817,7 +812,7 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify( path.join(repoRoot, "src", "lib", "credentials", "store.ts"), ); - const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "whatsapp", active: true }]); + const messagingPlanB64 = encodeMessagingPlanForChannels(["whatsapp"]); fs.mkdirSync(fakeBin, { recursive: true }); writeOkOpenshell(fakeBin); @@ -834,7 +829,6 @@ const fs = require("node:fs"); const commands = []; const registerCalls = []; -${inlineMessagingPlanHelper} runner.run = (command, opts = {}) => { const normalized = _n(command); commands.push({ command: normalized, env: opts.env || null }); @@ -894,7 +888,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env[key]; } } - process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["whatsapp"]))).toString("base64"); + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(${messagingPlanLiteral(["whatsapp"])})).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["whatsapp"], ); @@ -968,7 +962,7 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify( path.join(repoRoot, "src", "lib", "credentials", "store.ts"), ); - const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "whatsapp", active: false }]); + const messagingPlanB64 = encodeMessagingPlanForChannels(["whatsapp"], ["whatsapp"]); fs.mkdirSync(fakeBin, { recursive: true }); writeOkOpenshell(fakeBin); @@ -983,10 +977,9 @@ const childProcess = require("node:child_process"); const { EventEmitter } = require("node:events"); const fs = require("node:fs"); -${inlineMessagingPlanHelper} registry.registerSandbox({ name: "my-assistant", - messaging: { schemaVersion: 1, plan: makeMessagingPlan(["whatsapp"], ["whatsapp"]) }, + messaging: { schemaVersion: 1, plan: ${messagingPlanLiteral(["whatsapp"], ["whatsapp"])} }, }); const commands = []; @@ -1050,7 +1043,7 @@ const { createSandbox } = require(${onboardPath}); delete process.env[key]; } } - process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(makeMessagingPlan(["whatsapp"], ["whatsapp"]))).toString("base64"); + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(${messagingPlanLiteral(["whatsapp"], ["whatsapp"])})).toString("base64"); const sandboxName = await createSandbox( null, "gpt-5.4", "nvidia-prod", null, "my-assistant", null, ["whatsapp"], ); diff --git a/test/registry.test.ts b/test/registry.test.ts index 4e8eaff7bf..3ff892d218 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -7,6 +7,8 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; +import { makeMessagingPlan } from "./helpers/messaging-plan-fixtures"; + // Use a temp dir so tests don't touch real ~/.nemoclaw. // HOME must be set before loading registry (it reads HOME at require time), // so we use createRequire instead of a static import. @@ -18,38 +20,6 @@ const registry = require("../src/lib/state/registry"); const regFile = path.join(tmpDir, ".nemoclaw", "sandboxes.json"); -function makeMessagingPlan( - name: string, - channels: string[] = ["telegram"], - disabledChannels: string[] = [], -) { - const disabled = new Set(disabledChannels); - return { - schemaVersion: 1, - sandboxName: name, - agent: "openclaw", - workflow: "onboard", - channels: channels.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: "token-paste", - active: !disabled.has(channelId), - selected: true, - configured: true, - disabled: disabled.has(channelId), - inputs: [], - hooks: [], - })), - disabledChannels, - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - beforeEach(() => { if (fs.existsSync(regFile)) fs.unlinkSync(regFile); }); @@ -827,7 +797,7 @@ describe("registry", () => { }); it("stores messaging plan state at registration time", () => { - const basePlan = makeMessagingPlan("messaging", ["telegram"]); + const basePlan = makeMessagingPlan({ sandboxName: "messaging", channels: ["telegram"] }); const plan = { ...basePlan, channels: [ @@ -922,7 +892,7 @@ describe("registry", () => { }); it("drops legacy providerCredentialHashes when rewriting messaging rows (#3631)", () => { - const basePlan = makeMessagingPlan("messaging", ["telegram"]); + const basePlan = makeMessagingPlan({ sandboxName: "messaging", channels: ["telegram"] }); const binding = { channelId: "telegram", credentialId: "telegramBotToken", @@ -1053,7 +1023,10 @@ describe("registry", () => { it("setChannelDisabled toggles a channel on and off for a sandbox", () => { registry.registerSandbox({ name: "s1", - messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram", "discord"]) }, + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan({ sandboxName: "s1", channels: ["telegram", "discord"] }), + }, }); expect(registry.getDisabledChannels("s1")).toEqual([]); @@ -1070,7 +1043,10 @@ describe("registry", () => { it("setChannelDisabled clears plan.disabledChannels when empty", () => { registry.registerSandbox({ name: "s1", - messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram"]) }, + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan({ sandboxName: "s1", channels: ["telegram"] }), + }, }); registry.setChannelDisabled("s1", "telegram", true); registry.setChannelDisabled("s1", "telegram", false); @@ -1082,7 +1058,10 @@ describe("registry", () => { it("setChannelDisabled returns false when the channel is not configured in the plan", () => { registry.registerSandbox({ name: "s1", - messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram"]) }, + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan({ sandboxName: "s1", channels: ["telegram"] }), + }, }); expect(registry.setChannelDisabled("s1", "discord", true)).toBe(false); expect(registry.getDisabledChannels("s1")).toEqual([]); @@ -1095,7 +1074,10 @@ describe("registry", () => { it("registerSandbox preserves disabledChannels when re-registering", () => { registry.registerSandbox({ name: "s1", - messaging: { schemaVersion: 1, plan: makeMessagingPlan("s1", ["telegram"]) }, + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan({ sandboxName: "s1", channels: ["telegram"] }), + }, }); registry.setChannelDisabled("s1", "telegram", true); registry.registerSandbox({ diff --git a/test/repro-2201.test.ts b/test/repro-2201.test.ts index 3bbafd10aa..4e60fca7c9 100644 --- a/test/repro-2201.test.ts +++ b/test/repro-2201.test.ts @@ -31,6 +31,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import type { MessagingAgentId } from "../src/lib/messaging"; +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 const tmpFixtures: string[] = []; @@ -45,33 +48,6 @@ afterEach(() => { } }); -function makeMessagingPlan(sandboxName: string, agent: string | null, channelIds: string[]) { - return { - schemaVersion: 1, - sandboxName, - agent: agent ?? "openclaw", - workflow: "onboard", - channels: channelIds.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - })), - disabledChannels: [], - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - /** * Set up a temp HOME that mirrors the reporter's scenario: * @@ -90,12 +66,12 @@ function createFixture({ }: { rebuildTarget: { name: string; - agent: string | null; + agent: MessagingAgentId | null; messagingPlanChannels?: string[] | null; }; lastOnboarded: { name: string; - agent: string | null; + agent: MessagingAgentId | null; messagingPlanChannels?: string[] | null; }; fromDockerfile?: string | null; @@ -112,18 +88,18 @@ function createFixture({ fs.writeFileSync(dockerfilePath, "FROM scratch\n"); } const rebuildTargetMessagingPlan = rebuildTarget.messagingPlanChannels - ? makeMessagingPlan( - rebuildTarget.name, - rebuildTarget.agent, - rebuildTarget.messagingPlanChannels, - ) + ? makeMessagingPlan({ + sandboxName: rebuildTarget.name, + agent: rebuildTarget.agent ?? "openclaw", + channels: rebuildTarget.messagingPlanChannels, + }) : null; const lastOnboardedMessagingPlan = lastOnboarded.messagingPlanChannels - ? makeMessagingPlan( - lastOnboarded.name, - lastOnboarded.agent, - lastOnboarded.messagingPlanChannels, - ) + ? makeMessagingPlan({ + sandboxName: lastOnboarded.name, + agent: lastOnboarded.agent ?? "openclaw", + channels: lastOnboarded.messagingPlanChannels, + }) : null; // ── Registry — both sandboxes exist ───────────────────────────