From 27f34d4b92b6c3a15f0c497c0ba7619064f26209 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 1 Sep 2026 01:37:48 +0530 Subject: [PATCH 01/17] fix(messaging): stop rebuild from disabling gateway-backed channels Every rebuild re-derived which channels are configured from the host process environment. A pasted secret only ever lives in the process that captured it, so on any later run the required input reads as missing, the channel is disabled, and its credential bindings and network egress are stripped from the plan. The sandbox then comes back with no injected credential and no channel egress while its config still declares the channel enabled. Host env is no longer the only evidence. A channel whose credential the gateway still holds is kept: token channels resolve through the plan's credential bindings, and a bridge channel, which renders none, resolves its provider by name from the co-located provider profile. The check uses the provider match the create intent already uses to reuse a provider without its source secret, so it reads live OpenShell state and stores nothing. Removal still wins. Channel removal deletes both the per-credential and the bridge provider, so an absent provider keeps disabling the channel as before. The re-attach path needed no change; it was only blocked by the disabled-channel list this reconciliation was filling in. --- .../handlers/sandbox-messaging.test.ts | 125 +++++++++++++++++- .../machine/handlers/sandbox-messaging.ts | 63 ++++++++- 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 5cf50806638..17c64123759 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -226,6 +226,27 @@ function whatsappPlan(): SandboxMessagingPlan { }; } +function googlechatPlan(): SandboxMessagingPlan { + return { + ...telegramPlan(""), + channels: [ + { + channelId: "googlechat", + displayName: "Google Chat", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + // A bridge channel mints its provider from a profile, so it renders no binding. + credentialBindings: [], + }; +} + function slackPlan( botCredentialHash: string, appCredentialHash?: string, @@ -384,7 +405,7 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv, note: vi.fn(), writePlanToEnv: vi.fn() }, + { clearPlanEnv, note: vi.fn(), providerMatchesGatewayCredential: () => false, writePlanToEnv: vi.fn() }, plan, ); @@ -394,7 +415,12 @@ describe("reconcileReusedSandboxMessaging", () => { it("omits a retired host-backed channel from a reused sandbox selection (#9283)", () => { const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); - const deps = { clearPlanEnv: vi.fn(), note: vi.fn(), writePlanToEnv: vi.fn() }; + const deps = { + clearPlanEnv: vi.fn(), + note: vi.fn(), + providerMatchesGatewayCredential: () => false, + writePlanToEnv: vi.fn(), + }; vi.stubEnv("DISCORD_BOT_TOKEN", ""); const result = reconcileReusedSandboxMessaging( @@ -421,13 +447,92 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv: vi.fn(), note: vi.fn(), writePlanToEnv: vi.fn() }, + { + clearPlanEnv: vi.fn(), + note: vi.fn(), + providerMatchesGatewayCredential: () => false, + writePlanToEnv: vi.fn(), + }, plan, ); expect(result.selectedChannels).toEqual(["discord"]); }); + it("keeps a bridge channel whose gateway credential outlived the onboarding process (#10660)", () => { + const plan = googlechatPlan(); + // The pasted secret dies with its process, so a later rebuild sees empty env. + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); + const providerMatchesGatewayCredential = vi.fn(() => true); + const note = vi.fn(); + + const result = reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { clearPlanEnv: vi.fn(), note, providerMatchesGatewayCredential, writePlanToEnv: vi.fn() }, + plan, + ); + + expect(result).toEqual({ plan, selectedChannels: ["googlechat"], changed: false }); + expect(note).not.toHaveBeenCalledWith(expect.stringContaining("No host inputs configure")); + expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + "alpha-googlechat-bridge", + "google-chat-bridge", + "GOOGLE_CHAT_ACCESS_TOKEN", + ); + }); + + it("disables a bridge channel the gateway no longer holds a credential for (#10660)", () => { + const plan = googlechatPlan(); + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); + const note = vi.fn(); + + const result = reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { + clearPlanEnv: vi.fn(), + note, + providerMatchesGatewayCredential: () => false, + writePlanToEnv: vi.fn(), + }, + plan, + ); + + // `channels remove` deletes the provider, so absence is still the removal signal. + expect(result).toEqual({ + plan: withChannelDisabled(plan, "googlechat"), + selectedChannels: [], + changed: true, + }); + expect(note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure")); + }); + + it("keeps a token channel whose provider still matches at the gateway (#10660)", () => { + const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + const providerMatchesGatewayCredential = vi.fn(() => true); + + const result = reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { + clearPlanEnv: vi.fn(), + note: vi.fn(), + providerMatchesGatewayCredential, + writePlanToEnv: vi.fn(), + }, + plan, + ); + + expect(result).toEqual({ plan, selectedChannels: ["discord"], changed: false }); + expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + "alpha-discord-bridge", + expect.any(String), + "DISCORD_BOT_TOKEN", + ); + }); + it("keeps an in-sandbox QR channel in a reused sandbox selection (#9283)", () => { const plan = whatsappPlan(); vi.stubEnv("WHATSAPP_MODE", ""); @@ -436,7 +541,12 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv: vi.fn(), note: vi.fn(), writePlanToEnv: vi.fn() }, + { + clearPlanEnv: vi.fn(), + note: vi.fn(), + providerMatchesGatewayCredential: () => false, + writePlanToEnv: vi.fn(), + }, plan, ); @@ -450,7 +560,12 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( mixedChannelPlan(), { name: "openclaw" }, - { clearPlanEnv() {}, note() {}, writePlanToEnv() {} }, + { + clearPlanEnv() {}, + note() {}, + providerMatchesGatewayCredential: () => false, + writePlanToEnv() {}, + }, ); const filtered = result.plan; diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index d179382d723..8e8033af049 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -24,7 +24,11 @@ import { detectMessagingChannelsFromEnv, detectUnconfiguredMessagingChannels, } from "../../messaging-channel-setup"; -import { staticMessagingProviderTypeForChannel } from "../../messaging-bridge-provider"; +import { + bridgeProviderNamesForChannel, + messagingBridgeProfilesForAgent, + staticMessagingProviderTypeForChannel, +} from "../../messaging-bridge-provider"; import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; export { @@ -224,10 +228,49 @@ function selectionFromReusablePlan( }; } +/** + * Whether the gateway still holds this channel's credential. + * - Durable record: `channels remove` deletes the provider, a rebuild does not. + * - Same match the create intent uses to reuse a provider without its secret. + */ +function channelCredentialLivesAtGateway( + plan: SandboxMessagingPlan, + channelId: string, + deps: Pick, "providerMatchesGatewayCredential">, +): boolean { + const providerBindings = plan.credentialBindings + .filter((binding) => binding.channelId === channelId) + .map((binding) => ({ + name: binding.providerName, + type: + staticMessagingProviderTypeForChannel(channelId, plan.agent) ?? + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentialEnv: binding.providerEnvKey, + })); + // A bridge channel renders no binding; resolve its provider by name. + for (const profile of messagingBridgeProfilesForAgent(plan.agent)) { + if (profile.channelId !== channelId) continue; + for (const name of bridgeProviderNamesForChannel(plan.sandboxName, channelId, [profile])) { + providerBindings.push({ + name, + type: profile.profileId, + credentialEnv: profile.credentialKey, + }); + } + } + if (providerBindings.length === 0) return false; + return providerBindings.every(({ name, type, credentialEnv }) => + deps.providerMatchesGatewayCredential(name, type, credentialEnv), + ); +} + function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, - deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, + deps: Pick< + SandboxMessagingDeps, + "clearPlanEnv" | "note" | "providerMatchesGatewayCredential" | "writePlanToEnv" + >, ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host // input. Rebuild the host-backed selection so policy reconciliation can @@ -240,6 +283,17 @@ function filterUnconfiguredHostChannelsFromSelection( agent as Parameters[2], ), ); + // Host env is not the only evidence: + // - The pasted secret dies with the process that captured it. + // - Without this, every later rebuild strips the channel's bindings and egress. + const planForGatewayCheck = selection.plan; + if (planForGatewayCheck) { + for (const channelId of [...unconfiguredChannels]) { + if (channelCredentialLivesAtGateway(planForGatewayCheck, channelId, deps)) { + unconfiguredChannels.delete(channelId); + } + } + } if (unconfiguredChannels.size === 0) return selection; deps.note( ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, @@ -470,7 +524,10 @@ async function selectionFromRegistryPlan( export function reconcileReusedSandboxMessaging( plan: SandboxMessagingPlan | null, agent: Agent, - deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, + deps: Pick< + SandboxMessagingDeps, + "clearPlanEnv" | "note" | "providerMatchesGatewayCredential" | "writePlanToEnv" + >, recordedPlan: SandboxMessagingPlan | null = plan, ): SandboxMessagingSelection & { readonly changed: boolean } { const filtered = plan ? filterMessagingPlanForCurrentAgent(plan, agent) : null; From 3c1eded3c37f81b73418b433d015f1add74d240d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 20:45:48 -0700 Subject: [PATCH 02/17] test(messaging): cover partial Slack credential loss Signed-off-by: Apurv Kumaria --- .../handlers/sandbox-messaging.test.ts | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 17c64123759..64f8b6a7328 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -191,10 +191,7 @@ function discordPlan( }; } -function withChannelDisabled( - plan: SandboxMessagingPlan, - channelId: string, -): SandboxMessagingPlan { +function withChannelDisabled(plan: SandboxMessagingPlan, channelId: string): SandboxMessagingPlan { return { ...plan, channels: plan.channels.map((channel) => @@ -405,7 +402,12 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv, note: vi.fn(), providerMatchesGatewayCredential: () => false, writePlanToEnv: vi.fn() }, + { + clearPlanEnv, + note: vi.fn(), + providerMatchesGatewayCredential: () => false, + writePlanToEnv: vi.fn(), + }, plan, ); @@ -533,6 +535,35 @@ describe("reconcileReusedSandboxMessaging", () => { ); }); + it("disables a Slack channel when one required gateway credential is missing (#10660)", () => { + const plan = slackPlan( + hashCredential("previous-slack-bot-token") ?? "", + hashCredential("previous-slack-app-token") ?? "", + ); + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + const writePlanToEnv = vi.fn(); + const providerMatchesGatewayCredential = vi.fn( + (_name: string, _type: string, credentialEnv: string) => credentialEnv === "SLACK_BOT_TOKEN", + ); + + const result = reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { + clearPlanEnv: vi.fn(), + note: vi.fn(), + providerMatchesGatewayCredential, + writePlanToEnv, + }, + plan, + ); + const disabledPlan = withChannelDisabled(plan, "slack"); + + expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); + expect(writePlanToEnv).toHaveBeenCalledWith(disabledPlan); + }); + it("keeps an in-sandbox QR channel in a reused sandbox selection (#9283)", () => { const plan = whatsappPlan(); vi.stubEnv("WHATSAPP_MODE", ""); @@ -812,7 +843,9 @@ describe("reconcileSandboxMessaging plan authority", () => { // input; a channel the environment no longer configures must not re-enter // the selection, or its egress preset is re-applied. expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); - expect(deps.note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure discord")); + expect(deps.note).toHaveBeenCalledWith( + expect.stringContaining("No host inputs configure discord"), + ); expect(deps.clearPlanEnv).toHaveBeenCalledOnce(); expect(deps.writePlanToEnv).not.toHaveBeenCalled(); expect(result).toEqual({ plan: null, selectedChannels: [] }); From 772930886e693ce1b43559a53d9caae378949102 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 21:25:13 -0700 Subject: [PATCH 03/17] fix(messaging): preserve channel state on probe failure Signed-off-by: Apurv Kumaria --- .../enable-channels-during-onboarding.mdx | 29 ++-- src/lib/onboard.ts | 11 +- src/lib/onboard/checkpoint-replay.test.ts | 82 ++++++++++++ src/lib/onboard/checkpoint-replay.ts | 34 +++-- .../credential-provider-registration.test.ts | 57 ++++++++ .../credential-provider-registration.ts | 22 ++- .../onboard/machine/core-flow-phases.test.ts | 2 + .../handlers/sandbox-messaging.test.ts | 125 +++++++++++++++--- .../machine/handlers/sandbox-messaging.ts | 68 +++++----- .../machine/handlers/sandbox-test-fixtures.ts | 2 + src/lib/onboard/machine/handlers/sandbox.ts | 5 + 11 files changed, 354 insertions(+), 83 deletions(-) diff --git a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx index 8685b31f10f..4a7b1e8ce14 100644 --- a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx +++ b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx @@ -4,7 +4,7 @@ title: "Enable Channels During Onboarding" sidebar-title: "Enable Channels During Onboarding" description: "Select messaging channels and supply their credentials or pairing inputs during NemoClaw onboarding." -description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels, creating OpenShell bridge providers, and removing a channel by clearing its host inputs. Use when enabling or disabling channels during onboarding." +description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels, creating OpenShell bridge providers, and removing a channel with the channel lifecycle commands. Use when enabling or disabling channels during onboarding." keywords: ["nemoclaw onboard messaging", "messaging channel picker", "channel environment variables", "disable messaging channel"] content: type: "how_to" @@ -87,27 +87,22 @@ NemoClaw compiles the selected channel configuration into `NEMOCLAW_MESSAGING_PL The build applies the selected agent configuration, writes reduced runtime metadata to `/usr/local/share/nemoclaw/messaging-runtime-plan.json`, and removes the full build plan from the runtime environment. Credential bindings remain OpenShell credential placeholders, so raw messaging credentials do not enter the sandbox image or agent configuration. -## Stop Configuring a Channel +## Remove a Channel -Onboarding reads the host inputs on every run, so clearing a channel's inputs and re-onboarding removes it. -Unset the channel's environment variables. -Run onboarding again. +Use `channels remove` when you want to delete a channel's OpenShell provider, runtime configuration, and matching network policy. +This action can also clear stored pairing state. +Use `channels stop` when you want to pause the channel without deleting credentials or pairing state. -NemoClaw reports the removal and drops the channel's network policy preset with it, so the sandbox does not keep the wider egress of a channel it no longer serves. - -Expected output: - -```text - No host inputs configure discord; disabling the channel and its network egress. - [non-interactive] Applying policy presets: npm, pypi +```bash +$$nemoclaw channels remove ``` -Onboarding uses the current host inputs to determine whether a token-based channel remains configured. -`$$nemoclaw credentials reset` takes an OpenShell provider name and does not change those host inputs. +Accept the rebuild to remove the channel configuration and its network policy preset from the replacement sandbox. +Clearing the channel's host environment variables is not a removal signal when the matching provider remains in OpenShell. +Onboarding reuses that provider and keeps the channel selected, including its network egress. -A QR-paired channel such as WhatsApp is exempt. -The host holds no value that reports whether the pairing is still live, so an absent host input is not evidence that you removed the channel. -Use [`channels remove`](manage-messaging-channels) for those. +For a QR-paired channel such as WhatsApp, only `channels remove` clears the in-sandbox pairing state. +Refer to [Manage Messaging Channels](manage-messaging-channels) for channel-specific removal effects and recovery steps. ## Verify the Result diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b1eca0570db..0fb6d134516 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -893,8 +893,7 @@ const verifyDirectSandboxGpu = sandboxGpuPreflight.createDirectSandboxGpuVerifie redact, }); -const registeredCredentialProviders = - credentialProviderRegistration.createCredentialProviderRegistration({ +const registration = credentialProviderRegistration.createCredentialProviderRegistration({ root: ROOT, runOpenshell, getGatewayName: () => GATEWAY_NAME, @@ -903,9 +902,8 @@ const registeredCredentialProviders = stagedLegacyValues, migratedLegacyKeys, persistMigratedLegacyKeys, - }); -const { upsertProvider, upsertMessagingProviders, providerMatchesGatewayCredential } = - registeredCredentialProviders; +}); +const { upsertProvider, upsertMessagingProviders, providerMatchesGatewayCredential } = registration; const providerExistsInGateway = (name: string, gatewayName: string = GATEWAY_NAME) => onboardProviders.providerExistsInGateway( name, @@ -2522,7 +2520,7 @@ const sandboxCreateIntentResolver = sandboxCreateIntentResolution.createSandboxC const stageSandboxCredentialProviders = ( input: import("./onboard/credential-provider-registration").StageSandboxCredentialProvidersInput, ) => - registeredCredentialProviders.stageSandboxCredentialProviders( + registration.stageSandboxCredentialProviders( input, sandboxCreateIntentResolver.prepareCredentialProviders, ); @@ -3217,6 +3215,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { clearPlanEnv: messagingChannelSetup.clearPlanEnv, getRegistrySandboxMessagingAuthority: messagingChannelSetup.getRegistrySandboxMessagingAuthority, + inspectGatewayCredential: registration.inspectGatewayCredential, providerMatchesGatewayCredential, stageSandboxCredentialProviders, promptValidatedSandboxName, diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts index 9caab7b8e89..4c2c417bdbe 100644 --- a/src/lib/onboard/checkpoint-replay.test.ts +++ b/src/lib/onboard/checkpoint-replay.test.ts @@ -11,6 +11,7 @@ import { } from "../state/onboard-checkpoint-types"; import { checkpointSandboxIdentityMatches, + collectRequiredMessagingProviderBindings, observeProviderEffectFingerprint, planEffectGroupReplay, planSandboxCreateReplay, @@ -377,6 +378,87 @@ describe("requiredMessagingProviderBindings", () => { }, ]); }); + + it("filters provider bindings to one active messaging channel", () => { + const plan: SandboxMessagingPlan = { + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "alpha-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "alpha-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "openshell:resolve:env:SLACK_BOT_TOKEN", + credentialAvailable: true, + }, + { + channelId: "slack", + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: "alpha-slack-bridge", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "openshell:resolve:env:SLACK_APP_TOKEN", + credentialAvailable: true, + }, + ], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; + + expect(collectRequiredMessagingProviderBindings("alpha", plan, new Set(["slack"]))).toEqual([ + { + name: "alpha-slack-bridge", + type: "nemoclaw-mcp-v1", + credentialEnv: "SLACK_BOT_TOKEN", + }, + { + name: "alpha-slack-bridge", + type: "nemoclaw-mcp-v1", + credentialEnv: "SLACK_APP_TOKEN", + }, + ]); + }); }); describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => { diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index 6c50775ee80..db200376497 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -139,17 +139,23 @@ export function requiredWebSearchProviderType( : provider; } -export function requiredMessagingProviderBindings( +/** Collect every active credential binding, including multiple keys owned by one provider. */ +export function collectRequiredMessagingProviderBindings( sandboxName: string, plan: SandboxMessagingPlan | null, + channelIds?: ReadonlySet, ): CheckpointProviderBinding[] { if (!plan) return []; - const activeChannels = new Set(getActiveChannelIdsFromPlan(plan)); + const activeChannels = new Set( + getActiveChannelIdsFromPlan(plan).filter( + (channelId) => channelIds === undefined || channelIds.has(channelId), + ), + ); const profiles = messagingBridgeProfilesForAgent(plan.agent, listMessagingBridgeProfiles()); - const bindings = new Map(); + const bindings: CheckpointProviderBinding[] = []; for (const binding of plan.credentialBindings) { if (!activeChannels.has(binding.channelId)) continue; - bindings.set(binding.providerName, { + bindings.push({ name: binding.providerName, type: staticMessagingProviderTypeForChannel(binding.channelId, plan.agent, profiles) ?? @@ -160,13 +166,19 @@ export function requiredMessagingProviderBindings( for (const profile of profiles) { if (!activeChannels.has(profile.channelId)) continue; const name = `${sandboxName}-${profile.channelId}-bridge`; - const existing = bindings.get(name); - bindings.set( - name, - existing - ? { ...existing, type: profile.profileId } - : { name, type: profile.profileId, credentialEnv: profile.credentialKey }, - ); + if (bindings.some((binding) => binding.name === name)) continue; + bindings.push({ name, type: profile.profileId, credentialEnv: profile.credentialKey }); + } + return bindings; +} + +export function requiredMessagingProviderBindings( + sandboxName: string, + plan: SandboxMessagingPlan | null, +): CheckpointProviderBinding[] { + const bindings = new Map(); + for (const binding of collectRequiredMessagingProviderBindings(sandboxName, plan)) { + bindings.set(binding.name, binding); } return [...bindings.values()]; } diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index 2095e40c984..fbfe759a582 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -257,6 +257,63 @@ describe("credential provider registration", () => { ]); }); + it.each([ + { + condition: "a gateway command failure", + result: () => ({ status: 2, stderr: "gateway unavailable" }), + expected: { kind: "indeterminate" as const }, + }, + { + condition: "malformed provider metadata", + result: () => ({ status: 0, stdout: "unexpected output" }), + expected: { kind: "collision" as const }, + }, + { + condition: "a thrown gateway command", + result: () => { + throw new Error("gateway unavailable"); + }, + expected: { kind: "indeterminate" as const }, + }, + ])("preserves $condition when inspecting a credential binding", ({ result, expected }) => { + const session = { stagedCredentialProviders: [] } as unknown as Session; + const registration = createCredentialProviderRegistration( + registrationDeps(vi.fn(result), session), + ); + + expect( + registration.inspectGatewayCredential( + "alpha-telegram-bridge", + "nemoclaw-mcp-v1", + "TELEGRAM_BOT_TOKEN", + ), + ).toEqual(expected); + }); + + it("treats a failed static profile inspection as indeterminate", () => { + const session = { stagedCredentialProviders: [] } as unknown as Session; + const runOpenshell = vi.fn((args: string[]) => + args.includes("profile") + ? { status: 2, stderr: "gateway unavailable" } + : providerMetadata( + "alpha-discord-bridge", + "discord-hermes-static-v1", + "DISCORD_BOT_TOKEN", + ), + ); + const deps = registrationDeps(runOpenshell, session); + deps.root = process.cwd(); + const registration = createCredentialProviderRegistration(deps); + + expect( + registration.inspectGatewayCredential( + "alpha-discord-bridge", + "discord-hermes-static-v1", + "DISCORD_BOT_TOKEN", + ), + ).toEqual({ kind: "indeterminate" }); + }); + it("rejects tokenless Hermes Discord profile drift before provider mutation", async () => { const session = { stagedCredentialProviders: [] } as unknown as Session; const runOpenshell = vi.fn((args: string[]) => diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 363d36ea112..141da697ce5 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -275,21 +275,36 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg binding: CheckpointProviderBinding, runOpenshell: OpenshellCliHelpers["runOpenshell"], ): boolean { + return inspectGatewayCredentialBinding(binding, runOpenshell).kind === "exact"; + } + + function inspectGatewayCredentialBinding( + binding: CheckpointProviderBinding, + runOpenshell: OpenshellCliHelpers["runOpenshell"], + ): gatewayProviderMetadata.GatewayCredentialOnlyProviderInspection { const staticProfileMatches = messagingBridgeProvider.matchesRegisteredStaticMessagingProfile( binding.type, { root: deps.root, runOpenshell }, ); - if (staticProfileMatches === false) return false; - return gatewayProviderMetadata.matchesGatewayCredentialFamilyProviderBinding( - providers.readGatewayProviderMetadata(binding.name, runOpenshell, deps.getGatewayName()), + if (staticProfileMatches === false) return { kind: "indeterminate" }; + return gatewayProviderMetadata.inspectGatewayCredentialFamilyProviderBinding( { name: binding.name, type: binding.type, credentialKey: binding.credentialEnv, }, + runOpenshell, ); } + function inspectGatewayCredential( + name: string, + type: string, + credentialEnv: string, + ): gatewayProviderMetadata.GatewayCredentialOnlyProviderInspection { + return inspectGatewayCredentialBinding({ name, type, credentialEnv }, gatewayRunner()); + } + function providerMatchesGatewayCredential( name: string, type: string, @@ -368,6 +383,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg } return { + inspectGatewayCredential, providerMatchesGatewayCredential, stageSandboxCredentialProviders, upsertProvider, diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 47ad8725093..42d9eedef27 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -317,6 +317,8 @@ function createPhases( throw new Error(`exit ${code}`); }) as (code: number) => never, ...overrides.sandboxDeps, + inspectGatewayCredential: + overrides.sandboxDeps?.inspectGatewayCredential ?? (() => ({ kind: "missing" as const })), checkGatewayRouteCompatibility: overrides.sandboxDeps?.checkGatewayRouteCompatibility ?? (() => ({ ok: true })), withGatewayRouteMutationLock: diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 64f8b6a7328..a0d8115078a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -379,6 +379,7 @@ function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { authoritative: false, plan: null, })), + inspectGatewayCredential: vi.fn(() => ({ kind: "missing" as const })), providerMatchesGatewayCredential: vi.fn(() => false), }; } @@ -404,8 +405,8 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv, + inspectGatewayCredential: () => ({ kind: "missing" }), note: vi.fn(), - providerMatchesGatewayCredential: () => false, writePlanToEnv: vi.fn(), }, plan, @@ -419,8 +420,8 @@ describe("reconcileReusedSandboxMessaging", () => { const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); const deps = { clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "missing" as const }), note: vi.fn(), - providerMatchesGatewayCredential: () => false, writePlanToEnv: vi.fn(), }; vi.stubEnv("DISCORD_BOT_TOKEN", ""); @@ -451,8 +452,8 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "missing" }), note: vi.fn(), - providerMatchesGatewayCredential: () => false, writePlanToEnv: vi.fn(), }, plan, @@ -465,19 +466,19 @@ describe("reconcileReusedSandboxMessaging", () => { const plan = googlechatPlan(); // The pasted secret dies with its process, so a later rebuild sees empty env. vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); - const providerMatchesGatewayCredential = vi.fn(() => true); + const inspectGatewayCredential = vi.fn(() => ({ kind: "exact" as const })); const note = vi.fn(); const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv: vi.fn(), note, providerMatchesGatewayCredential, writePlanToEnv: vi.fn() }, + { clearPlanEnv: vi.fn(), inspectGatewayCredential, note, writePlanToEnv: vi.fn() }, plan, ); expect(result).toEqual({ plan, selectedChannels: ["googlechat"], changed: false }); expect(note).not.toHaveBeenCalledWith(expect.stringContaining("No host inputs configure")); - expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + expect(inspectGatewayCredential).toHaveBeenCalledWith( "alpha-googlechat-bridge", "google-chat-bridge", "GOOGLE_CHAT_ACCESS_TOKEN", @@ -494,8 +495,8 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "missing" }), note, - providerMatchesGatewayCredential: () => false, writePlanToEnv: vi.fn(), }, plan, @@ -513,29 +514,29 @@ describe("reconcileReusedSandboxMessaging", () => { it("keeps a token channel whose provider still matches at the gateway (#10660)", () => { const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); vi.stubEnv("DISCORD_BOT_TOKEN", ""); - const providerMatchesGatewayCredential = vi.fn(() => true); + const inspectGatewayCredential = vi.fn(() => ({ kind: "exact" as const })); const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, { clearPlanEnv: vi.fn(), + inspectGatewayCredential, note: vi.fn(), - providerMatchesGatewayCredential, writePlanToEnv: vi.fn(), }, plan, ); expect(result).toEqual({ plan, selectedChannels: ["discord"], changed: false }); - expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + expect(inspectGatewayCredential).toHaveBeenCalledWith( "alpha-discord-bridge", expect.any(String), "DISCORD_BOT_TOKEN", ); }); - it("disables a Slack channel when one required gateway credential is missing (#10660)", () => { + it("disables Slack when its app-token gateway credential is missing (#10660)", () => { const plan = slackPlan( hashCredential("previous-slack-bot-token") ?? "", hashCredential("previous-slack-app-token") ?? "", @@ -543,8 +544,11 @@ describe("reconcileReusedSandboxMessaging", () => { vi.stubEnv("SLACK_BOT_TOKEN", ""); vi.stubEnv("SLACK_APP_TOKEN", ""); const writePlanToEnv = vi.fn(); - const providerMatchesGatewayCredential = vi.fn( - (_name: string, _type: string, credentialEnv: string) => credentialEnv === "SLACK_BOT_TOKEN", + const inspectGatewayCredential = vi.fn( + (_name: string, _type: string, credentialEnv: string) => + credentialEnv === "SLACK_BOT_TOKEN" + ? ({ kind: "exact" } as const) + : ({ kind: "missing" } as const), ); const result = reconcileReusedSandboxMessaging( @@ -552,8 +556,8 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv: vi.fn(), + inspectGatewayCredential, note: vi.fn(), - providerMatchesGatewayCredential, writePlanToEnv, }, plan, @@ -562,6 +566,95 @@ describe("reconcileReusedSandboxMessaging", () => { expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); expect(writePlanToEnv).toHaveBeenCalledWith(disabledPlan); + expect(inspectGatewayCredential).toHaveBeenCalledTimes(2); + }); + + it("disables Slack when its bot-token gateway credential is missing (#10660)", () => { + const plan = slackPlan( + hashCredential("previous-slack-bot-token") ?? "", + hashCredential("previous-slack-app-token") ?? "", + ); + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + const writePlanToEnv = vi.fn(); + const inspectGatewayCredential = vi.fn( + (_name: string, _type: string, credentialEnv: string) => + credentialEnv === "SLACK_APP_TOKEN" + ? ({ kind: "exact" } as const) + : ({ kind: "missing" } as const), + ); + + const result = reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { + clearPlanEnv: vi.fn(), + inspectGatewayCredential, + note: vi.fn(), + writePlanToEnv, + }, + plan, + ); + const disabledPlan = withChannelDisabled(plan, "slack"); + + expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); + expect(writePlanToEnv).toHaveBeenCalledWith(disabledPlan); + expect(inspectGatewayCredential).toHaveBeenCalledTimes(2); + }); + + it.each(["collision", "indeterminate"] as const)( + "preserves the channel plan when gateway credential inspection is %s (#10660)", + (kind) => { + const plan = googlechatPlan(); + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); + const clearPlanEnv = vi.fn(); + const writePlanToEnv = vi.fn(); + + expect(() => + reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { + clearPlanEnv, + inspectGatewayCredential: () => ({ kind }), + note: vi.fn(), + writePlanToEnv, + }, + plan, + ), + ).toThrow(/provider 'alpha-googlechat-bridge'.*sandbox 'alpha'.*No messaging state was changed/u); + expect(clearPlanEnv).not.toHaveBeenCalled(); + expect(writePlanToEnv).not.toHaveBeenCalled(); + }, + ); + + it("preserves Slack when a missing binding accompanies an indeterminate inspection (#10660)", () => { + const plan = slackPlan( + hashCredential("previous-slack-bot-token") ?? "", + hashCredential("previous-slack-app-token") ?? "", + ); + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + const clearPlanEnv = vi.fn(); + const writePlanToEnv = vi.fn(); + const inspectGatewayCredential = vi.fn( + (_name: string, _type: string, credentialEnv: string) => + credentialEnv === "SLACK_BOT_TOKEN" + ? ({ kind: "missing" } as const) + : ({ kind: "indeterminate" } as const), + ); + + expect(() => + reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { clearPlanEnv, inspectGatewayCredential, note: vi.fn(), writePlanToEnv }, + plan, + ), + ).toThrow(/Could not inspect messaging provider/u); + expect(inspectGatewayCredential).toHaveBeenCalledTimes(2); + expect(clearPlanEnv).not.toHaveBeenCalled(); + expect(writePlanToEnv).not.toHaveBeenCalled(); }); it("keeps an in-sandbox QR channel in a reused sandbox selection (#9283)", () => { @@ -574,8 +667,8 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "missing" }), note: vi.fn(), - providerMatchesGatewayCredential: () => false, writePlanToEnv: vi.fn(), }, plan, @@ -593,8 +686,8 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv() {}, + inspectGatewayCredential: () => ({ kind: "missing" }), note() {}, - providerMatchesGatewayCredential: () => false, writePlanToEnv() {}, }, ); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 8e8033af049..5e3d20ab48c 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -20,15 +20,13 @@ import { import { hashCredential } from "../../../security/credential-hash"; import { isDecisionSelected, isDecisionUnset } from "../../../state/onboard-checkpoint-decision"; import type { Session } from "../../../state/onboard-session"; +import { collectRequiredMessagingProviderBindings } from "../../checkpoint-replay"; +import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; import { detectMessagingChannelsFromEnv, detectUnconfiguredMessagingChannels, } from "../../messaging-channel-setup"; -import { - bridgeProviderNamesForChannel, - messagingBridgeProfilesForAgent, - staticMessagingProviderTypeForChannel, -} from "../../messaging-bridge-provider"; +import { staticMessagingProviderTypeForChannel } from "../../messaging-bridge-provider"; import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; export { @@ -65,6 +63,11 @@ export interface SandboxMessagingDeps { writePlanToEnv(plan: SandboxMessagingPlan): void; clearPlanEnv(): void; getRegistrySandboxMessagingAuthority(sandboxName: string): RegistryMessagingAuthority; + inspectGatewayCredential( + name: string, + type: string, + credentialEnv: string, + ): GatewayCredentialOnlyProviderInspection; providerMatchesGatewayCredential(name: string, type: string, credentialEnv: string): boolean; } @@ -236,32 +239,37 @@ function selectionFromReusablePlan( function channelCredentialLivesAtGateway( plan: SandboxMessagingPlan, channelId: string, - deps: Pick, "providerMatchesGatewayCredential">, + deps: Pick, "inspectGatewayCredential">, ): boolean { - const providerBindings = plan.credentialBindings - .filter((binding) => binding.channelId === channelId) - .map((binding) => ({ - name: binding.providerName, - type: - staticMessagingProviderTypeForChannel(channelId, plan.agent) ?? - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - credentialEnv: binding.providerEnvKey, - })); - // A bridge channel renders no binding; resolve its provider by name. - for (const profile of messagingBridgeProfilesForAgent(plan.agent)) { - if (profile.channelId !== channelId) continue; - for (const name of bridgeProviderNamesForChannel(plan.sandboxName, channelId, [profile])) { - providerBindings.push({ - name, - type: profile.profileId, - credentialEnv: profile.credentialKey, - }); - } - } + const providerBindings = collectRequiredMessagingProviderBindings( + plan.sandboxName, + plan, + new Set([channelId]), + ); if (providerBindings.length === 0) return false; - return providerBindings.every(({ name, type, credentialEnv }) => - deps.providerMatchesGatewayCredential(name, type, credentialEnv), + const inspections = providerBindings.map((binding) => ({ + binding, + inspection: deps.inspectGatewayCredential( + binding.name, + binding.type, + binding.credentialEnv, + ), + })); + const unresolved = inspections.find( + ({ inspection }) => inspection.kind === "collision" || inspection.kind === "indeterminate", ); + if (unresolved) { + const { name } = unresolved.binding; + if (unresolved.inspection.kind === "indeterminate") { + throw new Error( + `Could not inspect messaging provider '${name}' for sandbox '${plan.sandboxName}'. No messaging state was changed. Run onboarding again after the OpenShell gateway is available.`, + ); + } + throw new Error( + `Messaging provider '${name}' for sandbox '${plan.sandboxName}' does not match the recorded credential binding. No messaging state was changed.`, + ); + } + return inspections.every(({ inspection }) => inspection.kind === "exact"); } function filterUnconfiguredHostChannelsFromSelection( @@ -269,7 +277,7 @@ function filterUnconfiguredHostChannelsFromSelection( agent: Agent, deps: Pick< SandboxMessagingDeps, - "clearPlanEnv" | "note" | "providerMatchesGatewayCredential" | "writePlanToEnv" + "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" >, ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host @@ -526,7 +534,7 @@ export function reconcileReusedSandboxMessaging( agent: Agent, deps: Pick< SandboxMessagingDeps, - "clearPlanEnv" | "note" | "providerMatchesGatewayCredential" | "writePlanToEnv" + "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" >, recordedPlan: SandboxMessagingPlan | null = plan, ): SandboxMessagingSelection & { readonly changed: boolean } { diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index 52ccc5d6c11..cf428bbee18 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -326,6 +326,8 @@ export function createDeps( error: calls.error, exitProcess: calls.exit, ...overrides, + inspectGatewayCredential: + overrides.inspectGatewayCredential ?? (() => ({ kind: "exact" as const })), checkGatewayRouteCompatibility: overrides.checkGatewayRouteCompatibility ?? calls.checkGatewayRouteCompatibility, withDashboardPortReservationLock: runWithDashboardPortReservationLock, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 74f8f0a1fa6..e0a4870cca2 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -302,6 +302,11 @@ export interface SandboxStateOptions< getRegistrySandboxMessagingAuthority( sandboxName: string, ): import("../../../messaging/plan-authority").RegistryMessagingAuthority; + inspectGatewayCredential( + name: string, + type: string, + credentialEnv: string, + ): import("../../gateway-provider-metadata").GatewayCredentialOnlyProviderInspection; providerMatchesGatewayCredential(name: string, type: string, credentialEnv: string): boolean; stageSandboxCredentialProviders(input: { sandboxName: string; From babdb1dcbdfbd86c724ffcac4ba7ba8eb9478640 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 21:43:51 -0700 Subject: [PATCH 04/17] fix(messaging): probe plans before staging Signed-off-by: Apurv Kumaria --- .../handlers/sandbox-messaging.test.ts | 114 ++++++++------- .../machine/handlers/sandbox-messaging.ts | 130 ++++++++++++------ 2 files changed, 152 insertions(+), 92 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index a0d8115078a..5900d5a3f78 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -16,6 +16,7 @@ import { } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session } from "../../../state/onboard-session"; import { setupMessagingChannels } from "../../messaging-channel-setup"; +import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; import { getActiveChannelsFromPlan } from "../../messaging-plan-session"; import { hasMessagingCredentialDrift, @@ -379,11 +380,25 @@ function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { authoritative: false, plan: null, })), - inspectGatewayCredential: vi.fn(() => ({ kind: "missing" as const })), + inspectGatewayCredential: vi.fn< + (name: string, type: string, credentialEnv: string) => GatewayCredentialOnlyProviderInspection + >(() => ({ kind: "missing" })), providerMatchesGatewayCredential: vi.fn(() => false), }; } +function registryDeps(plan: SandboxMessagingPlan) { + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ authoritative: true, plan }); + return deps; +} + +function recordedResumeDeps(plan: SandboxMessagingPlan) { + const deps = reconcileDeps([plan]); + deps.getRecordedMessagingChannelsForResume.mockReturnValue(["discord", "googlechat"]); + return deps; +} + beforeEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); @@ -536,7 +551,10 @@ describe("reconcileReusedSandboxMessaging", () => { ); }); - it("disables Slack when its app-token gateway credential is missing (#10660)", () => { + it.each([ + ["app-token", "SLACK_APP_TOKEN"], + ["bot-token", "SLACK_BOT_TOKEN"], + ] as const)("disables Slack when its %s gateway credential is missing (#10660)", (_, missing) => { const plan = slackPlan( hashCredential("previous-slack-bot-token") ?? "", hashCredential("previous-slack-app-token") ?? "", @@ -544,59 +562,17 @@ describe("reconcileReusedSandboxMessaging", () => { vi.stubEnv("SLACK_BOT_TOKEN", ""); vi.stubEnv("SLACK_APP_TOKEN", ""); const writePlanToEnv = vi.fn(); - const inspectGatewayCredential = vi.fn( - (_name: string, _type: string, credentialEnv: string) => - credentialEnv === "SLACK_BOT_TOKEN" - ? ({ kind: "exact" } as const) - : ({ kind: "missing" } as const), + const inspectGatewayCredential = vi.fn((_name: string, _type: string, credentialEnv: string) => + credentialEnv === missing ? ({ kind: "missing" } as const) : ({ kind: "exact" } as const), ); const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { - clearPlanEnv: vi.fn(), - inspectGatewayCredential, - note: vi.fn(), - writePlanToEnv, - }, + { clearPlanEnv: vi.fn(), inspectGatewayCredential, note: vi.fn(), writePlanToEnv }, plan, ); const disabledPlan = withChannelDisabled(plan, "slack"); - - expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); - expect(writePlanToEnv).toHaveBeenCalledWith(disabledPlan); - expect(inspectGatewayCredential).toHaveBeenCalledTimes(2); - }); - - it("disables Slack when its bot-token gateway credential is missing (#10660)", () => { - const plan = slackPlan( - hashCredential("previous-slack-bot-token") ?? "", - hashCredential("previous-slack-app-token") ?? "", - ); - vi.stubEnv("SLACK_BOT_TOKEN", ""); - vi.stubEnv("SLACK_APP_TOKEN", ""); - const writePlanToEnv = vi.fn(); - const inspectGatewayCredential = vi.fn( - (_name: string, _type: string, credentialEnv: string) => - credentialEnv === "SLACK_APP_TOKEN" - ? ({ kind: "exact" } as const) - : ({ kind: "missing" } as const), - ); - - const result = reconcileReusedSandboxMessaging( - structuredClone(plan), - { name: "openclaw" }, - { - clearPlanEnv: vi.fn(), - inspectGatewayCredential, - note: vi.fn(), - writePlanToEnv, - }, - plan, - ); - const disabledPlan = withChannelDisabled(plan, "slack"); - expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); expect(writePlanToEnv).toHaveBeenCalledWith(disabledPlan); expect(inspectGatewayCredential).toHaveBeenCalledTimes(2); @@ -622,7 +598,9 @@ describe("reconcileReusedSandboxMessaging", () => { }, plan, ), - ).toThrow(/provider 'alpha-googlechat-bridge'.*sandbox 'alpha'.*No messaging state was changed/u); + ).toThrow( + /provider 'alpha-googlechat-bridge'.*sandbox 'alpha'.*No messaging state was changed/u, + ); expect(clearPlanEnv).not.toHaveBeenCalled(); expect(writePlanToEnv).not.toHaveBeenCalled(); }, @@ -637,11 +615,10 @@ describe("reconcileReusedSandboxMessaging", () => { vi.stubEnv("SLACK_APP_TOKEN", ""); const clearPlanEnv = vi.fn(); const writePlanToEnv = vi.fn(); - const inspectGatewayCredential = vi.fn( - (_name: string, _type: string, credentialEnv: string) => - credentialEnv === "SLACK_BOT_TOKEN" - ? ({ kind: "missing" } as const) - : ({ kind: "indeterminate" } as const), + const inspectGatewayCredential = vi.fn((_name: string, _type: string, credentialEnv: string) => + credentialEnv === "SLACK_BOT_TOKEN" + ? ({ kind: "missing" } as const) + : ({ kind: "indeterminate" } as const), ); expect(() => @@ -795,6 +772,37 @@ describe("reconcileSandboxMessaging plan authority", () => { expect(result).toEqual({ plan: registryPlan, selectedChannels: ["telegram"] }); }); + it.each([ + ["lifecycle selection", false, "add-channel", registryDeps, () => null], + ["checkpoint resume", true, "onboard", registryDeps, completedCheckpointSession], + ["recorded resume selection", true, "onboard", recordedResumeDeps, () => null], + ] as const)( + "does not stage a refreshed %s before every gateway probe resolves (#10660)", + async (_, resume, workflow, depsFor, sessionFor) => { + const discord = discordPlan(hashCredential("previous-discord-token") ?? ""); + const registryPlan: SandboxMessagingPlan = { + ...discord, + workflow, + channels: [...discord.channels, ...googlechatPlan().channels], + }; + const deps = depsFor(registryPlan); + deps.inspectGatewayCredential.mockReturnValue({ kind: "indeterminate" }); + vi.stubEnv("DISCORD_BOT_TOKEN", "replacement-discord-token"); + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); + await expect( + reconcileSandboxMessaging({ + resume, + session: sessionFor(registryPlan), + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }), + ).rejects.toThrow(/No messaging state was changed/u); + expect(deps.writePlanToEnv).not.toHaveBeenCalled(); + expect(deps.clearPlanEnv).not.toHaveBeenCalled(); + }, + ); + it("omits a removed host-backed channel from fresh registry re-onboarding (#9109)", async () => { const registryPlan = discordPlan(hashCredential("previous-discord-token") ?? ""); const deps = reconcileDeps([]); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 5e3d20ab48c..e5a34fb3078 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -212,25 +212,41 @@ export function filterMessagingPlanForCurrentAgent( }; } -function selectionFromReusablePlan( +interface PreparedReusablePlan extends SandboxMessagingSelection { + readonly changed: boolean; +} + +function prepareReusablePlan( plan: SandboxMessagingPlan, agent: Agent, - writeToEnv: boolean, - deps: SandboxMessagingDeps, -): SandboxMessagingSelection { +): PreparedReusablePlan { const refreshed = refreshCredentialHashesFromEnv(plan); const filtered = filterMessagingPlanForCurrentAgent(refreshed.plan, agent); if (!filtered) { - deps.clearPlanEnv(); - return { plan: null, selectedChannels: [] }; + return { plan: null, selectedChannels: [], changed: true }; } - if (writeToEnv || refreshed.changed || filtered !== refreshed.plan) deps.writePlanToEnv(filtered); return { plan: filtered, selectedChannels: getActiveChannelsFromPlan(filtered), + changed: refreshed.changed || filtered !== refreshed.plan, }; } +function selectionFromReusablePlan( + plan: SandboxMessagingPlan, + agent: Agent, + writeToEnv: boolean, + deps: SandboxMessagingDeps, +): SandboxMessagingSelection { + const prepared = prepareReusablePlan(plan, agent); + if (!prepared.plan) { + deps.clearPlanEnv(); + return { plan: null, selectedChannels: [] }; + } + if (writeToEnv || prepared.changed) deps.writePlanToEnv(prepared.plan); + return { plan: prepared.plan, selectedChannels: prepared.selectedChannels }; +} + /** * Whether the gateway still holds this channel's credential. * - Durable record: `channels remove` deletes the provider, a rebuild does not. @@ -249,11 +265,7 @@ function channelCredentialLivesAtGateway( if (providerBindings.length === 0) return false; const inspections = providerBindings.map((binding) => ({ binding, - inspection: deps.inspectGatewayCredential( - binding.name, - binding.type, - binding.credentialEnv, - ), + inspection: deps.inspectGatewayCredential(binding.name, binding.type, binding.credentialEnv), })); const unresolved = inspections.find( ({ inspection }) => inspection.kind === "collision" || inspection.kind === "indeterminate", @@ -272,6 +284,14 @@ function channelCredentialLivesAtGateway( return inspections.every(({ inspection }) => inspection.kind === "exact"); } +function persistMessagingPlan( + plan: SandboxMessagingPlan | null, + deps: Pick, "clearPlanEnv" | "writePlanToEnv">, +): void { + if (plan) deps.writePlanToEnv(plan); + else deps.clearPlanEnv(); +} + function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, @@ -279,6 +299,7 @@ function filterUnconfiguredHostChannelsFromSelection( SandboxMessagingDeps, "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" >, + persist = true, ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host // input. Rebuild the host-backed selection so policy reconciliation can @@ -307,8 +328,7 @@ function filterUnconfiguredHostChannelsFromSelection( ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, ); const plan = disableChannelsInPlan(selection.plan, unconfiguredChannels); - if (plan) deps.writePlanToEnv(plan); - else deps.clearPlanEnv(); + if (persist) persistMessagingPlan(plan, deps); return { plan, selectedChannels: selection.selectedChannels.filter( @@ -437,6 +457,25 @@ async function selectionFromMessagingSetup( ); } +/** Probe gateway state before persisting a reusable plan. */ +function selectionFromReconciledReusablePlan( + plan: SandboxMessagingPlan, + agent: Agent, + writeToEnv: boolean, + deps: Pick< + SandboxMessagingDeps, + "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" + >, +): SandboxMessagingSelection { + const prepared = prepareReusablePlan(plan, agent); + const reusable = { plan: prepared.plan, selectedChannels: prepared.selectedChannels }; + const reconciled = filterUnconfiguredHostChannelsFromSelection(reusable, agent, deps, false); + if (writeToEnv || prepared.changed || reconciled.plan !== prepared.plan) { + persistMessagingPlan(reconciled.plan, deps); + } + return reconciled; +} + /** Reconcile checkpoint channels against current host inputs before reuse. */ function selectionFromRecordedChannels( recordedChannels: string[], @@ -448,10 +487,24 @@ function selectionFromRecordedChannels( plan: null, selectedChannels: filterChannelNamesForCurrentAgent(recordedChannels, options.agent), }; - if (envPlan) selection = selectionFromReusablePlan(envPlan, options.agent, false, options.deps); - else if (registryPlan) - selection = selectionFromReusablePlan(registryPlan, options.agent, true, options.deps); - selection = filterUnconfiguredHostChannelsFromSelection(selection, options.agent, options.deps); + if (registryPlan && !envPlan) { + selection = selectionFromReconciledReusablePlan( + registryPlan, + options.agent, + true, + options.deps, + ); + } else { + if (envPlan) { + selection = selectionFromReconciledReusablePlan(envPlan, options.agent, false, options.deps); + } else { + selection = filterUnconfiguredHostChannelsFromSelection( + selection, + options.agent, + options.deps, + ); + } + } if (selection.selectedChannels.length > 0) { options.deps.note( ` [non-interactive] Reusing messaging channel configuration: ${selection.selectedChannels.join(", ")}`, @@ -484,11 +537,7 @@ async function selectionFromRegistryPlan( // A lifecycle command owns which channels the operator asked for, but not // whether the host still configures them. Onboarding re-reads the host // either way, so the same removal check applies here. - return filterUnconfiguredHostChannelsFromSelection( - selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), - options.agent, - options.deps, - ); + return selectionFromReconciledReusablePlan(registryPlan, options.agent, true, options.deps); } const activeChannels = filterChannelNamesForCurrentAgent( getActiveChannelsFromPlan(registryPlan), @@ -512,11 +561,7 @@ async function selectionFromRegistryPlan( } const detectedChannels = channelsForRegistryPlanRefresh(registryPlan, options.agent); if (!detectedChannels) { - return filterUnconfiguredHostChannelsFromSelection( - selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), - options.agent, - options.deps, - ); + return selectionFromReconciledReusablePlan(registryPlan, options.agent, true, options.deps); } options.deps.note( ` [non-interactive] Detected messaging channel inputs for ${detectedChannels.join(", ")}; refreshing reused sandbox messaging plan.`, @@ -706,6 +751,23 @@ async function selectionFromCompletedMessagingCheckpoint( return selection; } +async function selectionFromCompletedRegistryCheckpoint( + registryPlan: SandboxMessagingPlan | null, + envPlan: SandboxMessagingPlan | null, + options: ReconcileSandboxMessagingOptions, +): Promise { + const filteredPlan = registryPlan + ? filterMessagingPlanForCurrentAgent(registryPlan, options.agent) + : null; + const reconciled = filterUnconfiguredHostChannelsFromSelection( + { plan: filteredPlan, selectedChannels: getActiveChannelsFromPlan(filteredPlan) }, + options.agent, + options.deps, + false, + ); + return selectionFromCompletedMessagingCheckpoint(envPlan, options, reconciled.plan, false); +} + async function selectionFromRegistryAuthority( authority: ReturnType, envPlan: SandboxMessagingPlan | null, @@ -715,17 +777,7 @@ async function selectionFromRegistryAuthority( if (authority.source !== "registry") return null; const agentName = (options.agent as MessagingAgentLike | null)?.name; if ((!agentName || agentName === "openclaw") && options.resume && messagingDecisionCompleted) { - const selection = await selectionFromCompletedMessagingCheckpoint( - envPlan, - options, - authority.plan, - false, - ); - return filterUnconfiguredHostChannelsFromSelection( - selection, - options.agent, - options.deps, - ); + return selectionFromCompletedRegistryCheckpoint(authority.plan, envPlan, options); } if (authority.plan) return selectionFromRegistryPlan(authority.plan, options); options.deps.clearPlanEnv(); From 4b23a42c6cd5b66a4fa43fe9c3c1da7277d8930c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 22:07:08 -0700 Subject: [PATCH 05/17] fix(messaging): normalize legacy Slack bindings Signed-off-by: Apurv Kumaria --- src/lib/onboard/checkpoint-replay.test.ts | 46 ++++++++++++++++------- src/lib/onboard/checkpoint-replay.ts | 19 +++++++++- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts index 4c2c417bdbe..aad4c9d3866 100644 --- a/src/lib/onboard/checkpoint-replay.test.ts +++ b/src/lib/onboard/checkpoint-replay.test.ts @@ -379,7 +379,7 @@ describe("requiredMessagingProviderBindings", () => { ]); }); - it("filters provider bindings to one active messaging channel", () => { + it("uses every credential and current provider identity for one active channel (#10660)", () => { const plan: SandboxMessagingPlan = { schemaVersion: 1, sandboxName: "alpha", @@ -458,6 +458,23 @@ describe("requiredMessagingProviderBindings", () => { credentialEnv: "SLACK_APP_TOKEN", }, ]); + expect(requiredMessagingProviderBindings("alpha", plan)).toEqual([ + { + name: "alpha-telegram-bridge", + type: "nemoclaw-mcp-v1", + credentialEnv: "TELEGRAM_BOT_TOKEN", + }, + { + name: "alpha-slack-bridge", + type: "nemoclaw-mcp-v1", + credentialEnv: "SLACK_BOT_TOKEN", + }, + { + name: "alpha-slack-app", + type: "nemoclaw-mcp-v1", + credentialEnv: "SLACK_APP_TOKEN", + }, + ]); }); }); @@ -514,19 +531,20 @@ describe("crash-then-resume matrix proves at-most-once destructive create (#6228 "post_verify", ] as const; - it.each( - states, - )("crash at %s: reuse a surviving sandbox, recreate under the same identity when it is gone", (state) => { - const cp = checkpoint({ - machineState: state, - effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } }, - }); - expect(planSandboxCreateReplay(cp, { liveSandboxExists: true }).action).toBe("reuse"); - expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({ - action: "create", - identity: { name: "my-sandbox", agent: "openclaw" }, - }); - }); + it.each(states)( + "crash at %s: reuse a surviving sandbox, recreate under the same identity when it is gone", + (state) => { + const cp = checkpoint({ + machineState: state, + effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } }, + }); + expect(planSandboxCreateReplay(cp, { liveSandboxExists: true }).action).toBe("reuse"); + expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({ + action: "create", + identity: { name: "my-sandbox", agent: "openclaw" }, + }); + }, + ); }); describe("revalidateCheckpointBindings fails closed without leaking values (#6228)", () => { diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index db200376497..53fcfad934c 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { listMessagingCredentialMetadata } from "../messaging/channels/metadata"; import type { SandboxMessagingPlan } from "../messaging/manifest"; import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../messaging/provider-profile"; import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation"; @@ -176,8 +177,24 @@ export function requiredMessagingProviderBindings( sandboxName: string, plan: SandboxMessagingPlan | null, ): CheckpointProviderBinding[] { + if (!plan) return []; + const providerNamesByCredential = new Map( + listMessagingCredentialMetadata({ agent: plan.agent }).map((credential) => [ + `${credential.channelId}\0${credential.providerEnvKey}`, + credential.providerNameTemplate.replaceAll("{sandboxName}", sandboxName), + ]), + ); + const registrationPlan: SandboxMessagingPlan = { + ...plan, + credentialBindings: plan.credentialBindings.map((binding) => { + const providerName = providerNamesByCredential.get( + `${binding.channelId}\0${binding.providerEnvKey}`, + ); + return providerName ? { ...binding, providerName } : binding; + }), + }; const bindings = new Map(); - for (const binding of collectRequiredMessagingProviderBindings(sandboxName, plan)) { + for (const binding of collectRequiredMessagingProviderBindings(sandboxName, registrationPlan)) { bindings.set(binding.name, binding); } return [...bindings.values()]; From ddfc27ae2c4328ee0b8ee4aafacea79a5479c503 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 31 Aug 2026 22:24:38 -0700 Subject: [PATCH 06/17] fix(messaging): preserve provider collision checks Signed-off-by: Apurv Kumaria --- src/lib/onboard/checkpoint-replay.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index 53fcfad934c..c90a00aed68 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -184,13 +184,31 @@ export function requiredMessagingProviderBindings( credential.providerNameTemplate.replaceAll("{sandboxName}", sandboxName), ]), ); + const currentProviderCredentialEnvs = new Map>(); + for (const binding of plan.credentialBindings) { + const providerName = providerNamesByCredential.get( + `${binding.channelId}\0${binding.providerEnvKey}`, + ); + if (providerName !== binding.providerName) continue; + const key = `${binding.channelId}\0${binding.providerName}`; + const credentialEnvs = currentProviderCredentialEnvs.get(key) ?? new Set(); + credentialEnvs.add(binding.providerEnvKey); + currentProviderCredentialEnvs.set(key, credentialEnvs); + } const registrationPlan: SandboxMessagingPlan = { ...plan, credentialBindings: plan.credentialBindings.map((binding) => { - const providerName = providerNamesByCredential.get( + const currentProviderName = providerNamesByCredential.get( `${binding.channelId}\0${binding.providerEnvKey}`, ); - return providerName ? { ...binding, providerName } : binding; + if (!currentProviderName || currentProviderName === binding.providerName) return binding; + const siblingCredentialEnvs = currentProviderCredentialEnvs.get( + `${binding.channelId}\0${binding.providerName}`, + ); + const hasCurrentSibling = [...(siblingCredentialEnvs ?? [])].some( + (providerEnvKey) => providerEnvKey !== binding.providerEnvKey, + ); + return hasCurrentSibling ? { ...binding, providerName: currentProviderName } : binding; }), }; const bindings = new Map(); From 84aca3fdb17f6338d95c5a92f3e60cf85b4098da Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 21:23:38 -0700 Subject: [PATCH 07/17] fix(messaging): verify gateway-backed lifecycle reuse Signed-off-by: Prekshi Vyas --- src/lib/onboard/checkpoint-replay.ts | 56 +++-- .../credential-provider-registration.test.ts | 21 +- .../handlers/sandbox-messaging.test.ts | 217 +++++++++++++----- .../machine/handlers/sandbox-messaging.ts | 21 +- .../onboard/machine/handlers/sandbox.test.ts | 59 ++++- 5 files changed, 284 insertions(+), 90 deletions(-) diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index c90a00aed68..422249e795a 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -173,11 +173,11 @@ export function collectRequiredMessagingProviderBindings( return bindings; } -export function requiredMessagingProviderBindings( +/** Replace proven legacy provider names with the current manifest-owned names. */ +export function normalizeMessagingProviderBindings( sandboxName: string, - plan: SandboxMessagingPlan | null, -): CheckpointProviderBinding[] { - if (!plan) return []; + plan: SandboxMessagingPlan, +): SandboxMessagingPlan { const providerNamesByCredential = new Map( listMessagingCredentialMetadata({ agent: plan.agent }).map((credential) => [ `${credential.channelId}\0${credential.providerEnvKey}`, @@ -195,24 +195,38 @@ export function requiredMessagingProviderBindings( credentialEnvs.add(binding.providerEnvKey); currentProviderCredentialEnvs.set(key, credentialEnvs); } - const registrationPlan: SandboxMessagingPlan = { - ...plan, - credentialBindings: plan.credentialBindings.map((binding) => { - const currentProviderName = providerNamesByCredential.get( - `${binding.channelId}\0${binding.providerEnvKey}`, - ); - if (!currentProviderName || currentProviderName === binding.providerName) return binding; - const siblingCredentialEnvs = currentProviderCredentialEnvs.get( - `${binding.channelId}\0${binding.providerName}`, - ); - const hasCurrentSibling = [...(siblingCredentialEnvs ?? [])].some( - (providerEnvKey) => providerEnvKey !== binding.providerEnvKey, - ); - return hasCurrentSibling ? { ...binding, providerName: currentProviderName } : binding; - }), - }; + let changed = false; + const credentialBindings = plan.credentialBindings.map((binding) => { + const currentProviderName = providerNamesByCredential.get( + `${binding.channelId}\0${binding.providerEnvKey}`, + ); + if (!currentProviderName || currentProviderName === binding.providerName) return binding; + const siblingCredentialEnvs = currentProviderCredentialEnvs.get( + `${binding.channelId}\0${binding.providerName}`, + ); + const hasCurrentSibling = [...(siblingCredentialEnvs ?? [])].some( + (providerEnvKey) => providerEnvKey !== binding.providerEnvKey, + ); + if (!hasCurrentSibling) return binding; + changed = true; + return { ...binding, providerName: currentProviderName }; + }); + return changed ? { ...plan, credentialBindings } : plan; +} + +export function requiredMessagingProviderBindings( + sandboxName: string, + plan: SandboxMessagingPlan | null, + channelIds?: ReadonlySet, +): CheckpointProviderBinding[] { + if (!plan) return []; + const registrationPlan = normalizeMessagingProviderBindings(sandboxName, plan); const bindings = new Map(); - for (const binding of collectRequiredMessagingProviderBindings(sandboxName, registrationPlan)) { + for (const binding of collectRequiredMessagingProviderBindings( + sandboxName, + registrationPlan, + channelIds, + )) { bindings.set(binding.name, binding); } return [...bindings.values()]; diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index fbfe759a582..342a936a2aa 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -251,10 +251,23 @@ describe("credential provider registration", () => { "DISCORD_BOT_TOKEN", ), ).toBe(true); - expect(runOpenshell.mock.calls.map(([args]) => args.join(" "))).toEqual([ - "provider profile -g test-gateway export discord-hermes-static-v1 --output json", - "provider get -g test-gateway alpha-discord-bridge", - ]); + expect(runOpenshell).toHaveBeenCalledWith( + [ + "provider", + "profile", + "-g", + "test-gateway", + "export", + "discord-hermes-static-v1", + "--output", + "json", + ], + expect.anything(), + ); + expect(runOpenshell).toHaveBeenCalledWith( + ["provider", "get", "-g", "test-gateway", "alpha-discord-bridge"], + expect.anything(), + ); }); it.each([ diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 5900d5a3f78..7a836e321d6 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -6,8 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentDefinition } from "../../../agent/defs"; import { MessagingSetupApplier } from "../../../messaging/applier/setup-applier"; import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../../../messaging/applier/types"; +import { wechatManifest } from "../../../messaging/channels/built-ins"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { RegistryMessagingAuthority } from "../../../messaging/plan-authority"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; import { hashCredential } from "../../../security/credential-hash"; import { decisionSelected, decisionUnset } from "../../../state/onboard-checkpoint-decision"; import { @@ -224,6 +226,50 @@ function whatsappPlan(): SandboxMessagingPlan { }; } +function wechatStartPlan(): SandboxMessagingPlan { + const credential = wechatManifest.credentials[0]; + const nodePreloads = wechatManifest.runtime.openclaw.nodePreloads ?? []; + return { + ...telegramPlan(hashCredential("previous-wechat-token") ?? ""), + workflow: "start-channel", + channels: [ + { + channelId: wechatManifest.id, + displayName: wechatManifest.displayName, + authMode: wechatManifest.auth.mode, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + credentialBindings: [ + { + channelId: wechatManifest.id, + credentialId: credential.id, + sourceInput: credential.sourceInput, + providerName: credential.providerName.replaceAll("{sandboxName}", "alpha"), + providerEnvKey: credential.providerEnvKey, + placeholder: credential.placeholder, + credentialAvailable: true, + credentialHash: hashCredential("previous-wechat-token") ?? "", + }, + ], + runtimeSetup: { + nodePreloads: nodePreloads.map((preload) => ({ + ...preload, + channelId: wechatManifest.id, + source: "manifest", + target: "agent", + })), + envAliases: [], + secretScans: [], + }, + }; +} + function googlechatPlan(): SandboxMessagingPlan { return { ...telegramPlan(""), @@ -431,7 +477,7 @@ describe("reconcileReusedSandboxMessaging", () => { expect(clearPlanEnv).not.toHaveBeenCalled(); }); - it("omits a retired host-backed channel from a reused sandbox selection (#9283)", () => { + it("rejects a retired channel without changing a Ready sandbox plan (#9283)", () => { const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); const deps = { clearPlanEnv: vi.fn(), @@ -441,20 +487,15 @@ describe("reconcileReusedSandboxMessaging", () => { }; vi.stubEnv("DISCORD_BOT_TOKEN", ""); - const result = reconcileReusedSandboxMessaging( - structuredClone(plan), - { name: "openclaw" }, - deps, - plan, - ); - - // Persist the removal so later readers cannot re-enable the channel and - // re-apply its egress preset. - expect(result).toEqual({ - plan: withChannelDisabled(plan, "discord"), - selectedChannels: [], - changed: true, - }); + expect(() => + reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + deps, + plan, + ), + ).toThrow(/Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u); + expect(deps.writePlanToEnv).not.toHaveBeenCalled(); expect(deps.clearPlanEnv).not.toHaveBeenCalled(); }); @@ -500,30 +541,27 @@ describe("reconcileReusedSandboxMessaging", () => { ); }); - it("disables a bridge channel the gateway no longer holds a credential for (#10660)", () => { + it("rejects Ready sandbox reuse when a bridge credential is missing (#10660)", () => { const plan = googlechatPlan(); vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); const note = vi.fn(); + const writePlanToEnv = vi.fn(); - const result = reconcileReusedSandboxMessaging( - structuredClone(plan), - { name: "openclaw" }, - { - clearPlanEnv: vi.fn(), - inspectGatewayCredential: () => ({ kind: "missing" }), - note, - writePlanToEnv: vi.fn(), - }, - plan, - ); - - // `channels remove` deletes the provider, so absence is still the removal signal. - expect(result).toEqual({ - plan: withChannelDisabled(plan, "googlechat"), - selectedChannels: [], - changed: true, - }); - expect(note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure")); + expect(() => + reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { + clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "missing" }), + note, + writePlanToEnv, + }, + plan, + ), + ).toThrow(/Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); }); it("keeps a token channel whose provider still matches at the gateway (#10660)", () => { @@ -554,7 +592,7 @@ describe("reconcileReusedSandboxMessaging", () => { it.each([ ["app-token", "SLACK_APP_TOKEN"], ["bot-token", "SLACK_BOT_TOKEN"], - ] as const)("disables Slack when its %s gateway credential is missing (#10660)", (_, missing) => { + ] as const)("rejects Ready sandbox reuse when its Slack %s is missing (#10660)", (_, missing) => { const plan = slackPlan( hashCredential("previous-slack-bot-token") ?? "", hashCredential("previous-slack-app-token") ?? "", @@ -566,15 +604,15 @@ describe("reconcileReusedSandboxMessaging", () => { credentialEnv === missing ? ({ kind: "missing" } as const) : ({ kind: "exact" } as const), ); - const result = reconcileReusedSandboxMessaging( - structuredClone(plan), - { name: "openclaw" }, - { clearPlanEnv: vi.fn(), inspectGatewayCredential, note: vi.fn(), writePlanToEnv }, - plan, - ); - const disabledPlan = withChannelDisabled(plan, "slack"); - expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); - expect(writePlanToEnv).toHaveBeenCalledWith(disabledPlan); + expect(() => + reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { clearPlanEnv: vi.fn(), inspectGatewayCredential, note: vi.fn(), writePlanToEnv }, + plan, + ), + ).toThrow(/Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u); + expect(writePlanToEnv).not.toHaveBeenCalled(); expect(inspectGatewayCredential).toHaveBeenCalledTimes(2); }); @@ -702,27 +740,86 @@ describe("reconcileReusedSandboxMessaging", () => { }); }); - it("disables and stages an unconfigured host-backed channel for Ready sandbox reuse (#9283)", () => { - const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); - const deps = reconcileDeps([]); - vi.stubEnv("DISCORD_BOT_TOKEN", ""); +}); - const result = reconcileReusedSandboxMessaging( - plan, - { name: "openclaw" }, +describe("reconcileSandboxMessaging plan authority", () => { + it("keeps WeChat selected for start/rebuild when the gateway retains its QR token (#10765)", async () => { + const registryPlan = wechatStartPlan(); + const deps = registryDeps(registryPlan); + deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); + vi.stubEnv("WECHAT_BOT_TOKEN", ""); + vi.stubEnv("WECHAT_ACCOUNT_ID", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, deps, - structuredClone(plan), + }); + + expect(result.selectedChannels).toEqual(["wechat"]); + expect(result.plan?.runtimeSetup?.nodePreloads.map(({ module }) => module)).toContain( + "wechat-account-placeholder", + ); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-wechat-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "WECHAT_BOT_TOKEN", ); - const disabledPlan = withChannelDisabled(plan, "discord"); + expect(deps.note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); + }); - expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); - expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(disabledPlan); - expect(deps.clearPlanEnv).not.toHaveBeenCalled(); - expect(deps.note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure")); + it("migrates legacy Slack bindings before start/rebuild gateway probes", async () => { + const currentPlan = slackPlan( + hashCredential("previous-slack-bot-token") ?? "", + hashCredential("previous-slack-app-token") ?? "", + ); + const registryPlan: SandboxMessagingPlan = { + ...currentPlan, + workflow: "start-channel", + credentialBindings: currentPlan.credentialBindings.map((binding) => + binding.providerEnvKey === "SLACK_APP_TOKEN" + ? { ...binding, providerName: "alpha-slack-bridge" } + : binding, + ), + }; + const expectedPlan: SandboxMessagingPlan = { + ...registryPlan, + credentialBindings: currentPlan.credentialBindings, + }; + const deps = registryDeps(registryPlan); + deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(result).toEqual({ plan: expectedPlan, selectedChannels: ["slack"] }); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_BOT_TOKEN", + ); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-app", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(deps.inspectGatewayCredential).not.toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(expectedPlan); }); -}); -describe("reconcileSandboxMessaging plan authority", () => { it("uses the registry plan before a staged plan for an existing sandbox", async () => { const registryToken = "123456:registry-token"; const registryPlan = telegramPlan(hashCredential(registryToken) ?? ""); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index e5a34fb3078..7b667e8f352 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -20,7 +20,10 @@ import { import { hashCredential } from "../../../security/credential-hash"; import { isDecisionSelected, isDecisionUnset } from "../../../state/onboard-checkpoint-decision"; import type { Session } from "../../../state/onboard-session"; -import { collectRequiredMessagingProviderBindings } from "../../checkpoint-replay"; +import { + normalizeMessagingProviderBindings, + requiredMessagingProviderBindings, +} from "../../checkpoint-replay"; import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; import { detectMessagingChannelsFromEnv, @@ -221,14 +224,15 @@ function prepareReusablePlan( agent: Agent, ): PreparedReusablePlan { const refreshed = refreshCredentialHashesFromEnv(plan); - const filtered = filterMessagingPlanForCurrentAgent(refreshed.plan, agent); + const normalized = normalizeMessagingProviderBindings(plan.sandboxName, refreshed.plan); + const filtered = filterMessagingPlanForCurrentAgent(normalized, agent); if (!filtered) { return { plan: null, selectedChannels: [], changed: true }; } return { plan: filtered, selectedChannels: getActiveChannelsFromPlan(filtered), - changed: refreshed.changed || filtered !== refreshed.plan, + changed: refreshed.changed || normalized !== refreshed.plan || filtered !== normalized, }; } @@ -257,7 +261,7 @@ function channelCredentialLivesAtGateway( channelId: string, deps: Pick, "inspectGatewayCredential">, ): boolean { - const providerBindings = collectRequiredMessagingProviderBindings( + const providerBindings = requiredMessagingProviderBindings( plan.sandboxName, plan, new Set([channelId]), @@ -300,6 +304,7 @@ function filterUnconfiguredHostChannelsFromSelection( "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" >, persist = true, + missingAction: "disable" | "reject-ready-reuse" = "disable", ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host // input. Rebuild the host-backed selection so policy reconciliation can @@ -324,6 +329,12 @@ function filterUnconfiguredHostChannelsFromSelection( } } if (unconfiguredChannels.size === 0) return selection; + if (missingAction === "reject-ready-reuse") { + const sandboxName = selection.plan?.sandboxName ?? "unknown"; + throw new Error( + `Messaging channel credentials for Ready sandbox '${sandboxName}' are missing at the gateway: ${[...unconfiguredChannels].join(", ")}. The running sandbox and durable messaging plan were not changed. Run 'nemoclaw ${sandboxName} channels remove ' for each listed channel, or restore its gateway credential and rerun onboarding.`, + ); + } deps.note( ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, ); @@ -588,6 +599,8 @@ export function reconcileReusedSandboxMessaging( { plan: filtered, selectedChannels: getActiveChannelsFromPlan(filtered) }, agent, deps, + false, + "reject-ready-reuse", ); const changed = !isDeepStrictEqual(selection.plan, recordedPlan); if (changed && isDeepStrictEqual(selection.plan, filtered)) deps.clearPlanEnv(); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index e4260ff2d73..d16a45d4535 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -11,7 +11,10 @@ import { } from "../../../state/onboard-checkpoint-decision"; import { CHECKPOINT_SCHEMA_VERSION } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session } from "../../../state/onboard-session"; -import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; +import { + detectMessagingChannelsFromEnv, + detectUnconfiguredMessagingChannels, +} from "../../messaging-channel-setup"; import { handleSandboxState } from "./sandbox"; import { baseOptions, @@ -28,6 +31,7 @@ vi.mock("../../messaging-channel-setup", () => ({ })); const detectMessagingChannelsFromEnvMock = vi.mocked(detectMessagingChannelsFromEnv); +const detectUnconfiguredMessagingChannelsMock = vi.mocked(detectUnconfiguredMessagingChannels); function dcodeRegistryEntry(name: string, observabilityEnabled?: boolean) { return { @@ -47,6 +51,7 @@ function dcodeRegistryEntry(name: string, observabilityEnabled?: boolean) { describe("handleSandboxState", () => { beforeEach(() => { detectMessagingChannelsFromEnvMock.mockReturnValue([]); + detectUnconfiguredMessagingChannelsMock.mockReturnValue([]); }); it("creates a sandbox and records messaging/web search state", async () => { @@ -622,6 +627,58 @@ describe("handleSandboxState", () => { expect(result.session).toBe(skippedSession); }); + it("keeps a Ready sandbox plan unchanged when its gateway credential is missing", async () => { + const registryPlan = withTelegramCredentialHash( + makeMinimalPlan("saved", "openclaw", ["telegram"]), + hashCredential("previous-telegram-token"), + ); + const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); + session.steps.sandbox.status = "complete"; + const recordStateSkipped = vi.fn(async () => session); + const writePlanToEnv = vi.fn(); + detectUnconfiguredMessagingChannelsMock.mockReturnValue(["telegram"]); + const { deps, calls, getSession } = createDeps( + { + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => ({ + name: "saved", + pendingRouteReservation: true, + reservationSessionId: session.sessionId, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + }), + getRegistrySandboxMessagingAuthority: () => ({ + authoritative: true, + plan: registryPlan, + }), + inspectGatewayCredential: () => ({ kind: "missing" }), + writePlanToEnv, + recordStateSkipped, + }, + session, + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }), + ).rejects.toThrow( + /Ready sandbox 'saved'.*running sandbox and durable messaging plan were not changed/u, + ); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(recordStateSkipped).not.toHaveBeenCalled(); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(registryPlan); + }); + it("treats checkpoint machine-state progress past sandbox as step-complete even when the legacy step status is stale (#6228)", async () => { const session = createSession({ sandboxName: "saved", From bf56e47b5eefd12e63b0fd56767ad5dbc5ac8f61 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 21:32:04 -0700 Subject: [PATCH 08/17] test(messaging): split lifecycle reuse coverage Signed-off-by: Prekshi Vyas --- .../sandbox-messaging-gateway-reuse.test.ts | 180 ++++++++++++++++++ .../handlers/sandbox-messaging.test.ts | 123 ------------ .../sandbox-ready-messaging-reuse.test.ts | 78 ++++++++ .../onboard/machine/handlers/sandbox.test.ts | 59 +----- 4 files changed, 259 insertions(+), 181 deletions(-) create mode 100644 src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts create mode 100644 src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts new file mode 100644 index 00000000000..0b8b6540e51 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { wechatManifest } from "../../../messaging/channels/built-ins"; +import type { + ChannelAuthMode, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingPlan, +} from "../../../messaging/manifest"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; +import { hashCredential } from "../../../security/credential-hash"; +import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; +import { reconcileSandboxMessaging } from "./sandbox-messaging"; + +function lifecyclePlan( + channelId: SandboxMessagingPlan["channels"][number]["channelId"], + displayName: string, + authMode: ChannelAuthMode, + credentialBindings: readonly SandboxMessagingCredentialBindingPlan[], +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "start-channel", + channels: [ + { + channelId, + displayName, + authMode, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings, + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +function gatewayReuseDeps(plan: SandboxMessagingPlan) { + return { + note: vi.fn(), + showMessagingStage: vi.fn(), + getRecordedMessagingChannelsForResume: vi.fn((): string[] | null => null), + setupMessagingChannels: vi.fn(async () => []), + readMessagingPlanFromEnv: vi.fn((): SandboxMessagingPlan | null => null), + writePlanToEnv: vi.fn(), + clearPlanEnv: vi.fn(), + getRegistrySandboxMessagingAuthority: vi.fn(() => ({ authoritative: true as const, plan })), + inspectGatewayCredential: vi.fn< + (name: string, type: string, credentialEnv: string) => GatewayCredentialOnlyProviderInspection + >(() => ({ kind: "exact" })), + providerMatchesGatewayCredential: vi.fn(() => false), + }; +} + +function slackBinding( + credentialId: "slackBotToken" | "slackAppToken", + providerName: string, + providerEnvKey: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN", +): SandboxMessagingCredentialBindingPlan { + return { + channelId: "slack", + credentialId, + sourceInput: credentialId === "slackBotToken" ? "botToken" : "appToken", + providerName, + providerEnvKey, + placeholder: `openshell:resolve:env:${providerEnvKey}`, + credentialAvailable: true, + credentialHash: hashCredential(`previous-${providerEnvKey.toLowerCase()}`) ?? "", + }; +} + +beforeEach(() => vi.unstubAllEnvs()); +afterEach(() => vi.unstubAllEnvs()); + +describe("gateway-backed messaging lifecycle reuse", () => { + it("keeps WeChat selected for start/rebuild when the gateway retains its QR token (#10765)", async () => { + const credential = wechatManifest.credentials[0]; + const nodePreloads = wechatManifest.runtime.openclaw.nodePreloads ?? []; + const plan: SandboxMessagingPlan = { + ...lifecyclePlan(wechatManifest.id, wechatManifest.displayName, wechatManifest.auth.mode, [ + { + channelId: wechatManifest.id, + credentialId: credential.id, + sourceInput: credential.sourceInput, + providerName: credential.providerName.replaceAll("{sandboxName}", "alpha"), + providerEnvKey: credential.providerEnvKey, + placeholder: credential.placeholder, + credentialAvailable: true, + credentialHash: hashCredential("previous-wechat-token") ?? "", + }, + ]), + runtimeSetup: { + nodePreloads: nodePreloads.map((preload) => ({ + ...preload, + channelId: wechatManifest.id, + source: "manifest", + target: "agent", + })), + envAliases: [], + secretScans: [], + }, + }; + const deps = gatewayReuseDeps(plan); + vi.stubEnv("WECHAT_BOT_TOKEN", ""); + vi.stubEnv("WECHAT_ACCOUNT_ID", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(result.selectedChannels).toEqual(["wechat"]); + expect(result.plan?.runtimeSetup?.nodePreloads.map(({ module }) => module)).toContain( + "wechat-account-placeholder", + ); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-wechat-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "WECHAT_BOT_TOKEN", + ); + expect(deps.note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); + }); + + it("migrates legacy Slack bindings before start/rebuild gateway probes", async () => { + const currentBindings = [ + slackBinding("slackBotToken", "alpha-slack-bridge", "SLACK_BOT_TOKEN"), + slackBinding("slackAppToken", "alpha-slack-app", "SLACK_APP_TOKEN"), + ]; + const legacyPlan = lifecyclePlan("slack", "Slack", "token-paste", [ + currentBindings[0], + { ...currentBindings[1], providerName: "alpha-slack-bridge" }, + ]); + const expectedPlan = { ...legacyPlan, credentialBindings: currentBindings }; + const deps = gatewayReuseDeps(legacyPlan); + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(result).toEqual({ plan: expectedPlan, selectedChannels: ["slack"] }); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_BOT_TOKEN", + ); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-app", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(deps.inspectGatewayCredential).not.toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(expectedPlan); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 7a836e321d6..389d64a423a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -6,10 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentDefinition } from "../../../agent/defs"; import { MessagingSetupApplier } from "../../../messaging/applier/setup-applier"; import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../../../messaging/applier/types"; -import { wechatManifest } from "../../../messaging/channels/built-ins"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { RegistryMessagingAuthority } from "../../../messaging/plan-authority"; -import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; import { hashCredential } from "../../../security/credential-hash"; import { decisionSelected, decisionUnset } from "../../../state/onboard-checkpoint-decision"; import { @@ -226,50 +224,6 @@ function whatsappPlan(): SandboxMessagingPlan { }; } -function wechatStartPlan(): SandboxMessagingPlan { - const credential = wechatManifest.credentials[0]; - const nodePreloads = wechatManifest.runtime.openclaw.nodePreloads ?? []; - return { - ...telegramPlan(hashCredential("previous-wechat-token") ?? ""), - workflow: "start-channel", - channels: [ - { - channelId: wechatManifest.id, - displayName: wechatManifest.displayName, - authMode: wechatManifest.auth.mode, - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [ - { - channelId: wechatManifest.id, - credentialId: credential.id, - sourceInput: credential.sourceInput, - providerName: credential.providerName.replaceAll("{sandboxName}", "alpha"), - providerEnvKey: credential.providerEnvKey, - placeholder: credential.placeholder, - credentialAvailable: true, - credentialHash: hashCredential("previous-wechat-token") ?? "", - }, - ], - runtimeSetup: { - nodePreloads: nodePreloads.map((preload) => ({ - ...preload, - channelId: wechatManifest.id, - source: "manifest", - target: "agent", - })), - envAliases: [], - secretScans: [], - }, - }; -} - function googlechatPlan(): SandboxMessagingPlan { return { ...telegramPlan(""), @@ -743,83 +697,6 @@ describe("reconcileReusedSandboxMessaging", () => { }); describe("reconcileSandboxMessaging plan authority", () => { - it("keeps WeChat selected for start/rebuild when the gateway retains its QR token (#10765)", async () => { - const registryPlan = wechatStartPlan(); - const deps = registryDeps(registryPlan); - deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); - vi.stubEnv("WECHAT_BOT_TOKEN", ""); - vi.stubEnv("WECHAT_ACCOUNT_ID", ""); - - const result = await reconcileSandboxMessaging({ - resume: false, - session: null, - sandboxName: "alpha", - agent: { name: "openclaw" }, - deps, - }); - - expect(result.selectedChannels).toEqual(["wechat"]); - expect(result.plan?.runtimeSetup?.nodePreloads.map(({ module }) => module)).toContain( - "wechat-account-placeholder", - ); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-wechat-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "WECHAT_BOT_TOKEN", - ); - expect(deps.note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); - }); - - it("migrates legacy Slack bindings before start/rebuild gateway probes", async () => { - const currentPlan = slackPlan( - hashCredential("previous-slack-bot-token") ?? "", - hashCredential("previous-slack-app-token") ?? "", - ); - const registryPlan: SandboxMessagingPlan = { - ...currentPlan, - workflow: "start-channel", - credentialBindings: currentPlan.credentialBindings.map((binding) => - binding.providerEnvKey === "SLACK_APP_TOKEN" - ? { ...binding, providerName: "alpha-slack-bridge" } - : binding, - ), - }; - const expectedPlan: SandboxMessagingPlan = { - ...registryPlan, - credentialBindings: currentPlan.credentialBindings, - }; - const deps = registryDeps(registryPlan); - deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); - vi.stubEnv("SLACK_BOT_TOKEN", ""); - vi.stubEnv("SLACK_APP_TOKEN", ""); - - const result = await reconcileSandboxMessaging({ - resume: false, - session: null, - sandboxName: "alpha", - agent: { name: "openclaw" }, - deps, - }); - - expect(result).toEqual({ plan: expectedPlan, selectedChannels: ["slack"] }); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_BOT_TOKEN", - ); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-app", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(deps.inspectGatewayCredential).not.toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(expectedPlan); - }); - it("uses the registry plan before a staged plan for an existing sandbox", async () => { const registryToken = "123456:registry-token"; const registryPlan = telegramPlan(hashCredential(registryToken) ?? ""); diff --git a/src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts b/src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts new file mode 100644 index 00000000000..6474a0bbcd3 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { hashCredential } from "../../../security/credential-hash"; +import { createSession } from "../../../state/onboard-session"; +import { detectUnconfiguredMessagingChannels } from "../../messaging-channel-setup"; +import { handleSandboxState } from "./sandbox"; +import { + baseOptions, + createDeps, + makeMinimalPlan, + withTelegramCredentialHash, +} from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), + detectUnconfiguredMessagingChannels: vi.fn(() => []), +})); + +const detectUnconfiguredMessagingChannelsMock = vi.mocked(detectUnconfiguredMessagingChannels); + +describe("Ready sandbox messaging reuse", () => { + beforeEach(() => detectUnconfiguredMessagingChannelsMock.mockReturnValue([])); + + it("keeps the durable plan unchanged when a gateway credential is missing", async () => { + const registryPlan = withTelegramCredentialHash( + makeMinimalPlan("saved", "openclaw", ["telegram"]), + hashCredential("previous-telegram-token"), + ); + const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); + session.steps.sandbox.status = "complete"; + const recordStateSkipped = vi.fn(async () => session); + const writePlanToEnv = vi.fn(); + detectUnconfiguredMessagingChannelsMock.mockReturnValue(["telegram"]); + const { deps, calls, getSession } = createDeps( + { + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => ({ + name: "saved", + pendingRouteReservation: true, + reservationSessionId: session.sessionId, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + }), + getRegistrySandboxMessagingAuthority: () => ({ + authoritative: true, + plan: registryPlan, + }), + inspectGatewayCredential: () => ({ kind: "missing" }), + writePlanToEnv, + recordStateSkipped, + }, + session, + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }), + ).rejects.toThrow( + /Ready sandbox 'saved'.*running sandbox and durable messaging plan were not changed/u, + ); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(recordStateSkipped).not.toHaveBeenCalled(); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(registryPlan); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index d16a45d4535..e4260ff2d73 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -11,10 +11,7 @@ import { } from "../../../state/onboard-checkpoint-decision"; import { CHECKPOINT_SCHEMA_VERSION } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session } from "../../../state/onboard-session"; -import { - detectMessagingChannelsFromEnv, - detectUnconfiguredMessagingChannels, -} from "../../messaging-channel-setup"; +import { detectMessagingChannelsFromEnv } from "../../messaging-channel-setup"; import { handleSandboxState } from "./sandbox"; import { baseOptions, @@ -31,7 +28,6 @@ vi.mock("../../messaging-channel-setup", () => ({ })); const detectMessagingChannelsFromEnvMock = vi.mocked(detectMessagingChannelsFromEnv); -const detectUnconfiguredMessagingChannelsMock = vi.mocked(detectUnconfiguredMessagingChannels); function dcodeRegistryEntry(name: string, observabilityEnabled?: boolean) { return { @@ -51,7 +47,6 @@ function dcodeRegistryEntry(name: string, observabilityEnabled?: boolean) { describe("handleSandboxState", () => { beforeEach(() => { detectMessagingChannelsFromEnvMock.mockReturnValue([]); - detectUnconfiguredMessagingChannelsMock.mockReturnValue([]); }); it("creates a sandbox and records messaging/web search state", async () => { @@ -627,58 +622,6 @@ describe("handleSandboxState", () => { expect(result.session).toBe(skippedSession); }); - it("keeps a Ready sandbox plan unchanged when its gateway credential is missing", async () => { - const registryPlan = withTelegramCredentialHash( - makeMinimalPlan("saved", "openclaw", ["telegram"]), - hashCredential("previous-telegram-token"), - ); - const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); - session.steps.sandbox.status = "complete"; - const recordStateSkipped = vi.fn(async () => session); - const writePlanToEnv = vi.fn(); - detectUnconfiguredMessagingChannelsMock.mockReturnValue(["telegram"]); - const { deps, calls, getSession } = createDeps( - { - getSandboxReuseState: () => "ready", - getSandboxRegistryEntry: () => ({ - name: "saved", - pendingRouteReservation: true, - reservationSessionId: session.sessionId, - provider: "provider", - model: "model", - endpointUrl: null, - preferredInferenceApi: "openai-completions", - toolDisclosure: "progressive", - fromDockerfile: null, - hermesAuthMethod: null, - }), - getRegistrySandboxMessagingAuthority: () => ({ - authoritative: true, - plan: registryPlan, - }), - inspectGatewayCredential: () => ({ kind: "missing" }), - writePlanToEnv, - recordStateSkipped, - }, - session, - ); - - await expect( - handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - }), - ).rejects.toThrow( - /Ready sandbox 'saved'.*running sandbox and durable messaging plan were not changed/u, - ); - - expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(recordStateSkipped).not.toHaveBeenCalled(); - expect(writePlanToEnv).not.toHaveBeenCalled(); - expect(getSession().messagingPlan).toEqual(registryPlan); - }); - it("treats checkpoint machine-state progress past sandbox as step-complete even when the legacy step status is stale (#6228)", async () => { const session = createSession({ sandboxName: "saved", From 8a3fce29afd1c7fc7bd22f8d6800bc1efdc4c524 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 22:16:46 -0700 Subject: [PATCH 09/17] fix(messaging): address reuse review findings Signed-off-by: Prekshi Vyas --- src/lib/onboard/checkpoint-replay.test.ts | 26 - .../credential-provider-registration.test.ts | 77 +-- .../credential-provider-registration.ts | 66 --- .../sandbox-messaging-gateway-reuse.test.ts | 180 ------ .../handlers/sandbox-messaging.test.ts | 533 ++++++------------ .../machine/handlers/sandbox-messaging.ts | 101 +++- .../sandbox-ready-messaging-reuse.test.ts | 78 --- .../handlers/sandbox-ready-messaging.test.ts | 126 ++++- test/e2e/live/channels-stop-start-helpers.ts | 27 +- .../channels-stop-start-googlechat.test.ts | 11 +- .../sandbox-messaging-test-fixtures.ts | 371 ++++++++++++ 11 files changed, 717 insertions(+), 879 deletions(-) delete mode 100644 src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts delete mode 100644 src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts create mode 100644 test/helpers/sandbox-messaging-test-fixtures.ts diff --git a/src/lib/onboard/checkpoint-replay.test.ts b/src/lib/onboard/checkpoint-replay.test.ts index aad4c9d3866..5f2e4b3f316 100644 --- a/src/lib/onboard/checkpoint-replay.test.ts +++ b/src/lib/onboard/checkpoint-replay.test.ts @@ -521,32 +521,6 @@ describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => { }); }); -describe("crash-then-resume matrix proves at-most-once destructive create (#6228)", () => { - const states = [ - "sandbox", - "openclaw", - "agent_setup", - "policies", - "finalizing", - "post_verify", - ] as const; - - it.each(states)( - "crash at %s: reuse a surviving sandbox, recreate under the same identity when it is gone", - (state) => { - const cp = checkpoint({ - machineState: state, - effectGroups: { sandbox_create: { completedAt: ISO, fingerprint: "fp" } }, - }); - expect(planSandboxCreateReplay(cp, { liveSandboxExists: true }).action).toBe("reuse"); - expect(planSandboxCreateReplay(cp, { liveSandboxExists: false })).toEqual({ - action: "create", - identity: { name: "my-sandbox", agent: "openclaw" }, - }); - }, - ); -}); - describe("revalidateCheckpointBindings fails closed without leaking values (#6228)", () => { it("passes when every binding is currently available", () => { const cp = checkpoint({ diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index 342a936a2aa..104d0d5a822 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -7,10 +7,8 @@ import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { Session } from "../state/onboard-session"; import { requiredMessagingProviderBindings } from "./checkpoint-replay"; import { - credentialProviderRegistrationDependencies, type CredentialProviderRegistrationDeps, createCredentialProviderRegistration, - installLiveE2eCredentialProviderRegistrationOverride, } from "./credential-provider-registration"; import type { MessagingTokenDef } from "./messaging-prep"; @@ -92,75 +90,6 @@ function sandboxInput(bindings: ReturnType) { } describe("credential provider registration", () => { - it("restricts the process-global provider override to the destructive live E2E", () => { - vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "0"); - try { - expect(() => - installLiveE2eCredentialProviderRegistrationOverride({ - expectedName: "e2e-oc-ch-cycle-googlechat-bridge", - expectedType: "google-chat-bridge", - upsert: vi.fn(() => []), - }), - ).toThrow("restricted to its destructive live E2E"); - } finally { - vi.unstubAllEnvs(); - } - }); - - it("routes one exact Google Chat live E2E plan through the process-global override", () => { - vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "1"); - const tokenDefs: MessagingTokenDef[] = [ - { - name: "e2e-oc-ch-cycle-googlechat-bridge", - envKey: "GOOGLE_CHAT_ACCESS_TOKEN", - token: null, - providerType: "google-chat-bridge", - }, - ]; - const runOpenshell = vi.fn(); - const override = vi.fn(() => ["e2e-oc-ch-cycle-googlechat-bridge"]); - const restore = installLiveE2eCredentialProviderRegistrationOverride({ - expectedName: "e2e-oc-ch-cycle-googlechat-bridge", - expectedType: "google-chat-bridge", - upsert: override, - }); - try { - expect( - credentialProviderRegistrationDependencies.upsertMessagingProviders( - tokenDefs, - runOpenshell, - { replaceExisting: true }, - ), - ).toEqual(["e2e-oc-ch-cycle-googlechat-bridge"]); - expect(override).toHaveBeenCalledExactlyOnceWith(tokenDefs, runOpenshell, { - replaceExisting: true, - }); - } finally { - restore(); - vi.unstubAllEnvs(); - } - }); - - it("resolves the provider upsert dependency when registration executes", () => { - const session = { stagedCredentialProviders: [] } as unknown as Session; - const runOpenshell = vi.fn(); - const deps = registrationDeps(runOpenshell, session); - const registration = createCredentialProviderRegistration(deps); - const tokenDefs: MessagingTokenDef[] = [ - { name: "alpha-googlechat-bridge", envKey: "GOOGLE_CHAT_ACCESS_TOKEN", token: null }, - ]; - const upsert = vi - .spyOn(credentialProviderRegistrationDependencies, "upsertMessagingProviders") - .mockReturnValue(["alpha-googlechat-bridge"]); - - try { - expect(registration.upsertMessagingProviders(tokenDefs)).toEqual(["alpha-googlechat-bridge"]); - expect(upsert).toHaveBeenCalledExactlyOnceWith(tokenDefs, deps.runOpenshell, {}); - } finally { - upsert.mockRestore(); - } - }); - it.each([ { condition: "matches", endpoints: [], expected: true }, { @@ -308,11 +237,7 @@ describe("credential provider registration", () => { const runOpenshell = vi.fn((args: string[]) => args.includes("profile") ? { status: 2, stderr: "gateway unavailable" } - : providerMetadata( - "alpha-discord-bridge", - "discord-hermes-static-v1", - "DISCORD_BOT_TOKEN", - ), + : providerMetadata("alpha-discord-bridge", "discord-hermes-static-v1", "DISCORD_BOT_TOKEN"), ); const deps = registrationDeps(runOpenshell, session); deps.root = process.cwd(); diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 141da697ce5..712328aaf3c 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -12,59 +12,6 @@ import { createGatewayScopedOpenshellRunner } from "./setup-inference"; const providers = require("./providers"); -type CredentialProviderRegistrationUpsert = ( - tokenDefs: MessagingTokenDef[], - runOpenshell: OpenshellCliHelpers["runOpenshell"], - options: MessagingProviderRegistrationOptions, -) => string[]; - -type LiveE2eCredentialProviderOverride = { - readonly expectedName: string; - readonly expectedType: string; - readonly upsert: CredentialProviderRegistrationUpsert; -}; - -const LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY = - "__nemoclawLiveE2eCredentialProviderRegistrationOverride" as const; - -function liveE2eCredentialProviderOverride(): LiveE2eCredentialProviderOverride | null { - const state = globalThis as typeof globalThis & { - [LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]?: LiveE2eCredentialProviderOverride; - }; - return state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY] ?? null; -} - -/** Install the exact Google Chat fake-mint boundary used by the destructive live E2E. */ -export function installLiveE2eCredentialProviderRegistrationOverride(input: { - readonly expectedName: string; - readonly expectedType: "google-chat-bridge" | "google-chat-hermes-bridge"; - readonly upsert: CredentialProviderRegistrationUpsert; -}): () => void { - if ( - process.env.NEMOCLAW_RUN_LIVE_E2E !== "1" || - !/^e2e-(?:oc|hm)-ch-[a-z0-9-]+-googlechat-bridge$/u.test(input.expectedName) - ) { - throw new Error("Google Chat provider override is restricted to its destructive live E2E."); - } - const state = globalThis as typeof globalThis & { - [LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]?: LiveE2eCredentialProviderOverride; - }; - if (state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]) { - throw new Error("A live E2E credential provider override is already installed."); - } - const installed = { ...input }; - state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY] = installed; - let restored = false; - return () => { - if (restored) return; - if (state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY] !== installed) { - throw new Error("The live E2E credential provider override changed before cleanup."); - } - delete state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]; - restored = true; - }; -} - /** Late-bound provider upsert seam used by live credential fixtures. */ export const credentialProviderRegistrationDependencies = { upsertMessagingProviders( @@ -72,19 +19,6 @@ export const credentialProviderRegistrationDependencies = { runOpenshell: OpenshellCliHelpers["runOpenshell"], options: MessagingProviderRegistrationOptions, ): string[] { - const override = liveE2eCredentialProviderOverride(); - if (override) { - const expected = tokenDefs.filter( - ({ envKey, name, providerType }) => - envKey === "GOOGLE_CHAT_ACCESS_TOKEN" && - name === override.expectedName && - providerType === override.expectedType, - ); - if (expected.length !== 1) { - throw new Error("Google Chat live E2E provider override received an unexpected plan."); - } - return override.upsert(tokenDefs, runOpenshell, options); - } return providers.upsertMessagingProviders(tokenDefs, runOpenshell, options) as string[]; }, }; diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts deleted file mode 100644 index 0b8b6540e51..00000000000 --- a/src/lib/onboard/machine/handlers/sandbox-messaging-gateway-reuse.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { wechatManifest } from "../../../messaging/channels/built-ins"; -import type { - ChannelAuthMode, - SandboxMessagingCredentialBindingPlan, - SandboxMessagingPlan, -} from "../../../messaging/manifest"; -import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; -import { hashCredential } from "../../../security/credential-hash"; -import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; -import { reconcileSandboxMessaging } from "./sandbox-messaging"; - -function lifecyclePlan( - channelId: SandboxMessagingPlan["channels"][number]["channelId"], - displayName: string, - authMode: ChannelAuthMode, - credentialBindings: readonly SandboxMessagingCredentialBindingPlan[], -): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName: "alpha", - agent: "openclaw", - workflow: "start-channel", - channels: [ - { - channelId, - displayName, - authMode, - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings, - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - -function gatewayReuseDeps(plan: SandboxMessagingPlan) { - return { - note: vi.fn(), - showMessagingStage: vi.fn(), - getRecordedMessagingChannelsForResume: vi.fn((): string[] | null => null), - setupMessagingChannels: vi.fn(async () => []), - readMessagingPlanFromEnv: vi.fn((): SandboxMessagingPlan | null => null), - writePlanToEnv: vi.fn(), - clearPlanEnv: vi.fn(), - getRegistrySandboxMessagingAuthority: vi.fn(() => ({ authoritative: true as const, plan })), - inspectGatewayCredential: vi.fn< - (name: string, type: string, credentialEnv: string) => GatewayCredentialOnlyProviderInspection - >(() => ({ kind: "exact" })), - providerMatchesGatewayCredential: vi.fn(() => false), - }; -} - -function slackBinding( - credentialId: "slackBotToken" | "slackAppToken", - providerName: string, - providerEnvKey: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN", -): SandboxMessagingCredentialBindingPlan { - return { - channelId: "slack", - credentialId, - sourceInput: credentialId === "slackBotToken" ? "botToken" : "appToken", - providerName, - providerEnvKey, - placeholder: `openshell:resolve:env:${providerEnvKey}`, - credentialAvailable: true, - credentialHash: hashCredential(`previous-${providerEnvKey.toLowerCase()}`) ?? "", - }; -} - -beforeEach(() => vi.unstubAllEnvs()); -afterEach(() => vi.unstubAllEnvs()); - -describe("gateway-backed messaging lifecycle reuse", () => { - it("keeps WeChat selected for start/rebuild when the gateway retains its QR token (#10765)", async () => { - const credential = wechatManifest.credentials[0]; - const nodePreloads = wechatManifest.runtime.openclaw.nodePreloads ?? []; - const plan: SandboxMessagingPlan = { - ...lifecyclePlan(wechatManifest.id, wechatManifest.displayName, wechatManifest.auth.mode, [ - { - channelId: wechatManifest.id, - credentialId: credential.id, - sourceInput: credential.sourceInput, - providerName: credential.providerName.replaceAll("{sandboxName}", "alpha"), - providerEnvKey: credential.providerEnvKey, - placeholder: credential.placeholder, - credentialAvailable: true, - credentialHash: hashCredential("previous-wechat-token") ?? "", - }, - ]), - runtimeSetup: { - nodePreloads: nodePreloads.map((preload) => ({ - ...preload, - channelId: wechatManifest.id, - source: "manifest", - target: "agent", - })), - envAliases: [], - secretScans: [], - }, - }; - const deps = gatewayReuseDeps(plan); - vi.stubEnv("WECHAT_BOT_TOKEN", ""); - vi.stubEnv("WECHAT_ACCOUNT_ID", ""); - - const result = await reconcileSandboxMessaging({ - resume: false, - session: null, - sandboxName: "alpha", - agent: { name: "openclaw" }, - deps, - }); - - expect(result.selectedChannels).toEqual(["wechat"]); - expect(result.plan?.runtimeSetup?.nodePreloads.map(({ module }) => module)).toContain( - "wechat-account-placeholder", - ); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-wechat-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "WECHAT_BOT_TOKEN", - ); - expect(deps.note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); - }); - - it("migrates legacy Slack bindings before start/rebuild gateway probes", async () => { - const currentBindings = [ - slackBinding("slackBotToken", "alpha-slack-bridge", "SLACK_BOT_TOKEN"), - slackBinding("slackAppToken", "alpha-slack-app", "SLACK_APP_TOKEN"), - ]; - const legacyPlan = lifecyclePlan("slack", "Slack", "token-paste", [ - currentBindings[0], - { ...currentBindings[1], providerName: "alpha-slack-bridge" }, - ]); - const expectedPlan = { ...legacyPlan, credentialBindings: currentBindings }; - const deps = gatewayReuseDeps(legacyPlan); - vi.stubEnv("SLACK_BOT_TOKEN", ""); - vi.stubEnv("SLACK_APP_TOKEN", ""); - - const result = await reconcileSandboxMessaging({ - resume: false, - session: null, - sandboxName: "alpha", - agent: { name: "openclaw" }, - deps, - }); - - expect(result).toEqual({ plan: expectedPlan, selectedChannels: ["slack"] }); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_BOT_TOKEN", - ); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-app", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(deps.inspectGatewayCredential).not.toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(expectedPlan); - }); -}); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 389d64a423a..c3c2c58d017 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -6,295 +6,32 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentDefinition } from "../../../agent/defs"; import { MessagingSetupApplier } from "../../../messaging/applier/setup-applier"; import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../../../messaging/applier/types"; +import { wechatManifest } from "../../../messaging/channels/built-ins"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; -import type { RegistryMessagingAuthority } from "../../../messaging/plan-authority"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; import { hashCredential } from "../../../security/credential-hash"; -import { decisionSelected, decisionUnset } from "../../../state/onboard-checkpoint-decision"; -import { - CHECKPOINT_SCHEMA_VERSION, - type OnboardCheckpoint, -} from "../../../state/onboard-checkpoint-types"; -import { createSession, type Session } from "../../../state/onboard-session"; import { setupMessagingChannels } from "../../messaging-channel-setup"; -import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; import { getActiveChannelsFromPlan } from "../../messaging-plan-session"; import { hasMessagingCredentialDrift, reconcileReusedSandboxMessaging, reconcileSandboxMessaging, } from "./sandbox-messaging"; - -const channelIds = ["telegram", "unsupported"]; - -function mixedChannelPlan(): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - channels: channelIds.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: "token-paste", - active: channelId === "telegram", - selected: true, - configured: true, - disabled: channelId !== "telegram", - inputs: [], - hooks: [], - })), - disabledChannels: ["unsupported"], - credentialBindings: channelIds.map((channelId) => ({ - channelId, - credentialId: "token", - sourceInput: "token", - providerName: `alpha-${channelId}`, - providerEnvKey: `${channelId.toUpperCase()}_TOKEN`, - placeholder: `openshell:resolve:env:${channelId.toUpperCase()}_TOKEN`, - credentialAvailable: true, - })), - networkPolicy: { - presets: [...channelIds], - entries: channelIds.map((channelId) => ({ - channelId, - presetName: channelId, - policyKeys: [`${channelId}_api`], - source: "manifest", - })), - }, - agentRender: channelIds.map((channelId) => ({ - channelId, - kind: "json-fragment", - agent: "openclaw", - target: "openclaw.json", - path: `channels.${channelId}`, - value: { enabled: true }, - templateRefs: [], - })), - buildSteps: channelIds.map((channelId) => ({ - channelId, - kind: "build-arg", - outputId: `${channelId}-arg`, - required: true, - value: "enabled", - })), - runtimeSetup: { - nodePreloads: channelIds.map((channelId) => ({ - channelId, - module: `${channelId}-preload`, - source: "manifest", - target: "agent", - })), - envAliases: channelIds.map((channelId) => ({ - channelId, - envKey: `${channelId.toUpperCase()}_TOKEN`, - match: "source", - value: "target", - })), - secretScans: channelIds.map((channelId) => ({ - channelId, - path: `/sandbox/${channelId}`, - pattern: "secret", - message: "secret found", - })), - }, - stateUpdates: channelIds.map((channelId) => ({ - channelId, - kind: "persist-inputs", - stateKey: `${channelId}Config`, - inputIds: ["token"], - })), - healthChecks: channelIds.map((channelId) => ({ - channelId, - phase: "health-check", - requiredBefore: "lifecycle-success", - hookIds: [`${channelId}-health`], - })), - }; -} - -function channelIdsFrom(entries: readonly T[]): string[] { - return entries.map((entry) => entry.channelId); -} - -function telegramPlan(credentialHash: string): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - channels: [ - { - channelId: "telegram", - displayName: "Telegram", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [ - { - channelId: "telegram", - credentialId: "botToken", - sourceInput: "botToken", - providerName: "alpha-telegram-bridge", - providerEnvKey: "TELEGRAM_BOT_TOKEN", - placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - credentialAvailable: true, - credentialHash, - }, - ], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - -function discordPlan( - credentialHash: string, - agent: SandboxMessagingPlan["agent"] = "openclaw", -): SandboxMessagingPlan { - return { - ...telegramPlan(credentialHash), - agent, - channels: [ - { - channelId: "discord", - displayName: "Discord", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [ - { - channelId: "discord", - credentialId: "discordBotToken", - sourceInput: "botToken", - providerName: "alpha-discord-bridge", - providerEnvKey: "DISCORD_BOT_TOKEN", - placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", - credentialAvailable: true, - credentialHash, - }, - ], - }; -} - -function withChannelDisabled(plan: SandboxMessagingPlan, channelId: string): SandboxMessagingPlan { - return { - ...plan, - channels: plan.channels.map((channel) => - channel.channelId === channelId - ? { ...channel, active: false, selected: false, disabled: true } - : channel, - ), - disabledChannels: [...new Set([...plan.disabledChannels, channelId])], - }; -} - -function whatsappPlan(): SandboxMessagingPlan { - return { - ...telegramPlan(""), - channels: [ - { - channelId: "whatsapp", - displayName: "WhatsApp", - authMode: "in-sandbox-qr", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [], - }; -} - -function googlechatPlan(): SandboxMessagingPlan { - return { - ...telegramPlan(""), - channels: [ - { - channelId: "googlechat", - displayName: "Google Chat", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - // A bridge channel mints its provider from a profile, so it renders no binding. - credentialBindings: [], - }; -} - -function slackPlan( - botCredentialHash: string, - appCredentialHash?: string, - agent: SandboxMessagingPlan["agent"] = "openclaw", -): SandboxMessagingPlan { - const appBinding = appCredentialHash - ? [ - { - channelId: "slack", - credentialId: "slackAppToken", - sourceInput: "appToken", - providerName: "alpha-slack-app", - providerEnvKey: "SLACK_APP_TOKEN", - placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", - credentialAvailable: true, - credentialHash: appCredentialHash, - }, - ] - : []; - return { - ...telegramPlan(botCredentialHash), - agent, - channels: [ - { - channelId: "slack", - displayName: "Slack", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [ - { - channelId: "slack", - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "alpha-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - credentialAvailable: true, - credentialHash: botCredentialHash, - }, - ...appBinding, - ], - }; -} +import { + channelIdsFrom, + completedCheckpointSession, + discordPlan, + googlechatPlan, + mixedChannelPlan, + reconcileDeps, + recordedResumeDeps, + registryDeps, + slackPlan, + telegramPlan, + whatsappPlan, + withChannelDisabled, + withMessagingCheckpoint, +} from "../../../../../test/helpers/sandbox-messaging-test-fixtures"; describe("hasMessagingCredentialDrift", () => { const oldToken = "123456:old-telegram-token"; @@ -319,86 +56,6 @@ describe("hasMessagingCredentialDrift", () => { ).toBe(false); }); }); -function completedCheckpointSession( - plan: SandboxMessagingPlan, - stagedCredentialProviders: string[] = [], -) { - const session = createSession(); - session.sandboxName = plan.sandboxName; - session.messagingPlan = plan; - session.stagedCredentialProviders = stagedCredentialProviders; - session.sandboxPromptProgress.sandboxName = true; - session.sandboxPromptProgress.messaging = true; - return session; -} - -function withMessagingCheckpoint( - session: Session, - selectedChannels: string[], - disabledChannels: string[] = [], -): Session { - const checkpoint: OnboardCheckpoint = { - schemaVersion: CHECKPOINT_SCHEMA_VERSION, - profile: { kind: "selected", value: "default" }, - runtimeAuthority: { kind: "unset" }, - sessionId: session.sessionId, - machineState: session.machine.state, - updatedAt: "2026-01-01T00:00:00.000Z", - sandboxIdentity: decisionUnset(), - webSearch: decisionUnset(), - messaging: decisionSelected({ selectedChannels, disabledChannels }), - resourceProfile: decisionUnset(), - gatewayAuthority: decisionUnset(), - effectGroups: {}, - bindings: { credentialEnvs: [], registeredProviders: [] }, - sandboxRecreate: null, - }; - session.checkpoint = checkpoint; - return session; -} - -function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { - return { - note: vi.fn(), - showMessagingStage: vi.fn(), - getRecordedMessagingChannelsForResume: vi.fn((): string[] | null => null), - setupMessagingChannels: vi.fn( - async ( - _agent: unknown, - _existingChannels: string[] | null, - _sandboxName: string, - _options?: { readonly selectionCompleted?: boolean }, - ) => ["telegram"], - ), - readMessagingPlanFromEnv: vi - .fn() - .mockReturnValueOnce(plans[0] ?? null) - .mockReturnValue(plans[1] ?? plans[0] ?? null), - writePlanToEnv: vi.fn(), - clearPlanEnv: vi.fn(), - getRegistrySandboxMessagingAuthority: vi.fn((): RegistryMessagingAuthority => ({ - authoritative: false, - plan: null, - })), - inspectGatewayCredential: vi.fn< - (name: string, type: string, credentialEnv: string) => GatewayCredentialOnlyProviderInspection - >(() => ({ kind: "missing" })), - providerMatchesGatewayCredential: vi.fn(() => false), - }; -} - -function registryDeps(plan: SandboxMessagingPlan) { - const deps = reconcileDeps([]); - deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ authoritative: true, plan }); - return deps; -} - -function recordedResumeDeps(plan: SandboxMessagingPlan) { - const deps = reconcileDeps([plan]); - deps.getRecordedMessagingChannelsForResume.mockReturnValue(["discord", "googlechat"]); - return deps; -} - beforeEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); @@ -442,13 +99,10 @@ describe("reconcileReusedSandboxMessaging", () => { vi.stubEnv("DISCORD_BOT_TOKEN", ""); expect(() => - reconcileReusedSandboxMessaging( - structuredClone(plan), - { name: "openclaw" }, - deps, - plan, - ), - ).toThrow(/Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u); + reconcileReusedSandboxMessaging(structuredClone(plan), { name: "openclaw" }, deps, plan), + ).toThrow( + /Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u, + ); expect(deps.writePlanToEnv).not.toHaveBeenCalled(); expect(deps.clearPlanEnv).not.toHaveBeenCalled(); }); @@ -513,7 +167,9 @@ describe("reconcileReusedSandboxMessaging", () => { }, plan, ), - ).toThrow(/Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u); + ).toThrow( + /Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u, + ); expect(writePlanToEnv).not.toHaveBeenCalled(); expect(note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); }); @@ -565,7 +221,9 @@ describe("reconcileReusedSandboxMessaging", () => { { clearPlanEnv: vi.fn(), inspectGatewayCredential, note: vi.fn(), writePlanToEnv }, plan, ), - ).toThrow(/Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u); + ).toThrow( + /Ready sandbox 'alpha'.*running sandbox and durable messaging plan were not changed/u, + ); expect(writePlanToEnv).not.toHaveBeenCalled(); expect(inspectGatewayCredential).toHaveBeenCalledTimes(2); }); @@ -693,10 +351,145 @@ describe("reconcileReusedSandboxMessaging", () => { healthChecks: ["telegram"], }); }); - }); describe("reconcileSandboxMessaging plan authority", () => { + it("refreshes credential hashes from the caller environment instead of ambient process state", async () => { + const callerToken = "caller-telegram-token"; + const ambientToken = "ambient-telegram-token"; + const plan = { + ...telegramPlan(hashCredential("previous-telegram-token") ?? ""), + workflow: "start-channel" as const, + }; + const deps = registryDeps(plan); + vi.stubEnv("TELEGRAM_BOT_TOKEN", ambientToken); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + env: { TELEGRAM_BOT_TOKEN: callerToken }, + deps, + }); + + expect(result.plan?.credentialBindings[0]?.credentialHash).toBe(hashCredential(callerToken)); + expect(result.plan?.credentialBindings[0]?.credentialHash).not.toBe( + hashCredential(ambientToken), + ); + expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(result.plan); + }); + + it("keeps WeChat selected for start/rebuild when the gateway retains its QR token (#10765)", async () => { + const credential = wechatManifest.credentials[0]; + const baseline = telegramPlan(hashCredential("previous-wechat-token") ?? ""); + const plan: SandboxMessagingPlan = { + ...baseline, + workflow: "start-channel", + channels: [ + { + ...baseline.channels[0], + channelId: wechatManifest.id, + displayName: wechatManifest.displayName, + authMode: wechatManifest.auth.mode, + }, + ], + credentialBindings: [ + { + channelId: wechatManifest.id, + credentialId: credential.id, + sourceInput: credential.sourceInput, + providerName: credential.providerName.replaceAll("{sandboxName}", "alpha"), + providerEnvKey: credential.providerEnvKey, + placeholder: credential.placeholder, + credentialAvailable: true, + credentialHash: hashCredential("previous-wechat-token") ?? "", + }, + ], + runtimeSetup: { + nodePreloads: (wechatManifest.runtime.openclaw.nodePreloads ?? []).map((preload) => ({ + ...preload, + channelId: wechatManifest.id, + source: "manifest", + target: "agent", + })), + envAliases: [], + secretScans: [], + }, + }; + const deps = registryDeps(plan); + deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); + vi.stubEnv("WECHAT_BOT_TOKEN", ""); + vi.stubEnv("WECHAT_ACCOUNT_ID", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(result.selectedChannels).toEqual(["wechat"]); + expect(result.plan?.runtimeSetup?.nodePreloads.map(({ module }) => module)).toContain( + "wechat-account-placeholder", + ); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-wechat-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "WECHAT_BOT_TOKEN", + ); + expect(deps.note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); + }); + + it("migrates legacy Slack bindings before start/rebuild gateway probes", async () => { + const currentPlan = { + ...slackPlan( + hashCredential("previous-slack-bot-token") ?? "", + hashCredential("previous-slack-app-token") ?? "", + ), + workflow: "start-channel" as const, + }; + const legacyPlan = { + ...currentPlan, + credentialBindings: currentPlan.credentialBindings.map((binding) => + binding.providerEnvKey === "SLACK_APP_TOKEN" + ? { ...binding, providerName: "alpha-slack-bridge" } + : binding, + ), + }; + const deps = registryDeps(legacyPlan); + deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(result).toEqual({ plan: currentPlan, selectedChannels: ["slack"] }); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_BOT_TOKEN", + ); + expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-app", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(deps.inspectGatewayCredential).not.toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(currentPlan); + }); + it("uses the registry plan before a staged plan for an existing sandbox", async () => { const registryToken = "123456:registry-token"; const registryPlan = telegramPlan(hashCredential(registryToken) ?? ""); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 7b667e8f352..185913e56de 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -129,14 +129,17 @@ function messagingChannelsWithCredentialDrift( return [...driftedChannels]; } -function refreshCredentialHashesFromEnv(plan: SandboxMessagingPlan): { +function refreshCredentialHashesFromEnv( + plan: SandboxMessagingPlan, + env: NodeJS.ProcessEnv, +): { plan: SandboxMessagingPlan; changed: boolean; } { let changed = false; const credentialBindings = plan.credentialBindings.map((binding) => { if (binding.credentialAvailable !== true) return binding; - const credentialHash = hashCredential(process.env[binding.providerEnvKey]); + const credentialHash = hashCredential(env[binding.providerEnvKey]); if (!credentialHash || credentialHash === binding.credentialHash) return binding; changed = true; return { ...binding, credentialHash }; @@ -222,8 +225,9 @@ interface PreparedReusablePlan extends SandboxMessagingSelection { function prepareReusablePlan( plan: SandboxMessagingPlan, agent: Agent, + env: NodeJS.ProcessEnv, ): PreparedReusablePlan { - const refreshed = refreshCredentialHashesFromEnv(plan); + const refreshed = refreshCredentialHashesFromEnv(plan, env); const normalized = normalizeMessagingProviderBindings(plan.sandboxName, refreshed.plan); const filtered = filterMessagingPlanForCurrentAgent(normalized, agent); if (!filtered) { @@ -240,9 +244,10 @@ function selectionFromReusablePlan( plan: SandboxMessagingPlan, agent: Agent, writeToEnv: boolean, + env: NodeJS.ProcessEnv, deps: SandboxMessagingDeps, ): SandboxMessagingSelection { - const prepared = prepareReusablePlan(plan, agent); + const prepared = prepareReusablePlan(plan, agent, env); if (!prepared.plan) { deps.clearPlanEnv(); return { plan: null, selectedChannels: [] }; @@ -473,12 +478,13 @@ function selectionFromReconciledReusablePlan( plan: SandboxMessagingPlan, agent: Agent, writeToEnv: boolean, + env: NodeJS.ProcessEnv, deps: Pick< SandboxMessagingDeps, "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" >, ): SandboxMessagingSelection { - const prepared = prepareReusablePlan(plan, agent); + const prepared = prepareReusablePlan(plan, agent, env); const reusable = { plan: prepared.plan, selectedChannels: prepared.selectedChannels }; const reconciled = filterUnconfiguredHostChannelsFromSelection(reusable, agent, deps, false); if (writeToEnv || prepared.changed || reconciled.plan !== prepared.plan) { @@ -503,11 +509,18 @@ function selectionFromRecordedChannels( registryPlan, options.agent, true, + options.env as NodeJS.ProcessEnv, options.deps, ); } else { if (envPlan) { - selection = selectionFromReconciledReusablePlan(envPlan, options.agent, false, options.deps); + selection = selectionFromReconciledReusablePlan( + envPlan, + options.agent, + false, + options.env as NodeJS.ProcessEnv, + options.deps, + ); } else { selection = filterUnconfiguredHostChannelsFromSelection( selection, @@ -548,7 +561,13 @@ async function selectionFromRegistryPlan( // A lifecycle command owns which channels the operator asked for, but not // whether the host still configures them. Onboarding re-reads the host // either way, so the same removal check applies here. - return selectionFromReconciledReusablePlan(registryPlan, options.agent, true, options.deps); + return selectionFromReconciledReusablePlan( + registryPlan, + options.agent, + true, + options.env as NodeJS.ProcessEnv, + options.deps, + ); } const activeChannels = filterChannelNamesForCurrentAgent( getActiveChannelsFromPlan(registryPlan), @@ -556,7 +575,7 @@ async function selectionFromRegistryPlan( ); const credentialDriftChannels = messagingChannelsWithCredentialDrift( registryPlan, - options.env ?? process.env, + options.env as NodeJS.ProcessEnv, activeChannels, ); if (credentialDriftChannels.length > 0) { @@ -572,7 +591,13 @@ async function selectionFromRegistryPlan( } const detectedChannels = channelsForRegistryPlanRefresh(registryPlan, options.agent); if (!detectedChannels) { - return selectionFromReconciledReusablePlan(registryPlan, options.agent, true, options.deps); + return selectionFromReconciledReusablePlan( + registryPlan, + options.agent, + true, + options.env as NodeJS.ProcessEnv, + options.deps, + ); } options.deps.note( ` [non-interactive] Detected messaging channel inputs for ${detectedChannels.join(", ")}; refreshing reused sandbox messaging plan.`, @@ -727,6 +752,7 @@ async function selectionFromCompletedMessagingCheckpoint( validationPlan, options.agent, envPlan !== validationPlan, + options.env as NodeJS.ProcessEnv, options.deps, ); options.deps.showMessagingStage?.(); @@ -755,6 +781,7 @@ async function selectionFromCompletedMessagingCheckpoint( validationPlan, options.agent, envPlan !== validationPlan, + options.env as NodeJS.ProcessEnv, options.deps, ); options.deps.showMessagingStage?.(); @@ -811,13 +838,19 @@ async function selectionFromForcedCredentialValidation( } const requiredChannels = messagingChannelsWithCredentialDrift( validationBaseline, - options.env ?? process.env, + options.env as NodeJS.ProcessEnv, ); if (requiredChannels.length === 0) { requiredChannels.push(...getActiveChannelsFromPlan(validationBaseline)); } if (requiredChannels.length === 0) { - return selectionFromReusablePlan(validationBaseline, options.agent, true, options.deps); + return selectionFromReusablePlan( + validationBaseline, + options.agent, + true, + options.env as NodeJS.ProcessEnv, + options.deps, + ); } options.deps.writePlanToEnv(validationBaseline); return selectionFromMessagingSetup(requiredChannels, options, true, validationBaseline); @@ -849,50 +882,60 @@ async function selectionFromCompletedMessagingAuthority( export async function reconcileSandboxMessaging( options: ReconcileSandboxMessagingOptions, ): Promise { + const resolvedOptions: ReconcileSandboxMessagingOptions & { env: NodeJS.ProcessEnv } = { + ...options, + env: options.env ?? process.env, + }; const registry = - options.registryAuthoritySnapshot ?? - options.deps.getRegistrySandboxMessagingAuthority(options.sandboxName); - const envPlan = registry.authoritative ? null : options.deps.readMessagingPlanFromEnv(); + resolvedOptions.registryAuthoritySnapshot ?? + resolvedOptions.deps.getRegistrySandboxMessagingAuthority(resolvedOptions.sandboxName); + const envPlan = registry.authoritative ? null : resolvedOptions.deps.readMessagingPlanFromEnv(); const authority = resolveMessagingPlanAuthority({ - sandboxName: options.sandboxName, + sandboxName: resolvedOptions.sandboxName, registry, stagedPlan: envPlan, - sessionPlan: options.session?.messagingPlan ?? null, + sessionPlan: resolvedOptions.session?.messagingPlan ?? null, }); - const forcedValidationSelection = await selectionFromForcedCredentialValidation(options); + const forcedValidationSelection = await selectionFromForcedCredentialValidation(resolvedOptions); if (forcedValidationSelection) return forcedValidationSelection; - const messagingDecisionCompleted = options.session?.checkpoint - ? !isDecisionUnset(options.session.checkpoint.messaging) - : options.session?.sandboxPromptProgress?.messaging === true; + const messagingDecisionCompleted = resolvedOptions.session?.checkpoint + ? !isDecisionUnset(resolvedOptions.session.checkpoint.messaging) + : resolvedOptions.session?.sandboxPromptProgress?.messaging === true; const registrySelection = await selectionFromRegistryAuthority( authority, envPlan, messagingDecisionCompleted, - options, + resolvedOptions, ); if (registrySelection) return registrySelection; const completedSelection = await selectionFromCompletedMessagingAuthority( authority, envPlan, messagingDecisionCompleted, - options, + resolvedOptions, ); if (completedSelection) return completedSelection; - const recordedChannels = options.deps.getRecordedMessagingChannelsForResume( - options.resume, - options.session, - options.sandboxName, + const recordedChannels = resolvedOptions.deps.getRecordedMessagingChannelsForResume( + resolvedOptions.resume, + resolvedOptions.session, + resolvedOptions.sandboxName, ); if (recordedChannels) { return selectionFromRecordedChannels( recordedChannels, stagedPlanFromAuthority(authority), null, - options, + resolvedOptions, ); } if (authority.source === "staged" && authority.plan) { - return selectionFromReusablePlan(authority.plan, options.agent, false, options.deps); + return selectionFromReusablePlan( + authority.plan, + resolvedOptions.agent, + false, + resolvedOptions.env, + resolvedOptions.deps, + ); } - return selectionFromMessagingSetup(getChannelsFromPlan(authority.plan), options); + return selectionFromMessagingSetup(getChannelsFromPlan(authority.plan), resolvedOptions); } diff --git a/src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts b/src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts deleted file mode 100644 index 6474a0bbcd3..00000000000 --- a/src/lib/onboard/machine/handlers/sandbox-ready-messaging-reuse.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { hashCredential } from "../../../security/credential-hash"; -import { createSession } from "../../../state/onboard-session"; -import { detectUnconfiguredMessagingChannels } from "../../messaging-channel-setup"; -import { handleSandboxState } from "./sandbox"; -import { - baseOptions, - createDeps, - makeMinimalPlan, - withTelegramCredentialHash, -} from "./sandbox-test-fixtures"; - -vi.mock("../../messaging-channel-setup", () => ({ - detectMessagingChannelsFromEnv: vi.fn(() => []), - detectUnconfiguredMessagingChannels: vi.fn(() => []), -})); - -const detectUnconfiguredMessagingChannelsMock = vi.mocked(detectUnconfiguredMessagingChannels); - -describe("Ready sandbox messaging reuse", () => { - beforeEach(() => detectUnconfiguredMessagingChannelsMock.mockReturnValue([])); - - it("keeps the durable plan unchanged when a gateway credential is missing", async () => { - const registryPlan = withTelegramCredentialHash( - makeMinimalPlan("saved", "openclaw", ["telegram"]), - hashCredential("previous-telegram-token"), - ); - const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); - session.steps.sandbox.status = "complete"; - const recordStateSkipped = vi.fn(async () => session); - const writePlanToEnv = vi.fn(); - detectUnconfiguredMessagingChannelsMock.mockReturnValue(["telegram"]); - const { deps, calls, getSession } = createDeps( - { - getSandboxReuseState: () => "ready", - getSandboxRegistryEntry: () => ({ - name: "saved", - pendingRouteReservation: true, - reservationSessionId: session.sessionId, - provider: "provider", - model: "model", - endpointUrl: null, - preferredInferenceApi: "openai-completions", - toolDisclosure: "progressive", - fromDockerfile: null, - hermesAuthMethod: null, - }), - getRegistrySandboxMessagingAuthority: () => ({ - authoritative: true, - plan: registryPlan, - }), - inspectGatewayCredential: () => ({ kind: "missing" }), - writePlanToEnv, - recordStateSkipped, - }, - session, - ); - - await expect( - handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - }), - ).rejects.toThrow( - /Ready sandbox 'saved'.*running sandbox and durable messaging plan were not changed/u, - ); - - expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(recordStateSkipped).not.toHaveBeenCalled(); - expect(writePlanToEnv).not.toHaveBeenCalled(); - expect(getSession().messagingPlan).toEqual(registryPlan); - }); -}); diff --git a/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts index 1459d7f557d..817b72baa82 100644 --- a/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts @@ -3,10 +3,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hashCredential } from "../../../security/credential-hash"; import { createSession } from "../../../state/onboard-session"; import { detectUnconfiguredMessagingChannels } from "../../messaging-channel-setup"; import { handleSandboxState } from "./sandbox"; -import { baseOptions, createDeps, makeMinimalPlan } from "./sandbox-test-fixtures"; +import { + baseOptions, + createDeps, + makeMinimalPlan, + withTelegramCredentialHash, +} from "./sandbox-test-fixtures"; vi.mock("../../messaging-channel-setup", () => ({ detectMessagingChannelsFromEnv: vi.fn(() => []), @@ -20,38 +26,47 @@ describe("handleSandboxState Ready sandbox messaging", () => { detectUnconfiguredMessagingChannelsMock.mockReturnValue([]); }); - it("omits an unconfigured host-backed channel when reusing a Ready sandbox (#9283)", async () => { - const registryPlan = makeMinimalPlan("saved", "openclaw", ["discord"]); - const disabledPlan = { - ...registryPlan, - channels: registryPlan.channels.map((channel) => ({ - ...channel, - active: false, - selected: false, - disabled: true, - })), - disabledChannels: ["discord"], + it("keeps a Ready channel when its credential remains at the gateway", async () => { + const minimalPlan = makeMinimalPlan("saved", "openclaw", ["discord"]); + const registryPlan = { + ...minimalPlan, + credentialBindings: [ + { + channelId: "discord", + credentialId: "discordBotToken", + sourceInput: "botToken", + providerName: "saved-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + credentialAvailable: true, + credentialHash: hashCredential("previous-discord-token") ?? "", + }, + ], }; const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); session.steps.sandbox.status = "complete"; const writePlanToEnv = vi.fn(); detectUnconfiguredMessagingChannelsMock.mockReturnValue(["discord"]); - const { deps, calls, getSession } = createDeps({ - getSandboxReuseState: () => "ready", - getSandboxRegistryEntry: () => ({ - name: "saved", - pendingRouteReservation: true, - provider: "provider", - model: "model", - endpointUrl: null, - preferredInferenceApi: "openai-completions", - toolDisclosure: "progressive", - fromDockerfile: null, - hermesAuthMethod: null, - }), - getRegistrySandboxMessagingAuthority: () => ({ authoritative: true, plan: registryPlan }), - writePlanToEnv, - }); + const { deps, calls, getSession } = createDeps( + { + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => ({ + name: "saved", + pendingRouteReservation: true, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + }), + getRegistrySandboxMessagingAuthority: () => ({ authoritative: true, plan: registryPlan }), + inspectGatewayCredential: () => ({ kind: "exact" }), + writePlanToEnv, + }, + session, + ); const result = await handleSandboxState({ ...baseOptions(deps, session), @@ -60,8 +75,57 @@ describe("handleSandboxState Ready sandbox messaging", () => { }); expect(calls.createSandbox).not.toHaveBeenCalled(); - expect(result.selectedMessagingChannels).toEqual([]); - expect(writePlanToEnv).toHaveBeenLastCalledWith(disabledPlan); - expect(getSession().messagingPlan).toEqual(disabledPlan); + expect(result.selectedMessagingChannels).toEqual(["discord"]); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(registryPlan); + }); + + it("keeps the durable plan unchanged when a gateway credential is missing", async () => { + const registryPlan = withTelegramCredentialHash( + makeMinimalPlan("saved", "openclaw", ["telegram"]), + hashCredential("previous-telegram-token"), + ); + const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); + session.steps.sandbox.status = "complete"; + const recordStateSkipped = vi.fn(async () => session); + const writePlanToEnv = vi.fn(); + detectUnconfiguredMessagingChannelsMock.mockReturnValue(["telegram"]); + const { deps, calls, getSession } = createDeps( + { + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => ({ + name: "saved", + pendingRouteReservation: true, + reservationSessionId: session.sessionId, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + }), + getRegistrySandboxMessagingAuthority: () => ({ authoritative: true, plan: registryPlan }), + inspectGatewayCredential: () => ({ kind: "missing" }), + writePlanToEnv, + recordStateSkipped, + }, + session, + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }), + ).rejects.toThrow( + /Ready sandbox 'saved'.*running sandbox and durable messaging plan were not changed/u, + ); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(recordStateSkipped).not.toHaveBeenCalled(); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(registryPlan); }); }); diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts index 112693fc520..1bf743dec77 100644 --- a/test/e2e/live/channels-stop-start-helpers.ts +++ b/test/e2e/live/channels-stop-start-helpers.ts @@ -59,9 +59,6 @@ type PolicyChannelDependenciesModule = type OpenshellRuntimeModule = typeof import("../../../src/lib/adapters/openshell/runtime.ts"); type CredentialProviderRegistrationModule = typeof import("../../../src/lib/onboard/credential-provider-registration.ts"); -type LiveE2eCredentialProviderOverrideInput = Parameters< - CredentialProviderRegistrationModule["installLiveE2eCredentialProviderRegistrationOverride"] ->[0]; type MessagingBridgeProviderModule = typeof import("../../../src/lib/onboard/messaging-bridge-provider.ts"); type StatePathsModule = typeof import("../../../src/lib/state/paths.ts"); @@ -239,10 +236,9 @@ export function installGooglechatCredentialFixture( }; const providerDependencies = dependencies.providerDependencies ?? credentialProviderRegistrationDependencies; - const injectedProviderDependencies = dependencies.providerDependencies !== undefined; const effectiveLegacyProviderDependencies = dependencies.legacyProviderDependencies ?? - (injectedProviderDependencies ? providerDependencies : legacyProviderDependencies); + (dependencies.providerDependencies ? providerDependencies : legacyProviderDependencies); const root = dependencies.root ?? ROOT; const run = dependencies.run ?? runOpenshell; const originalRegistrationUpsert = providerDependencies.upsertMessagingProviders; @@ -320,21 +316,12 @@ export function installGooglechatCredentialFixture( const registered = new Set([...delegatedProviderNames, expectedName]); return tokenDefs.map(({ name }) => name).filter((name) => registered.has(name)); }; - let restore: () => void; - if (injectedProviderDependencies) { - providerDependencies.upsertMessagingProviders = fixtureUpsert; - effectiveLegacyProviderDependencies.upsertMessagingProviders = fixtureUpsert; - restore = () => { - providerDependencies.upsertMessagingProviders = originalRegistrationUpsert; - effectiveLegacyProviderDependencies.upsertMessagingProviders = originalLegacyUpsert; - }; - } else { - restore = credentialProviderRegistration.installLiveE2eCredentialProviderRegistrationOverride({ - expectedName, - expectedType, - upsert: fixtureUpsert as LiveE2eCredentialProviderOverrideInput["upsert"], - }); - } + providerDependencies.upsertMessagingProviders = fixtureUpsert; + effectiveLegacyProviderDependencies.upsertMessagingProviders = fixtureUpsert; + const restore = () => { + providerDependencies.upsertMessagingProviders = originalRegistrationUpsert; + effectiveLegacyProviderDependencies.upsertMessagingProviders = originalLegacyUpsert; + }; return Object.assign(restore, { upsertMessagingProviders: directUpsert }); } diff --git a/test/e2e/support/channels-stop-start-googlechat.test.ts b/test/e2e/support/channels-stop-start-googlechat.test.ts index dfa3ef454db..c2a144342cf 100644 --- a/test/e2e/support/channels-stop-start-googlechat.test.ts +++ b/test/e2e/support/channels-stop-start-googlechat.test.ts @@ -81,8 +81,7 @@ describe("channels stop/start Google Chat live composition", () => { expect(channelDependencies.upsertMessagingProviders).toBe(originalUpsert); }); - it("routes rebuild registration through the process-global live fixture", () => { - vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "1"); + it("temporarily routes rebuild registration through the live fixture", () => { const sandboxName = "e2e-oc-ch-cycle"; const expectedName = `${sandboxName}-googlechat-bridge`; const runMock = vi.fn((args: string[]) => ({ @@ -91,12 +90,16 @@ describe("channels stop/start Google Chat live composition", () => { stderr: "", })); const run = runMock as unknown as FixtureRunner; + const originalUpsert = credentialProviderRegistrationDependencies.upsertMessagingProviders; const restore = installGooglechatCredentialFixture(sandboxName, "openclaw", { ensureProfiles: vi.fn(), root: "/repo", run, }); try { + expect(credentialProviderRegistrationDependencies.upsertMessagingProviders).not.toBe( + originalUpsert, + ); expect( credentialProviderRegistrationDependencies.upsertMessagingProviders( [ @@ -115,8 +118,10 @@ describe("channels stop/start Google Chat live composition", () => { expect(runMock.mock.calls.some(([args]) => args.includes("refresh"))).toBe(false); } finally { restore(); - vi.unstubAllEnvs(); } + expect(credentialProviderRegistrationDependencies.upsertMessagingProviders).toBe( + originalUpsert, + ); }); it("grants a process-local audience capability to the exact live sandbox", async () => { diff --git a/test/helpers/sandbox-messaging-test-fixtures.ts b/test/helpers/sandbox-messaging-test-fixtures.ts new file mode 100644 index 00000000000..1052344dd01 --- /dev/null +++ b/test/helpers/sandbox-messaging-test-fixtures.ts @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; + +import type { SandboxMessagingPlan } from "../../src/lib/messaging/manifest"; +import type { RegistryMessagingAuthority } from "../../src/lib/messaging/plan-authority"; +import { decisionSelected, decisionUnset } from "../../src/lib/state/onboard-checkpoint-decision"; +import { + CHECKPOINT_SCHEMA_VERSION, + type OnboardCheckpoint, +} from "../../src/lib/state/onboard-checkpoint-types"; +import { createSession, type Session } from "../../src/lib/state/onboard-session"; +import type { GatewayCredentialOnlyProviderInspection } from "../../src/lib/onboard/gateway-provider-metadata"; + +const channelIds = ["telegram", "unsupported"]; + +export function mixedChannelPlan(): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: channelId === "telegram", + selected: true, + configured: true, + disabled: channelId !== "telegram", + inputs: [], + hooks: [], + })), + disabledChannels: ["unsupported"], + credentialBindings: channelIds.map((channelId) => ({ + channelId, + credentialId: "token", + sourceInput: "token", + providerName: `alpha-${channelId}`, + providerEnvKey: `${channelId.toUpperCase()}_TOKEN`, + placeholder: `openshell:resolve:env:${channelId.toUpperCase()}_TOKEN`, + credentialAvailable: true, + })), + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [`${channelId}_api`], + source: "manifest", + })), + }, + agentRender: channelIds.map((channelId) => ({ + channelId, + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: `channels.${channelId}`, + value: { enabled: true }, + templateRefs: [], + })), + buildSteps: channelIds.map((channelId) => ({ + channelId, + kind: "build-arg", + outputId: `${channelId}-arg`, + required: true, + value: "enabled", + })), + runtimeSetup: { + nodePreloads: channelIds.map((channelId) => ({ + channelId, + module: `${channelId}-preload`, + source: "manifest", + target: "agent", + })), + envAliases: channelIds.map((channelId) => ({ + channelId, + envKey: `${channelId.toUpperCase()}_TOKEN`, + match: "source", + value: "target", + })), + secretScans: channelIds.map((channelId) => ({ + channelId, + path: `/sandbox/${channelId}`, + pattern: "secret", + message: "secret found", + })), + }, + stateUpdates: channelIds.map((channelId) => ({ + channelId, + kind: "persist-inputs", + stateKey: `${channelId}Config`, + inputIds: ["token"], + })), + healthChecks: channelIds.map((channelId) => ({ + channelId, + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: [`${channelId}-health`], + })), + }; +} + +export function channelIdsFrom( + entries: readonly T[], +): string[] { + return entries.map((entry) => entry.channelId); +} + +export function telegramPlan(credentialHash: string): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "alpha", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "botToken", + sourceInput: "botToken", + providerName: "alpha-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + credentialHash, + }, + ], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +export function discordPlan( + credentialHash: string, + agent: SandboxMessagingPlan["agent"] = "openclaw", +): SandboxMessagingPlan { + return { + ...telegramPlan(credentialHash), + agent, + channels: [ + { + channelId: "discord", + displayName: "Discord", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + credentialBindings: [ + { + channelId: "discord", + credentialId: "discordBotToken", + sourceInput: "botToken", + providerName: "alpha-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + credentialAvailable: true, + credentialHash, + }, + ], + }; +} + +export function withChannelDisabled( + plan: SandboxMessagingPlan, + channelId: string, +): SandboxMessagingPlan { + return { + ...plan, + channels: plan.channels.map((channel) => + channel.channelId === channelId + ? { ...channel, active: false, selected: false, disabled: true } + : channel, + ), + disabledChannels: [...new Set([...plan.disabledChannels, channelId])], + }; +} + +export function whatsappPlan(): SandboxMessagingPlan { + return { + ...telegramPlan(""), + channels: [ + { + channelId: "whatsapp", + displayName: "WhatsApp", + authMode: "in-sandbox-qr", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + credentialBindings: [], + }; +} + +export function googlechatPlan(): SandboxMessagingPlan { + return { + ...telegramPlan(""), + channels: [ + { + channelId: "googlechat", + displayName: "Google Chat", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + // A bridge channel mints its provider from a profile, so it renders no binding. + credentialBindings: [], + }; +} + +export function slackPlan( + botCredentialHash: string, + appCredentialHash?: string, + agent: SandboxMessagingPlan["agent"] = "openclaw", +): SandboxMessagingPlan { + const appBinding = appCredentialHash + ? [ + { + channelId: "slack", + credentialId: "slackAppToken", + sourceInput: "appToken", + providerName: "alpha-slack-app", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + credentialAvailable: true, + credentialHash: appCredentialHash, + }, + ] + : []; + return { + ...telegramPlan(botCredentialHash), + agent, + channels: [ + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + credentialBindings: [ + { + channelId: "slack", + credentialId: "slackBotToken", + sourceInput: "botToken", + providerName: "alpha-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + credentialAvailable: true, + credentialHash: botCredentialHash, + }, + ...appBinding, + ], + }; +} + +export function completedCheckpointSession( + plan: SandboxMessagingPlan, + stagedCredentialProviders: string[] = [], +) { + const session = createSession(); + session.sandboxName = plan.sandboxName; + session.messagingPlan = plan; + session.stagedCredentialProviders = stagedCredentialProviders; + session.sandboxPromptProgress.sandboxName = true; + session.sandboxPromptProgress.messaging = true; + return session; +} + +export function withMessagingCheckpoint( + session: Session, + selectedChannels: string[], + disabledChannels: string[] = [], +): Session { + const checkpoint: OnboardCheckpoint = { + schemaVersion: CHECKPOINT_SCHEMA_VERSION, + profile: { kind: "selected", value: "default" }, + runtimeAuthority: { kind: "unset" }, + sessionId: session.sessionId, + machineState: session.machine.state, + updatedAt: "2026-01-01T00:00:00.000Z", + sandboxIdentity: decisionUnset(), + webSearch: decisionUnset(), + messaging: decisionSelected({ selectedChannels, disabledChannels }), + resourceProfile: decisionUnset(), + gatewayAuthority: decisionUnset(), + effectGroups: {}, + bindings: { credentialEnvs: [], registeredProviders: [] }, + sandboxRecreate: null, + }; + session.checkpoint = checkpoint; + return session; +} + +export function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { + return { + note: vi.fn(), + showMessagingStage: vi.fn(), + getRecordedMessagingChannelsForResume: vi.fn((): string[] | null => null), + setupMessagingChannels: vi.fn( + async ( + _agent: unknown, + _existingChannels: string[] | null, + _sandboxName: string, + _options?: { readonly selectionCompleted?: boolean }, + ) => ["telegram"], + ), + readMessagingPlanFromEnv: vi + .fn() + .mockReturnValueOnce(plans[0] ?? null) + .mockReturnValue(plans[1] ?? plans[0] ?? null), + writePlanToEnv: vi.fn(), + clearPlanEnv: vi.fn(), + getRegistrySandboxMessagingAuthority: vi.fn((): RegistryMessagingAuthority => ({ + authoritative: false, + plan: null, + })), + inspectGatewayCredential: vi.fn< + (name: string, type: string, credentialEnv: string) => GatewayCredentialOnlyProviderInspection + >(() => ({ kind: "missing" })), + providerMatchesGatewayCredential: vi.fn(() => false), + }; +} + +export function registryDeps(plan: SandboxMessagingPlan) { + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ authoritative: true, plan }); + return deps; +} + +export function recordedResumeDeps(plan: SandboxMessagingPlan) { + const deps = reconcileDeps([plan]); + deps.getRecordedMessagingChannelsForResume.mockReturnValue(["discord", "googlechat"]); + return deps; +} From 742ae13cc746834639d3dfbea7ba083c1cb35b5c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 22:32:42 -0700 Subject: [PATCH 10/17] chore(ci): remove stale live e2e env allowance Signed-off-by: Prekshi Vyas --- ci/env-var-doc-allowlist.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ci/env-var-doc-allowlist.json b/ci/env-var-doc-allowlist.json index b7a4cfc04fa..8f3704cf60e 100644 --- a/ci/env-var-doc-allowlist.json +++ b/ci/env-var-doc-allowlist.json @@ -75,10 +75,6 @@ "name": "NEMOCLAW_E2E_USE_HOSTED_INFERENCE", "reason": "Internal E2E-only sentinel that tells CI to route the repository NVIDIA_INFERENCE_API_KEY secret through the hosted inference-api.nvidia.com OpenAI-compatible endpoint. Not user-facing." }, - { - "name": "NEMOCLAW_RUN_LIVE_E2E", - "reason": "Internal Vitest/live-E2E sentinel that permits the exact sandbox-scoped Google Chat fake-mint provider override used by the destructive channel lifecycle fixture. Production users must not set it." - }, { "name": "NEMOCLAW_COMPAT_MODEL", "reason": "Internal E2E/test override for the model used by OpenAI-compatible endpoint scenarios. User-facing custom endpoint model selection is collected through onboard prompts or NEMOCLAW_MODEL." From b46980ced159611c0e2e1d8da9367bba828f26c4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 23:15:06 -0700 Subject: [PATCH 11/17] fix(messaging): validate lifecycle credential drift Signed-off-by: Prekshi Vyas --- .../handlers/sandbox-messaging.test.ts | 302 ++++++++++++-- .../machine/handlers/sandbox-messaging.ts | 24 +- .../channels-stop-start-googlechat.test.ts | 2 +- .../sandbox-messaging-test-fixtures.ts | 371 ------------------ 4 files changed, 279 insertions(+), 420 deletions(-) delete mode 100644 test/helpers/sandbox-messaging-test-fixtures.ts diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index c3c2c58d017..c2c468b39e2 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -7,31 +7,261 @@ import type { AgentDefinition } from "../../../agent/defs"; import { MessagingSetupApplier } from "../../../messaging/applier/setup-applier"; import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../../../messaging/applier/types"; import { wechatManifest } from "../../../messaging/channels/built-ins"; -import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import type { + MessagingAgentId, + MessagingChannelId, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingPlan, +} from "../../../messaging/manifest"; +import type { RegistryMessagingAuthority } from "../../../messaging/plan-authority"; import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; import { hashCredential } from "../../../security/credential-hash"; +import { decisionSelected } from "../../../state/onboard-checkpoint-decision"; +import { deriveCheckpointFromSession } from "../../../state/onboard-checkpoint-migrate"; +import { createSession, type Session } from "../../../state/onboard-session"; +import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; import { setupMessagingChannels } from "../../messaging-channel-setup"; +import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; import { getActiveChannelsFromPlan } from "../../messaging-plan-session"; import { hasMessagingCredentialDrift, reconcileReusedSandboxMessaging, reconcileSandboxMessaging, } from "./sandbox-messaging"; -import { - channelIdsFrom, - completedCheckpointSession, - discordPlan, - googlechatPlan, - mixedChannelPlan, - reconcileDeps, - recordedResumeDeps, - registryDeps, - slackPlan, - telegramPlan, - whatsappPlan, - withChannelDisabled, - withMessagingCheckpoint, -} from "../../../../../test/helpers/sandbox-messaging-test-fixtures"; + +const mixedChannelIds: MessagingChannelId[] = ["telegram", "unsupported"]; + +function channelIdsFrom(entries: readonly T[]): string[] { + return entries.map(({ channelId }) => channelId); +} + +const credentialSpecs = { + telegram: ["telegram", "botToken", "botToken", "alpha-telegram-bridge", "TELEGRAM_BOT_TOKEN"], + discord: ["discord", "discordBotToken", "botToken", "alpha-discord-bridge", "DISCORD_BOT_TOKEN"], + slackBot: ["slack", "slackBotToken", "botToken", "alpha-slack-bridge", "SLACK_BOT_TOKEN"], + slackApp: ["slack", "slackAppToken", "appToken", "alpha-slack-app", "SLACK_APP_TOKEN"], +} as const; + +function credentialBinding( + kind: keyof typeof credentialSpecs, + credentialHash: string, +): SandboxMessagingCredentialBindingPlan { + const [channelId, credentialId, sourceInput, providerName, providerEnvKey] = + credentialSpecs[kind]; + const placeholderPrefix = kind === "slackBot" ? "xoxb-" : kind === "slackApp" ? "xapp-" : ""; + return { + channelId, + credentialId, + sourceInput, + providerName, + providerEnvKey, + placeholder: placeholderPrefix + ? `${placeholderPrefix}OPENSHELL-RESOLVE-ENV-${providerEnvKey}` + : `openshell:resolve:env:${providerEnvKey}`, + credentialAvailable: true, + credentialHash, + }; +} + +function messagingPlan( + channelId: MessagingChannelId, + credentialBindings: readonly SandboxMessagingCredentialBindingPlan[] = [], + agent: MessagingAgentId = "openclaw", +): SandboxMessagingPlan { + return makeMessagingPlan({ + sandboxName: "alpha", + agent, + channels: [channelId], + credentialBindings, + }); +} + +function telegramPlan(credentialHash: string): SandboxMessagingPlan { + return messagingPlan("telegram", [credentialBinding("telegram", credentialHash)]); +} + +function discordPlan(credentialHash: string, agent: MessagingAgentId = "openclaw") { + return messagingPlan("discord", [credentialBinding("discord", credentialHash)], agent); +} + +function slackPlan( + botCredentialHash: string, + appCredentialHash?: string, + agent: MessagingAgentId = "openclaw", +) { + const bindings = [ + credentialBinding("slackBot", botCredentialHash), + ...(appCredentialHash ? [credentialBinding("slackApp", appCredentialHash)] : []), + ]; + return messagingPlan("slack", bindings, agent); +} + +function googlechatPlan() { + return messagingPlan("googlechat"); +} + +function whatsappPlan() { + return makeMessagingPlan({ + sandboxName: "alpha", + channels: ["whatsapp"], + authMode: "in-sandbox-qr", + }); +} + +function withChannelDisabled(plan: SandboxMessagingPlan, channelId: string) { + return { + ...plan, + channels: plan.channels.map((channel) => + channel.channelId === channelId + ? { ...channel, active: false, selected: false, disabled: true } + : channel, + ), + disabledChannels: [...new Set([...plan.disabledChannels, channelId])], + }; +} + +function mixedChannelPlan(): SandboxMessagingPlan { + const plan = makeMessagingPlan({ + sandboxName: "alpha", + channels: mixedChannelIds, + disabledChannels: ["unsupported"], + }); + return { + ...plan, + credentialBindings: mixedChannelIds.map((channelId) => ({ + channelId, + credentialId: "token", + sourceInput: "token", + providerName: `alpha-${channelId}`, + providerEnvKey: `${channelId.toUpperCase()}_TOKEN`, + placeholder: `openshell:resolve:env:${channelId.toUpperCase()}_TOKEN`, + credentialAvailable: true, + credentialHash: "", + })), + networkPolicy: { + presets: [...mixedChannelIds], + entries: mixedChannelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [`${channelId}_api`], + source: "manifest", + })), + }, + agentRender: mixedChannelIds.map((channelId) => ({ + channelId, + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: `channels.${channelId}`, + value: { enabled: true }, + templateRefs: [], + })), + buildSteps: mixedChannelIds.map((channelId) => ({ + channelId, + kind: "build-arg", + outputId: `${channelId}-arg`, + required: true, + value: "enabled", + })), + runtimeSetup: { + nodePreloads: mixedChannelIds.map((channelId) => ({ + channelId, + module: `${channelId}-preload`, + source: "manifest", + target: "agent", + })), + envAliases: mixedChannelIds.map((channelId) => ({ + channelId, + envKey: `${channelId.toUpperCase()}_TOKEN`, + match: "source", + value: "target", + })), + secretScans: mixedChannelIds.map((channelId) => ({ + channelId, + path: `/sandbox/${channelId}`, + pattern: "secret", + message: "secret found", + })), + }, + stateUpdates: mixedChannelIds.map((channelId) => ({ + channelId, + kind: "persist-inputs", + stateKey: `${channelId}Config`, + inputIds: ["token"], + })), + healthChecks: mixedChannelIds.map((channelId) => ({ + channelId, + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: [`${channelId}-health`], + })), + }; +} + +function completedCheckpointSession( + plan: SandboxMessagingPlan, + stagedCredentialProviders: string[] = [], +) { + const session = createSession({ sandboxName: plan.sandboxName, messagingPlan: plan }); + session.stagedCredentialProviders = stagedCredentialProviders; + session.sandboxPromptProgress.sandboxName = true; + session.sandboxPromptProgress.messaging = true; + return session; +} + +function withMessagingCheckpoint( + session: Session, + selectedChannels: string[], + disabledChannels: string[] = [], +) { + session.checkpoint = { + ...deriveCheckpointFromSession(session), + messaging: decisionSelected({ selectedChannels, disabledChannels }), + }; + return session; +} + +function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { + return { + note: vi.fn(), + showMessagingStage: vi.fn(), + getRecordedMessagingChannelsForResume: vi.fn((): string[] | null => null), + setupMessagingChannels: vi.fn( + async ( + _agent: unknown, + _existingChannels: string[] | null, + _sandboxName: string, + _options?: { readonly selectionCompleted?: boolean }, + ) => ["telegram"], + ), + readMessagingPlanFromEnv: vi + .fn() + .mockReturnValueOnce(plans[0] ?? null) + .mockReturnValue(plans[1] ?? plans[0] ?? null), + writePlanToEnv: vi.fn(), + clearPlanEnv: vi.fn(), + getRegistrySandboxMessagingAuthority: vi.fn<() => RegistryMessagingAuthority>(() => ({ + authoritative: false, + plan: null, + })), + inspectGatewayCredential: vi.fn< + (name: string, type: string, credentialEnv: string) => GatewayCredentialOnlyProviderInspection + >(() => ({ kind: "missing" })), + providerMatchesGatewayCredential: vi.fn(() => false), + }; +} + +function registryDeps(plan: SandboxMessagingPlan) { + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ authoritative: true, plan }); + return deps; +} + +function recordedResumeDeps(plan: SandboxMessagingPlan) { + const deps = reconcileDeps([plan]); + deps.getRecordedMessagingChannelsForResume.mockReturnValue(["discord", "googlechat"]); + return deps; +} describe("hasMessagingCredentialDrift", () => { const oldToken = "123456:old-telegram-token"; @@ -354,30 +584,30 @@ describe("reconcileReusedSandboxMessaging", () => { }); describe("reconcileSandboxMessaging plan authority", () => { - it("refreshes credential hashes from the caller environment instead of ambient process state", async () => { - const callerToken = "caller-telegram-token"; - const ambientToken = "ambient-telegram-token"; + it("validates a changed lifecycle credential before persisting its hash", async () => { + const previousToken = "previous-telegram-token"; const plan = { - ...telegramPlan(hashCredential("previous-telegram-token") ?? ""), + ...telegramPlan(hashCredential(previousToken) ?? ""), workflow: "start-channel" as const, }; const deps = registryDeps(plan); - vi.stubEnv("TELEGRAM_BOT_TOKEN", ambientToken); + deps.setupMessagingChannels.mockRejectedValue(new Error("invalid Telegram token")); + vi.stubEnv("TELEGRAM_BOT_TOKEN", previousToken); - const result = await reconcileSandboxMessaging({ - resume: false, - session: null, - sandboxName: "alpha", - agent: { name: "openclaw" }, - env: { TELEGRAM_BOT_TOKEN: callerToken }, - deps, - }); + await expect( + reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + env: { TELEGRAM_BOT_TOKEN: "invalid-replacement-token" }, + deps, + }), + ).rejects.toThrow("invalid Telegram token"); - expect(result.plan?.credentialBindings[0]?.credentialHash).toBe(hashCredential(callerToken)); - expect(result.plan?.credentialBindings[0]?.credentialHash).not.toBe( - hashCredential(ambientToken), - ); - expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(result.plan); + expect(deps.setupMessagingChannels).toHaveBeenCalledOnce(); + expect(deps.writePlanToEnv).not.toHaveBeenCalled(); + expect(plan.credentialBindings[0]?.credentialHash).toBe(hashCredential(previousToken)); }); it("keeps WeChat selected for start/rebuild when the gateway retains its QR token (#10765)", async () => { @@ -544,7 +774,7 @@ describe("reconcileSandboxMessaging plan authority", () => { ["checkpoint resume", true, "onboard", registryDeps, completedCheckpointSession], ["recorded resume selection", true, "onboard", recordedResumeDeps, () => null], ] as const)( - "does not stage a refreshed %s before every gateway probe resolves (#10660)", + "does not stage a reused %s before every gateway probe resolves (#10660)", async (_, resume, workflow, depsFor, sessionFor) => { const discord = discordPlan(hashCredential("previous-discord-token") ?? ""); const registryPlan: SandboxMessagingPlan = { @@ -554,7 +784,7 @@ describe("reconcileSandboxMessaging plan authority", () => { }; const deps = depsFor(registryPlan); deps.inspectGatewayCredential.mockReturnValue({ kind: "indeterminate" }); - vi.stubEnv("DISCORD_BOT_TOKEN", "replacement-discord-token"); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); await expect( reconcileSandboxMessaging({ diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 185913e56de..8714fe73d4d 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -557,18 +557,6 @@ async function selectionFromRegistryPlan( registryPlan: SandboxMessagingPlan, options: ReconcileSandboxMessagingOptions, ): Promise { - if (registryPlanRecordsLifecycleSelection(registryPlan)) { - // A lifecycle command owns which channels the operator asked for, but not - // whether the host still configures them. Onboarding re-reads the host - // either way, so the same removal check applies here. - return selectionFromReconciledReusablePlan( - registryPlan, - options.agent, - true, - options.env as NodeJS.ProcessEnv, - options.deps, - ); - } const activeChannels = filterChannelNamesForCurrentAgent( getActiveChannelsFromPlan(registryPlan), options.agent, @@ -589,6 +577,18 @@ async function selectionFromRegistryPlan( registryPlan, ); } + if (registryPlanRecordsLifecycleSelection(registryPlan)) { + // A lifecycle command owns which channels the operator asked for, but not + // whether the host still configures them. Onboarding re-reads the host + // either way, so the same removal check applies here. + return selectionFromReconciledReusablePlan( + registryPlan, + options.agent, + true, + options.env as NodeJS.ProcessEnv, + options.deps, + ); + } const detectedChannels = channelsForRegistryPlanRefresh(registryPlan, options.agent); if (!detectedChannels) { return selectionFromReconciledReusablePlan( diff --git a/test/e2e/support/channels-stop-start-googlechat.test.ts b/test/e2e/support/channels-stop-start-googlechat.test.ts index c2a144342cf..30e36f3a1b5 100644 --- a/test/e2e/support/channels-stop-start-googlechat.test.ts +++ b/test/e2e/support/channels-stop-start-googlechat.test.ts @@ -315,7 +315,7 @@ describe("channels stop/start Google Chat live composition", () => { ["openclaw", "e2e-oc-ch-cycle", "google-chat-bridge"], ["hermes", "e2e-hm-ch-cycle", "google-chat-hermes-bridge"], ] as const)( - "creates the real %s provider profile without putting the fixture value in argv", + "passes the %s Google Chat credential through the environment without adding it to argv", (agent, sandboxName, providerType) => { const delegatedName = `${sandboxName}-slack-bridge`; const delegatedTokenDef = { diff --git a/test/helpers/sandbox-messaging-test-fixtures.ts b/test/helpers/sandbox-messaging-test-fixtures.ts deleted file mode 100644 index 1052344dd01..00000000000 --- a/test/helpers/sandbox-messaging-test-fixtures.ts +++ /dev/null @@ -1,371 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { vi } from "vitest"; - -import type { SandboxMessagingPlan } from "../../src/lib/messaging/manifest"; -import type { RegistryMessagingAuthority } from "../../src/lib/messaging/plan-authority"; -import { decisionSelected, decisionUnset } from "../../src/lib/state/onboard-checkpoint-decision"; -import { - CHECKPOINT_SCHEMA_VERSION, - type OnboardCheckpoint, -} from "../../src/lib/state/onboard-checkpoint-types"; -import { createSession, type Session } from "../../src/lib/state/onboard-session"; -import type { GatewayCredentialOnlyProviderInspection } from "../../src/lib/onboard/gateway-provider-metadata"; - -const channelIds = ["telegram", "unsupported"]; - -export function mixedChannelPlan(): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - channels: channelIds.map((channelId) => ({ - channelId, - displayName: channelId, - authMode: "token-paste", - active: channelId === "telegram", - selected: true, - configured: true, - disabled: channelId !== "telegram", - inputs: [], - hooks: [], - })), - disabledChannels: ["unsupported"], - credentialBindings: channelIds.map((channelId) => ({ - channelId, - credentialId: "token", - sourceInput: "token", - providerName: `alpha-${channelId}`, - providerEnvKey: `${channelId.toUpperCase()}_TOKEN`, - placeholder: `openshell:resolve:env:${channelId.toUpperCase()}_TOKEN`, - credentialAvailable: true, - })), - networkPolicy: { - presets: [...channelIds], - entries: channelIds.map((channelId) => ({ - channelId, - presetName: channelId, - policyKeys: [`${channelId}_api`], - source: "manifest", - })), - }, - agentRender: channelIds.map((channelId) => ({ - channelId, - kind: "json-fragment", - agent: "openclaw", - target: "openclaw.json", - path: `channels.${channelId}`, - value: { enabled: true }, - templateRefs: [], - })), - buildSteps: channelIds.map((channelId) => ({ - channelId, - kind: "build-arg", - outputId: `${channelId}-arg`, - required: true, - value: "enabled", - })), - runtimeSetup: { - nodePreloads: channelIds.map((channelId) => ({ - channelId, - module: `${channelId}-preload`, - source: "manifest", - target: "agent", - })), - envAliases: channelIds.map((channelId) => ({ - channelId, - envKey: `${channelId.toUpperCase()}_TOKEN`, - match: "source", - value: "target", - })), - secretScans: channelIds.map((channelId) => ({ - channelId, - path: `/sandbox/${channelId}`, - pattern: "secret", - message: "secret found", - })), - }, - stateUpdates: channelIds.map((channelId) => ({ - channelId, - kind: "persist-inputs", - stateKey: `${channelId}Config`, - inputIds: ["token"], - })), - healthChecks: channelIds.map((channelId) => ({ - channelId, - phase: "health-check", - requiredBefore: "lifecycle-success", - hookIds: [`${channelId}-health`], - })), - }; -} - -export function channelIdsFrom( - entries: readonly T[], -): string[] { - return entries.map((entry) => entry.channelId); -} - -export function telegramPlan(credentialHash: string): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName: "alpha", - agent: "openclaw", - workflow: "onboard", - channels: [ - { - channelId: "telegram", - displayName: "Telegram", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - disabledChannels: [], - credentialBindings: [ - { - channelId: "telegram", - credentialId: "botToken", - sourceInput: "botToken", - providerName: "alpha-telegram-bridge", - providerEnvKey: "TELEGRAM_BOT_TOKEN", - placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - credentialAvailable: true, - credentialHash, - }, - ], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - -export function discordPlan( - credentialHash: string, - agent: SandboxMessagingPlan["agent"] = "openclaw", -): SandboxMessagingPlan { - return { - ...telegramPlan(credentialHash), - agent, - channels: [ - { - channelId: "discord", - displayName: "Discord", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [ - { - channelId: "discord", - credentialId: "discordBotToken", - sourceInput: "botToken", - providerName: "alpha-discord-bridge", - providerEnvKey: "DISCORD_BOT_TOKEN", - placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", - credentialAvailable: true, - credentialHash, - }, - ], - }; -} - -export function withChannelDisabled( - plan: SandboxMessagingPlan, - channelId: string, -): SandboxMessagingPlan { - return { - ...plan, - channels: plan.channels.map((channel) => - channel.channelId === channelId - ? { ...channel, active: false, selected: false, disabled: true } - : channel, - ), - disabledChannels: [...new Set([...plan.disabledChannels, channelId])], - }; -} - -export function whatsappPlan(): SandboxMessagingPlan { - return { - ...telegramPlan(""), - channels: [ - { - channelId: "whatsapp", - displayName: "WhatsApp", - authMode: "in-sandbox-qr", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [], - }; -} - -export function googlechatPlan(): SandboxMessagingPlan { - return { - ...telegramPlan(""), - channels: [ - { - channelId: "googlechat", - displayName: "Google Chat", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - // A bridge channel mints its provider from a profile, so it renders no binding. - credentialBindings: [], - }; -} - -export function slackPlan( - botCredentialHash: string, - appCredentialHash?: string, - agent: SandboxMessagingPlan["agent"] = "openclaw", -): SandboxMessagingPlan { - const appBinding = appCredentialHash - ? [ - { - channelId: "slack", - credentialId: "slackAppToken", - sourceInput: "appToken", - providerName: "alpha-slack-app", - providerEnvKey: "SLACK_APP_TOKEN", - placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", - credentialAvailable: true, - credentialHash: appCredentialHash, - }, - ] - : []; - return { - ...telegramPlan(botCredentialHash), - agent, - channels: [ - { - channelId: "slack", - displayName: "Slack", - authMode: "token-paste", - active: true, - selected: true, - configured: true, - disabled: false, - inputs: [], - hooks: [], - }, - ], - credentialBindings: [ - { - channelId: "slack", - credentialId: "slackBotToken", - sourceInput: "botToken", - providerName: "alpha-slack-bridge", - providerEnvKey: "SLACK_BOT_TOKEN", - placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - credentialAvailable: true, - credentialHash: botCredentialHash, - }, - ...appBinding, - ], - }; -} - -export function completedCheckpointSession( - plan: SandboxMessagingPlan, - stagedCredentialProviders: string[] = [], -) { - const session = createSession(); - session.sandboxName = plan.sandboxName; - session.messagingPlan = plan; - session.stagedCredentialProviders = stagedCredentialProviders; - session.sandboxPromptProgress.sandboxName = true; - session.sandboxPromptProgress.messaging = true; - return session; -} - -export function withMessagingCheckpoint( - session: Session, - selectedChannels: string[], - disabledChannels: string[] = [], -): Session { - const checkpoint: OnboardCheckpoint = { - schemaVersion: CHECKPOINT_SCHEMA_VERSION, - profile: { kind: "selected", value: "default" }, - runtimeAuthority: { kind: "unset" }, - sessionId: session.sessionId, - machineState: session.machine.state, - updatedAt: "2026-01-01T00:00:00.000Z", - sandboxIdentity: decisionUnset(), - webSearch: decisionUnset(), - messaging: decisionSelected({ selectedChannels, disabledChannels }), - resourceProfile: decisionUnset(), - gatewayAuthority: decisionUnset(), - effectGroups: {}, - bindings: { credentialEnvs: [], registeredProviders: [] }, - sandboxRecreate: null, - }; - session.checkpoint = checkpoint; - return session; -} - -export function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { - return { - note: vi.fn(), - showMessagingStage: vi.fn(), - getRecordedMessagingChannelsForResume: vi.fn((): string[] | null => null), - setupMessagingChannels: vi.fn( - async ( - _agent: unknown, - _existingChannels: string[] | null, - _sandboxName: string, - _options?: { readonly selectionCompleted?: boolean }, - ) => ["telegram"], - ), - readMessagingPlanFromEnv: vi - .fn() - .mockReturnValueOnce(plans[0] ?? null) - .mockReturnValue(plans[1] ?? plans[0] ?? null), - writePlanToEnv: vi.fn(), - clearPlanEnv: vi.fn(), - getRegistrySandboxMessagingAuthority: vi.fn((): RegistryMessagingAuthority => ({ - authoritative: false, - plan: null, - })), - inspectGatewayCredential: vi.fn< - (name: string, type: string, credentialEnv: string) => GatewayCredentialOnlyProviderInspection - >(() => ({ kind: "missing" })), - providerMatchesGatewayCredential: vi.fn(() => false), - }; -} - -export function registryDeps(plan: SandboxMessagingPlan) { - const deps = reconcileDeps([]); - deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ authoritative: true, plan }); - return deps; -} - -export function recordedResumeDeps(plan: SandboxMessagingPlan) { - const deps = reconcileDeps([plan]); - deps.getRecordedMessagingChannelsForResume.mockReturnValue(["discord", "googlechat"]); - return deps; -} From 16ca4af27dcfff386a6066e15f05f91fbf398d64 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 23:54:56 -0700 Subject: [PATCH 12/17] test(messaging): scope Google Chat credential fixture Signed-off-by: Prekshi Vyas --- .../credential-provider-registration.ts | 15 +- test/e2e/live/channels-stop-start-helpers.ts | 177 +++--------- .../channels-stop-start-googlechat.test.ts | 266 +++++------------- 3 files changed, 113 insertions(+), 345 deletions(-) diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 712328aaf3c..7cc88553dc9 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -12,17 +12,6 @@ import { createGatewayScopedOpenshellRunner } from "./setup-inference"; const providers = require("./providers"); -/** Late-bound provider upsert seam used by live credential fixtures. */ -export const credentialProviderRegistrationDependencies = { - upsertMessagingProviders( - tokenDefs: MessagingTokenDef[], - runOpenshell: OpenshellCliHelpers["runOpenshell"], - options: MessagingProviderRegistrationOptions, - ): string[] { - return providers.upsertMessagingProviders(tokenDefs, runOpenshell, options) as string[]; - }, -}; - export interface StageSandboxCredentialProvidersInput { sandboxName: string; enabledChannels: readonly string[]; @@ -191,11 +180,11 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg options: MessagingProviderRegistrationOptions = {}, runOpenshell: OpenshellCliHelpers["runOpenshell"] = deps.runOpenshell, ): string[] { - const upserted = credentialProviderRegistrationDependencies.upsertMessagingProviders( + const upserted = providers.upsertMessagingProviders( tokenDefs, runOpenshell, options, - ); + ) as string[]; recordMigratedLegacyMessagingCredentials( tokenDefs, upserted, diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts index 1bf743dec77..eaa9ffe382e 100644 --- a/test/e2e/live/channels-stop-start-helpers.ts +++ b/test/e2e/live/channels-stop-start-helpers.ts @@ -9,9 +9,7 @@ import type { AddSandboxChannelDependencies } from "../../../src/lib/actions/san import * as policyChannelDependenciesModule from "../../../src/lib/actions/sandbox/policy-channel-dependencies.ts"; import * as policyChannelModule from "../../../src/lib/actions/sandbox/policy-channel.ts"; import * as openshellRuntimeModule from "../../../src/lib/adapters/openshell/runtime.ts"; -import * as credentialProviderRegistrationModule from "../../../src/lib/onboard/credential-provider-registration.ts"; import * as messagingBridgeProviderModule from "../../../src/lib/onboard/messaging-bridge-provider.ts"; -import * as legacyProvidersModule from "../../../src/lib/onboard/providers.ts"; import { clearStoppedDockerSandboxChannelState } from "../../../src/lib/sandbox/privileged-exec.ts"; import * as statePathsModule from "../../../src/lib/state/paths.ts"; import { @@ -57,22 +55,9 @@ type PolicyChannelModule = typeof import("../../../src/lib/actions/sandbox/polic type PolicyChannelDependenciesModule = typeof import("../../../src/lib/actions/sandbox/policy-channel-dependencies.ts"); type OpenshellRuntimeModule = typeof import("../../../src/lib/adapters/openshell/runtime.ts"); -type CredentialProviderRegistrationModule = - typeof import("../../../src/lib/onboard/credential-provider-registration.ts"); type MessagingBridgeProviderModule = typeof import("../../../src/lib/onboard/messaging-bridge-provider.ts"); type StatePathsModule = typeof import("../../../src/lib/state/paths.ts"); -type ProviderUpsertOptions = { - readonly replaceExisting?: boolean; - readonly revalidateSandboxIdentity?: (operation: string) => void; -}; -type ProviderDependencies = { - upsertMessagingProviders( - tokenDefs: Parameters[0], - run: typeof runOpenshell, - options?: ProviderUpsertOptions, - ): string[]; -}; const policyChannel = ( "default" in policyChannelModule ? policyChannelModule.default : policyChannelModule @@ -94,16 +79,6 @@ const messagingBridgeProvider = ( : messagingBridgeProviderModule ) as MessagingBridgeProviderModule; const { ensureMessagingBridgeProfiles } = messagingBridgeProvider; -const credentialProviderRegistration = ( - "default" in credentialProviderRegistrationModule - ? credentialProviderRegistrationModule.default - : credentialProviderRegistrationModule -) as CredentialProviderRegistrationModule; -const credentialProviderRegistrationDependencies = - credentialProviderRegistration.credentialProviderRegistrationDependencies as ProviderDependencies; -const legacyProviderDependencies = ( - "default" in legacyProvidersModule ? legacyProvidersModule.default : legacyProvidersModule -) as ProviderDependencies; const statePaths = ( "default" in statePathsModule ? statePathsModule.default : statePathsModule ) as StatePathsModule; @@ -121,7 +96,12 @@ interface GooglechatLiveE2eDependencies { options: { readonly channel: string }, dependencies: AddSandboxChannelDependencies, ) => Promise; - readonly installCredentialFixture: (sandboxName: string, agent: AgentKind) => () => void; + readonly createCredentialFixture: ( + sandboxName: string, + agent: AgentKind, + ) => { + readonly upsertMessagingProviders?: AddSandboxChannelDependencies["upsertMessagingProviders"]; + }; readonly rebuildSandbox?: (sandboxName: string, args: string[]) => Promise; } @@ -131,13 +111,10 @@ interface GooglechatCredentialFixtureDependencies { "runGatewayOpenshell" | "upsertMessagingProviders" >; readonly ensureProfiles?: typeof ensureMessagingBridgeProfiles; - readonly providerDependencies?: ProviderDependencies; - readonly legacyProviderDependencies?: ProviderDependencies; readonly root?: string; - readonly run?: typeof runOpenshell; } -type InstalledGooglechatCredentialFixture = (() => void) & { +type GooglechatCredentialFixture = { readonly upsertMessagingProviders: NonNullable< AddSandboxChannelDependencies["upsertMessagingProviders"] >; @@ -159,18 +136,18 @@ const PROVIDER_TYPE_BY_AGENT: Readonly< * injection, bound provider egress, and removal without requiring a Google * service account in CI. */ -export function installGooglechatCredentialFixture( +export function createGooglechatCredentialFixture( sandboxName: string, agent: AgentKind, dependencies: GooglechatCredentialFixtureDependencies = {}, -): InstalledGooglechatCredentialFixture { +): GooglechatCredentialFixture { assertChannelsStopStartSandboxName(sandboxName, agent); const ensureProfiles = dependencies.ensureProfiles ?? ensureMessagingBridgeProfiles; const channelDependencies = dependencies.channelDependencies ?? policyChannelDependencies; const originalChannelUpsert = channelDependencies.upsertMessagingProviders; const expectedName = `${sandboxName}-googlechat-bridge`; const expectedType = PROVIDER_TYPE_BY_AGENT[agent]; - const directUpsert: InstalledGooglechatCredentialFixture["upsertMessagingProviders"] = ( + const upsertMessagingProviders: GooglechatCredentialFixture["upsertMessagingProviders"] = ( tokenDefs, gatewayName, options = {}, @@ -234,100 +211,12 @@ export function installGooglechatCredentialFixture( const registered = new Set([...delegatedProviderNames, expectedName]); return tokenDefs.map(({ name }) => name).filter((name) => registered.has(name)); }; - const providerDependencies = - dependencies.providerDependencies ?? credentialProviderRegistrationDependencies; - const effectiveLegacyProviderDependencies = - dependencies.legacyProviderDependencies ?? - (dependencies.providerDependencies ? providerDependencies : legacyProviderDependencies); - const root = dependencies.root ?? ROOT; - const run = dependencies.run ?? runOpenshell; - const originalRegistrationUpsert = providerDependencies.upsertMessagingProviders; - const originalLegacyUpsert = effectiveLegacyProviderDependencies.upsertMessagingProviders; - - const fixtureUpsert: ProviderDependencies["upsertMessagingProviders"] = ( - tokenDefs, - providerRun, - options = {}, - ) => { - const fixtureTokenDefs = tokenDefs.filter(({ name }) => name === expectedName); - const fixtureTokenDef = fixtureTokenDefs[0]; - if ( - fixtureTokenDefs.length !== 1 || - fixtureTokenDef?.envKey !== "GOOGLE_CHAT_ACCESS_TOKEN" || - fixtureTokenDef?.providerType !== expectedType - ) { - throw new Error("Google Chat live fixture received an unexpected provider definition"); - } - - const delegatedTokenDefs = tokenDefs.filter(({ name }) => name !== expectedName); - const delegatedProviderNames = - delegatedTokenDefs.length === 0 - ? [] - : originalLegacyUpsert(delegatedTokenDefs, providerRun, options); - const baseRun = providerRun ?? run; - const revalidate = () => - options.revalidateSandboxIdentity?.( - `manage Google Chat live fixture provider '${expectedName}'`, - ); - const effectiveRun: typeof runOpenshell = (args, runOptions) => { - revalidate(); - return baseRun(args, runOptions); - }; - ensureProfiles(fixtureTokenDefs, { - root, - runOpenshell: effectiveRun, - redact: (value) => value.replaceAll(GOOGLECHAT_E2E_ACCESS_TOKEN, "[redacted]"), - }); - const existing = effectiveRun(["provider", "get", expectedName], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (existing.status === 0 && options.replaceExisting) { - const removed = effectiveRun(["provider", "delete", expectedName], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (removed.status !== 0) { - throw new Error(`Google Chat live fixture could not replace provider '${expectedName}'`); - } - } - const action = existing.status === 0 && !options.replaceExisting ? "update" : "create"; - const providerArgs = - action === "update" - ? ["provider", "update", expectedName, "--credential", "GOOGLE_CHAT_ACCESS_TOKEN"] - : [ - "provider", - "create", - "--name", - expectedName, - "--type", - expectedType, - "--credential", - "GOOGLE_CHAT_ACCESS_TOKEN", - ]; - const mutated = effectiveRun(providerArgs, { - env: { GOOGLE_CHAT_ACCESS_TOKEN: GOOGLECHAT_E2E_ACCESS_TOKEN }, - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (mutated.status !== 0) { - throw new Error(`Google Chat live fixture could not ${action} provider '${expectedName}'`); - } - const registered = new Set([...delegatedProviderNames, expectedName]); - return tokenDefs.map(({ name }) => name).filter((name) => registered.has(name)); - }; - providerDependencies.upsertMessagingProviders = fixtureUpsert; - effectiveLegacyProviderDependencies.upsertMessagingProviders = fixtureUpsert; - const restore = () => { - providerDependencies.upsertMessagingProviders = originalRegistrationUpsert; - effectiveLegacyProviderDependencies.upsertMessagingProviders = originalLegacyUpsert; - }; - return Object.assign(restore, { upsertMessagingProviders: directUpsert }); + return { upsertMessagingProviders }; } const DEFAULT_GOOGLECHAT_DEPENDENCIES: GooglechatLiveE2eDependencies = { addSandboxChannel, - installCredentialFixture: installGooglechatCredentialFixture, + createCredentialFixture: createGooglechatCredentialFixture, rebuildSandbox: (sandboxName, args) => policyChannelDependencies.rebuildSandbox(sandboxName, args), }; @@ -341,11 +230,11 @@ function requireLiveAudience(input: GooglechatLiveE2eComposition): string { return audience; } -async function addGooglechatWithInstalledFixture( +async function addGooglechatWithCredentialFixture( input: GooglechatLiveE2eComposition, audience: string, dependencies: GooglechatLiveE2eDependencies, - fixture: (() => void) & { + fixture: { readonly upsertMessagingProviders?: AddSandboxChannelDependencies["upsertMessagingProviders"]; }, ): Promise { @@ -364,7 +253,21 @@ async function addGooglechatWithInstalledFixture( ); } -/** Keep the fake OAuth mint installed across both provider registrations. */ +async function withoutGooglechatSourceCredential(operation: () => Promise): Promise { + const sourceCredential = process.env.GOOGLECHAT_SERVICE_ACCOUNT; + delete process.env.GOOGLECHAT_SERVICE_ACCOUNT; + try { + return await operation(); + } finally { + delete process.env.GOOGLECHAT_SERVICE_ACCOUNT; + Object.assign( + process.env, + sourceCredential === undefined ? {} : { GOOGLECHAT_SERVICE_ACCOUNT: sourceCredential }, + ); + } +} + +/** Create the bridge once, then rebuild through gateway-backed credential reuse. */ export async function addAndRebuildGooglechatForChannelsStopStartLiveE2e( input: GooglechatLiveE2eComposition, dependencies: GooglechatLiveE2eDependencies = DEFAULT_GOOGLECHAT_DEPENDENCIES, @@ -373,31 +276,25 @@ export async function addAndRebuildGooglechatForChannelsStopStartLiveE2e( if (!dependencies.rebuildSandbox) { throw new Error("Google Chat live rebuild dependency is unavailable"); } + const rebuildSandbox = dependencies.rebuildSandbox; - const restore = dependencies.installCredentialFixture(input.sandboxName, input.agent); - try { - await addGooglechatWithInstalledFixture(input, audience, dependencies, restore); - await dependencies.rebuildSandbox(input.sandboxName, ["--yes"]); - } finally { - restore(); - } + const fixture = dependencies.createCredentialFixture(input.sandboxName, input.agent); + await addGooglechatWithCredentialFixture(input, audience, dependencies, fixture); + await withoutGooglechatSourceCredential(() => rebuildSandbox(input.sandboxName, ["--yes"])); } -/** Keep the fake OAuth mint installed while a later lifecycle rebuild reconciles Google Chat. */ +/** Rebuild with no host source secret so gateway-backed credential reuse is exercised. */ export async function rebuildGooglechatForChannelsStopStartLiveE2e( input: Pick, dependencies: GooglechatLiveE2eDependencies = DEFAULT_GOOGLECHAT_DEPENDENCIES, ): Promise { + assertChannelsStopStartSandboxName(input.sandboxName, input.agent); if (!dependencies.rebuildSandbox) { throw new Error("Google Chat live rebuild dependency is unavailable"); } + const rebuildSandbox = dependencies.rebuildSandbox; - const restore = dependencies.installCredentialFixture(input.sandboxName, input.agent); - try { - await dependencies.rebuildSandbox(input.sandboxName, ["--yes"]); - } finally { - restore(); - } + await withoutGooglechatSourceCredential(() => rebuildSandbox(input.sandboxName, ["--yes"])); } async function withLiveE2eEnvironment( diff --git a/test/e2e/support/channels-stop-start-googlechat.test.ts b/test/e2e/support/channels-stop-start-googlechat.test.ts index 30e36f3a1b5..674116f43aa 100644 --- a/test/e2e/support/channels-stop-start-googlechat.test.ts +++ b/test/e2e/support/channels-stop-start-googlechat.test.ts @@ -3,28 +3,14 @@ import { describe, expect, it, vi } from "vitest"; -import { credentialProviderRegistrationDependencies } from "../../../src/lib/onboard/credential-provider-registration.ts"; import { addAndRebuildGooglechatForChannelsStopStartLiveE2e, + createGooglechatCredentialFixture, GOOGLECHAT_E2E_ACCESS_TOKEN, - installGooglechatCredentialFixture, rebuildGooglechatForChannelsStopStartLiveE2e, } from "../live/channels-stop-start-helpers.ts"; type FixtureRunner = typeof import("../../../src/lib/adapters/openshell/runtime.ts").runOpenshell; -type FixtureProviderDependencies = { - upsertMessagingProviders( - tokenDefs: Parameters< - (typeof import("../../../src/lib/actions/sandbox/policy-channel-dependencies.ts"))["policyChannelDependencies"]["upsertMessagingProviders"] - >[0], - run: FixtureRunner, - options?: { - readonly replaceExisting?: boolean; - readonly revalidateSandboxIdentity?: (operation: string) => void; - }, - ): string[]; -}; - type FixtureChannelDependencies = Pick< (typeof import("../../../src/lib/actions/sandbox/policy-channel-dependencies.ts"))["policyChannelDependencies"], "runGatewayOpenshell" | "upsertMessagingProviders" @@ -43,15 +29,14 @@ describe("channels stop/start Google Chat live composition", () => { return { status: args[1] === "get" ? 1 : 0 } as never; }), }; - const restore = installGooglechatCredentialFixture(sandboxName, "openclaw", { + const fixture = createGooglechatCredentialFixture(sandboxName, "openclaw", { channelDependencies, ensureProfiles: vi.fn(), - providerDependencies: { upsertMessagingProviders: vi.fn(() => []) }, root: "/repo", }); expect( - restore.upsertMessagingProviders( + fixture.upsertMessagingProviders( [ { name: expectedName, @@ -76,59 +61,12 @@ describe("channels stop/start Google Chat live composition", () => { "--credential", "GOOGLE_CHAT_ACCESS_TOKEN", ]); - - restore(); - expect(channelDependencies.upsertMessagingProviders).toBe(originalUpsert); - }); - - it("temporarily routes rebuild registration through the live fixture", () => { - const sandboxName = "e2e-oc-ch-cycle"; - const expectedName = `${sandboxName}-googlechat-bridge`; - const runMock = vi.fn((args: string[]) => ({ - status: args[1] === "get" ? 1 : 0, - stdout: "", - stderr: "", - })); - const run = runMock as unknown as FixtureRunner; - const originalUpsert = credentialProviderRegistrationDependencies.upsertMessagingProviders; - const restore = installGooglechatCredentialFixture(sandboxName, "openclaw", { - ensureProfiles: vi.fn(), - root: "/repo", - run, - }); - try { - expect(credentialProviderRegistrationDependencies.upsertMessagingProviders).not.toBe( - originalUpsert, - ); - expect( - credentialProviderRegistrationDependencies.upsertMessagingProviders( - [ - { - name: expectedName, - envKey: "GOOGLE_CHAT_ACCESS_TOKEN", - token: null, - providerType: "google-chat-bridge", - }, - ], - run, - {}, - ), - ).toEqual([expectedName]); - expect(runMock.mock.calls.some(([args]) => args[1] === "create")).toBe(true); - expect(runMock.mock.calls.some(([args]) => args.includes("refresh"))).toBe(false); - } finally { - restore(); - } - expect(credentialProviderRegistrationDependencies.upsertMessagingProviders).toBe( - originalUpsert, - ); }); it("grants a process-local audience capability to the exact live sandbox", async () => { const addSandboxChannel = vi.fn(async () => {}); const rebuildSandbox = vi.fn(async () => {}); - const restore = vi.fn(); - const installCredentialFixture = vi.fn(() => restore); + const createCredentialFixture = vi.fn(() => ({})); await addAndRebuildGooglechatForChannelsStopStartLiveE2e( { @@ -136,10 +74,10 @@ describe("channels stop/start Google Chat live composition", () => { agent: "openclaw", audience: " https://e2e-fake.trycloudflare.com/googlechat ", }, - { addSandboxChannel, installCredentialFixture, rebuildSandbox }, + { addSandboxChannel, createCredentialFixture, rebuildSandbox }, ); - expect(installCredentialFixture).toHaveBeenCalledWith("e2e-oc-ch-cycle", "openclaw"); + expect(createCredentialFixture).toHaveBeenCalledWith("e2e-oc-ch-cycle", "openclaw"); expect(addSandboxChannel).toHaveBeenCalledWith( "e2e-oc-ch-cycle", { channel: "googlechat" }, @@ -150,14 +88,12 @@ describe("channels stop/start Google Chat live composition", () => { }, ); expect(rebuildSandbox).toHaveBeenCalledWith("e2e-oc-ch-cycle", ["--yes"]); - expect(restore).toHaveBeenCalledOnce(); }); it("adds Hermes Google Chat without the OpenClaw audience capability", async () => { const addSandboxChannel = vi.fn(async () => {}); const rebuildSandbox = vi.fn(async () => {}); - const restore = vi.fn(); - const installCredentialFixture = vi.fn(() => restore); + const createCredentialFixture = vi.fn(() => ({})); await addAndRebuildGooglechatForChannelsStopStartLiveE2e( { @@ -165,22 +101,21 @@ describe("channels stop/start Google Chat live composition", () => { agent: "hermes", audience: "https://e2e-fake.trycloudflare.com/googlechat", }, - { addSandboxChannel, installCredentialFixture, rebuildSandbox }, + { addSandboxChannel, createCredentialFixture, rebuildSandbox }, ); - expect(installCredentialFixture).toHaveBeenCalledWith("e2e-hm-ch-cycle", "hermes"); + expect(createCredentialFixture).toHaveBeenCalledWith("e2e-hm-ch-cycle", "hermes"); expect(addSandboxChannel).toHaveBeenCalledWith( "e2e-hm-ch-cycle", { channel: "googlechat" }, {}, ); expect(rebuildSandbox).toHaveBeenCalledWith("e2e-hm-ch-cycle", ["--yes"]); - expect(restore).toHaveBeenCalledOnce(); }); it("refuses to grant the capability outside the destructive live-test sandbox namespace", async () => { const addSandboxChannel = vi.fn(async () => {}); - const installCredentialFixture = vi.fn(() => vi.fn()); + const createCredentialFixture = vi.fn(() => ({})); await expect( addAndRebuildGooglechatForChannelsStopStartLiveE2e( @@ -189,16 +124,16 @@ describe("channels stop/start Google Chat live composition", () => { agent: "openclaw", audience: "https://example.com/googlechat", }, - { addSandboxChannel, installCredentialFixture }, + { addSandboxChannel, createCredentialFixture }, ), ).rejects.toThrow(/only accepts openclaw sandbox names with prefix e2e-oc-ch-/); expect(addSandboxChannel).not.toHaveBeenCalled(); - expect(installCredentialFixture).not.toHaveBeenCalled(); + expect(createCredentialFixture).not.toHaveBeenCalled(); }); it("refuses an empty live-test audience", async () => { const addSandboxChannel = vi.fn(async () => {}); - const installCredentialFixture = vi.fn(() => vi.fn()); + const createCredentialFixture = vi.fn(() => ({})); await expect( addAndRebuildGooglechatForChannelsStopStartLiveE2e( @@ -207,18 +142,18 @@ describe("channels stop/start Google Chat live composition", () => { agent: "openclaw", audience: " ", }, - { addSandboxChannel, installCredentialFixture }, + { addSandboxChannel, createCredentialFixture }, ), ).rejects.toThrow(/GOOGLECHAT_AUDIENCE is required/); expect(addSandboxChannel).not.toHaveBeenCalled(); - expect(installCredentialFixture).not.toHaveBeenCalled(); + expect(createCredentialFixture).not.toHaveBeenCalled(); }); - it("restores the provider boundary when channel add fails", async () => { + it("does not rebuild when channel add fails", async () => { const addSandboxChannel = vi.fn(async () => { throw new Error("planned add failed"); }); - const restore = vi.fn(); + const rebuildSandbox = vi.fn(async () => {}); await expect( addAndRebuildGooglechatForChannelsStopStartLiveE2e( @@ -229,17 +164,17 @@ describe("channels stop/start Google Chat live composition", () => { }, { addSandboxChannel, - installCredentialFixture: () => restore, - rebuildSandbox: async () => {}, + createCredentialFixture: () => ({}), + rebuildSandbox, }, ), ).rejects.toThrow("planned add failed"); - expect(restore).toHaveBeenCalledOnce(); + expect(rebuildSandbox).not.toHaveBeenCalled(); }); - it("keeps the provider fixture installed across add and rebuild", async () => { + it("limits the provider fixture to channel add and hides the source credential from rebuild", async () => { + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", "fake-service-account"); const events: string[] = []; - const restore = vi.fn(() => events.push("restore")); await addAndRebuildGooglechatForChannelsStopStartLiveE2e( { @@ -248,26 +183,29 @@ describe("channels stop/start Google Chat live composition", () => { audience: "https://e2e-fake.trycloudflare.com/googlechat", }, { - installCredentialFixture: () => { - events.push("install"); - return restore; + createCredentialFixture: () => { + events.push("create-fixture"); + return {}; }, addSandboxChannel: async () => { + expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBe("fake-service-account"); events.push("add"); }, rebuildSandbox: async (_sandboxName, args) => { expect(args).toEqual(["--yes"]); + expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBeUndefined(); events.push("rebuild"); }, }, ); - expect(events).toEqual(["install", "add", "rebuild", "restore"]); - expect(restore).toHaveBeenCalledOnce(); + expect(events).toEqual(["create-fixture", "add", "rebuild"]); + expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBe("fake-service-account"); + vi.unstubAllEnvs(); }); - it("restores the provider fixture when rebuild fails", async () => { - const restore = vi.fn(); + it("restores the source credential when rebuild fails", async () => { + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", "fake-service-account"); await expect( addAndRebuildGooglechatForChannelsStopStartLiveE2e( @@ -277,38 +215,41 @@ describe("channels stop/start Google Chat live composition", () => { audience: "https://e2e-fake.trycloudflare.com/googlechat", }, { - installCredentialFixture: () => restore, + createCredentialFixture: () => ({}), addSandboxChannel: async () => {}, rebuildSandbox: async () => { + expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBeUndefined(); throw new Error("planned rebuild failed"); }, }, ), ).rejects.toThrow("planned rebuild failed"); - expect(restore).toHaveBeenCalledOnce(); + expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBe("fake-service-account"); + vi.unstubAllEnvs(); }); - it("keeps the provider fixture installed across a later lifecycle rebuild", async () => { + it("reuses the gateway credential without creating a fixture for a later rebuild", async () => { + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", "fake-service-account"); const events: string[] = []; - const restore = vi.fn(() => events.push("restore")); + const createCredentialFixture = vi.fn(() => ({})); await rebuildGooglechatForChannelsStopStartLiveE2e( { sandboxName: "e2e-oc-ch-cycle", agent: "openclaw" }, { - installCredentialFixture: () => { - events.push("install"); - return restore; - }, + createCredentialFixture, addSandboxChannel: async () => {}, rebuildSandbox: async (_sandboxName, args) => { expect(args).toEqual(["--yes"]); + expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBeUndefined(); events.push("rebuild"); }, }, ); - expect(events).toEqual(["install", "rebuild", "restore"]); - expect(restore).toHaveBeenCalledOnce(); + expect(events).toEqual(["rebuild"]); + expect(createCredentialFixture).not.toHaveBeenCalled(); + expect(process.env.GOOGLECHAT_SERVICE_ACCOUNT).toBe("fake-service-account"); + vi.unstubAllEnvs(); }); it.each([ @@ -325,23 +266,26 @@ describe("channels stop/start Google Chat live composition", () => { providerType: "nemoclaw-mcp-v1", }; const originalUpsert = vi.fn(() => [delegatedName]); - const providerDependencies: FixtureProviderDependencies = { + const runGatewayOpenshell = vi.fn( + ( + _gatewayName: string, + args: string[], + _options?: Parameters[2], + ) => ({ status: args[1] === "get" ? 1 : 0 }), + ); + const channelDependencies: FixtureChannelDependencies = { upsertMessagingProviders: originalUpsert, + runGatewayOpenshell: runGatewayOpenshell as never, }; const ensureProfiles = vi.fn(); - const runMock = vi.fn((args: string[], _options?: { env?: NodeJS.ProcessEnv }) => ({ - status: args[1] === "get" ? 1 : 0, - })); - const run = runMock as unknown as FixtureRunner; - const revalidateSandboxIdentity = vi.fn(); - const restore = installGooglechatCredentialFixture(sandboxName, agent, { + const fixture = createGooglechatCredentialFixture(sandboxName, agent, { + channelDependencies, ensureProfiles, - providerDependencies, root: "/repo", - run, }); - const providerNames = providerDependencies.upsertMessagingProviders( + const options = { bestEffort: true, requireExactBindings: true }; + const providerNames = fixture.upsertMessagingProviders( [ delegatedTokenDef, { @@ -351,14 +295,12 @@ describe("channels stop/start Google Chat live composition", () => { providerType, }, ], - run, - { revalidateSandboxIdentity }, + "nemoclaw", + options, ); expect(providerNames).toEqual([delegatedName, `${sandboxName}-googlechat-bridge`]); - expect(originalUpsert).toHaveBeenCalledWith([delegatedTokenDef], run, { - revalidateSandboxIdentity, - }); + expect(originalUpsert).toHaveBeenCalledWith([delegatedTokenDef], "nemoclaw", options); expect(ensureProfiles).toHaveBeenCalledOnce(); const profileDependencies = ensureProfiles.mock.calls[0]?.[1] as { redact: (value: string) => string; @@ -366,12 +308,10 @@ describe("channels stop/start Google Chat live composition", () => { runOpenshell: FixtureRunner; }; expect(profileDependencies.root).toBe("/repo"); - expect(profileDependencies.runOpenshell).not.toBe(run); expect(profileDependencies.redact(GOOGLECHAT_E2E_ACCESS_TOKEN)).toBe("[redacted]"); - expect(revalidateSandboxIdentity).toHaveBeenCalledTimes(2); - const createCall = runMock.mock.calls.find(([args]) => args[1] === "create"); - expect(createCall?.[0]).toEqual([ + const createCall = runGatewayOpenshell.mock.calls.find(([, args]) => args[1] === "create"); + expect(createCall?.[1]).toEqual([ "provider", "create", "--name", @@ -381,69 +321,13 @@ describe("channels stop/start Google Chat live composition", () => { "--credential", "GOOGLE_CHAT_ACCESS_TOKEN", ]); - expect(createCall?.[0]).not.toContain(GOOGLECHAT_E2E_ACCESS_TOKEN); - expect(createCall?.[1]?.env).toMatchObject({ + expect(createCall?.[1]).not.toContain(GOOGLECHAT_E2E_ACCESS_TOKEN); + expect(createCall?.[2]?.env).toMatchObject({ GOOGLE_CHAT_ACCESS_TOKEN: GOOGLECHAT_E2E_ACCESS_TOKEN, }); - - restore(); - expect(providerDependencies.upsertMessagingProviders).toBe(originalUpsert); }, ); - it("intercepts both registration and legacy provider boundaries during rebuild", () => { - const sandboxName = "e2e-oc-ch-cycle"; - const expectedName = `${sandboxName}-googlechat-bridge`; - const delegatedName = `${sandboxName}-slack-bridge`; - const registrationOriginal = vi.fn(() => []); - const legacyOriginal = vi.fn(() => [delegatedName]); - const providerDependencies: FixtureProviderDependencies = { - upsertMessagingProviders: registrationOriginal, - }; - const legacyProviderDependencies: FixtureProviderDependencies = { - upsertMessagingProviders: legacyOriginal, - }; - const run = vi.fn((args: string[]) => ({ - status: args[1] === "get" ? 1 : 0, - })) as unknown as FixtureRunner; - const restore = installGooglechatCredentialFixture(sandboxName, "openclaw", { - ensureProfiles: vi.fn(), - providerDependencies, - legacyProviderDependencies, - root: "/repo", - run, - }); - - expect(providerDependencies.upsertMessagingProviders).toBe( - legacyProviderDependencies.upsertMessagingProviders, - ); - expect( - providerDependencies.upsertMessagingProviders( - [ - { - name: delegatedName, - envKey: "SLACK_BOT_TOKEN", - token: "e2e-fake-slack-token", - providerType: "nemoclaw-mcp-v1", - }, - { - name: expectedName, - envKey: "GOOGLE_CHAT_ACCESS_TOKEN", - token: null, - providerType: "google-chat-bridge", - }, - ], - run, - ), - ).toEqual([delegatedName, expectedName]); - expect(registrationOriginal).not.toHaveBeenCalled(); - expect(legacyOriginal).toHaveBeenCalledOnce(); - - restore(); - expect(providerDependencies.upsertMessagingProviders).toBe(registrationOriginal); - expect(legacyProviderDependencies.upsertMessagingProviders).toBe(legacyOriginal); - }); - it.each([ [ {}, @@ -478,22 +362,21 @@ describe("channels stop/start Google Chat live composition", () => { ] as const)( "reconciles an existing fixture provider with options %o", (options, expectedCalls) => { - const providerDependencies: FixtureProviderDependencies = { - upsertMessagingProviders: vi.fn(() => []), - }; const calls: string[][] = []; - const run = ((args: string[]) => { + const runGatewayOpenshell = vi.fn((_gatewayName: string, args: string[]) => { calls.push(args); return { status: 0 }; - }) as unknown as FixtureRunner; - const restore = installGooglechatCredentialFixture("e2e-oc-ch-cycle", "openclaw", { + }); + const fixture = createGooglechatCredentialFixture("e2e-oc-ch-cycle", "openclaw", { + channelDependencies: { + upsertMessagingProviders: vi.fn(() => []), + runGatewayOpenshell: runGatewayOpenshell as never, + }, ensureProfiles: vi.fn(), - providerDependencies, root: "/repo", - run, }); - providerDependencies.upsertMessagingProviders( + fixture.upsertMessagingProviders( [ { name: "e2e-oc-ch-cycle-googlechat-bridge", @@ -502,12 +385,11 @@ describe("channels stop/start Google Chat live composition", () => { providerType: "google-chat-bridge", }, ], - run, + "nemoclaw", options, ); expect(calls).toEqual(expectedCalls); - restore(); }, ); }); From 5f10449c3bc77fcb444f2f3e7ef94cdc1174106f Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 00:27:51 -0700 Subject: [PATCH 13/17] fix(messaging): validate staged credential drift Signed-off-by: Prekshi Vyas --- .../handlers/sandbox-messaging.test.ts | 2 +- .../machine/handlers/sandbox-messaging.ts | 51 +++++++++++-------- .../onboard/machine/handlers/sandbox.test.ts | 51 +++++++++++-------- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index c2c468b39e2..c62a7df6f0a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -606,7 +606,7 @@ describe("reconcileSandboxMessaging plan authority", () => { ).rejects.toThrow("invalid Telegram token"); expect(deps.setupMessagingChannels).toHaveBeenCalledOnce(); - expect(deps.writePlanToEnv).not.toHaveBeenCalled(); + expect(deps.writePlanToEnv).toHaveBeenCalledWith(plan); expect(plan.credentialBindings[0]?.credentialHash).toBe(hashCredential(previousToken)); }); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 8714fe73d4d..f600f3f8913 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -557,26 +557,6 @@ async function selectionFromRegistryPlan( registryPlan: SandboxMessagingPlan, options: ReconcileSandboxMessagingOptions, ): Promise { - const activeChannels = filterChannelNamesForCurrentAgent( - getActiveChannelsFromPlan(registryPlan), - options.agent, - ); - const credentialDriftChannels = messagingChannelsWithCredentialDrift( - registryPlan, - options.env as NodeJS.ProcessEnv, - activeChannels, - ); - if (credentialDriftChannels.length > 0) { - options.deps.note( - ` [non-interactive] Detected messaging channel inputs for ${credentialDriftChannels.join(", ")}; reconciling reused sandbox messaging plan.`, - ); - return selectionFromMessagingSetup( - credentialDriftChannels, - { ...options, forceCredentialValidation: true }, - true, - registryPlan, - ); - } if (registryPlanRecordsLifecycleSelection(registryPlan)) { // A lifecycle command owns which channels the operator asked for, but not // whether the host still configures them. Onboarding re-reads the host @@ -856,6 +836,32 @@ async function selectionFromForcedCredentialValidation( return selectionFromMessagingSetup(requiredChannels, options, true, validationBaseline); } +async function selectionFromCredentialDrift( + plan: SandboxMessagingPlan | null, + options: ReconcileSandboxMessagingOptions, +): Promise { + const activeChannels = filterChannelNamesForCurrentAgent( + getActiveChannelsFromPlan(plan), + options.agent, + ); + const driftedChannels = messagingChannelsWithCredentialDrift( + plan, + options.env as NodeJS.ProcessEnv, + activeChannels, + ); + if (driftedChannels.length === 0 || !plan) return null; + options.deps.note( + ` [non-interactive] Detected messaging channel inputs for ${driftedChannels.join(", ")}; reconciling reused sandbox messaging plan.`, + ); + options.deps.writePlanToEnv(plan); + return selectionFromMessagingSetup( + driftedChannels, + { ...options, forceCredentialValidation: true }, + true, + plan, + ); +} + function stagedPlanFromAuthority( authority: ReturnType, ): SandboxMessagingPlan | null { @@ -898,6 +904,11 @@ export async function reconcileSandboxMessaging( }); const forcedValidationSelection = await selectionFromForcedCredentialValidation(resolvedOptions); if (forcedValidationSelection) return forcedValidationSelection; + const driftValidationSelection = await selectionFromCredentialDrift( + authority.plan, + resolvedOptions, + ); + if (driftValidationSelection) return driftValidationSelection; const messagingDecisionCompleted = resolvedOptions.session?.checkpoint ? !isDecisionUnset(resolvedOptions.session.checkpoint.messaging) : resolvedOptions.session?.sandboxPromptProgress?.messaging === true; diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index e4260ff2d73..50c92d14ebd 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -18,7 +18,6 @@ import { bindJournaledRecreate, createDeps, makeMinimalPlan, - withEnv, withTelegramCredentialHash, } from "./sandbox-test-fixtures"; @@ -1234,33 +1233,38 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(registryPlan); }); - it("refreshes credential hashes when reusing an env-staged rebuild plan", async () => { + it("validates changed credentials before refreshing an env-staged rebuild plan", async () => { const oldHash = hashCredential("telegram-token-a"); const newHash = hashCredential("telegram-token-b"); const rebuiltPlan = withTelegramCredentialHash( makeMinimalPlan("my-assistant", "openclaw", ["telegram"]), oldHash, ); + const validatedPlan = withTelegramCredentialHash(rebuiltPlan, newHash); + let stagedPlan = rebuiltPlan; const session = createSession({ sandboxName: "my-assistant", messagingPlan: rebuiltPlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps, calls, getSession } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, - readMessagingPlanFromEnv: () => rebuiltPlan, + readMessagingPlanFromEnv: () => stagedPlan, getRegistrySandboxMessagingAuthority: () => ({ authoritative: false, plan: null }), }); + calls.setupMessaging.mockImplementation(async () => { + stagedPlan = validatedPlan; + return ["telegram"]; + }); - await withEnv("TELEGRAM_BOT_TOKEN", "telegram-token-b", async () => { - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }); + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + env: { TELEGRAM_BOT_TOKEN: "telegram-token-b" }, }); - expect(calls.setupMessaging).not.toHaveBeenCalled(); - expect(writePlanToEnv).toHaveBeenCalledWith( + expect(calls.setupMessaging).toHaveBeenCalledOnce(); + expect(writePlanToEnv).toHaveBeenLastCalledWith( expect.objectContaining({ credentialBindings: [ expect.objectContaining({ @@ -1273,33 +1277,38 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan?.credentialBindings[0]?.credentialHash).toBe(newHash); }); - it("refreshes credential hashes when restoring a registry plan for rebuild resume", async () => { + it("validates changed credentials before refreshing a registry rebuild plan", async () => { const oldHash = hashCredential("telegram-token-a"); const newHash = hashCredential("telegram-token-b"); const registryPlan = withTelegramCredentialHash( makeMinimalPlan("my-assistant", "openclaw", ["telegram"]), oldHash, ); + const validatedPlan = withTelegramCredentialHash(registryPlan, newHash); + let stagedPlan = registryPlan; const session = createSession({ sandboxName: "my-assistant", messagingPlan: registryPlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); const writePlanToEnv = vi.fn(); const { deps, calls, getSession } = createDeps({ getRecordedMessagingChannelsForResume, writePlanToEnv, - readMessagingPlanFromEnv: () => null, + readMessagingPlanFromEnv: () => stagedPlan, getRegistrySandboxMessagingAuthority: () => ({ authoritative: true, plan: registryPlan }), }); + calls.setupMessaging.mockImplementation(async () => { + stagedPlan = validatedPlan; + return ["telegram"]; + }); - await withEnv("TELEGRAM_BOT_TOKEN", "telegram-token-b", async () => { - await handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "my-assistant", - }); + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + env: { TELEGRAM_BOT_TOKEN: "telegram-token-b" }, }); - expect(calls.setupMessaging).not.toHaveBeenCalled(); - expect(writePlanToEnv).toHaveBeenCalledWith( + expect(calls.setupMessaging).toHaveBeenCalledOnce(); + expect(writePlanToEnv).toHaveBeenLastCalledWith( expect.objectContaining({ credentialBindings: [ expect.objectContaining({ From c2ba363561b3fb97b1e468eaef6ee33d05d4136b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 07:21:54 -0700 Subject: [PATCH 14/17] fix(messaging): restore gateway credential inspection Signed-off-by: Prekshi Vyas --- ci/env-var-doc-allowlist.json | 4 ++ .../credential-provider-registration.test.ts | 53 +++++++++++++++++++ .../credential-provider-registration.ts | 22 ++++++-- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/ci/env-var-doc-allowlist.json b/ci/env-var-doc-allowlist.json index 8f3704cf60e..b7a4cfc04fa 100644 --- a/ci/env-var-doc-allowlist.json +++ b/ci/env-var-doc-allowlist.json @@ -75,6 +75,10 @@ "name": "NEMOCLAW_E2E_USE_HOSTED_INFERENCE", "reason": "Internal E2E-only sentinel that tells CI to route the repository NVIDIA_INFERENCE_API_KEY secret through the hosted inference-api.nvidia.com OpenAI-compatible endpoint. Not user-facing." }, + { + "name": "NEMOCLAW_RUN_LIVE_E2E", + "reason": "Internal Vitest/live-E2E sentinel that permits the exact sandbox-scoped Google Chat fake-mint provider override used by the destructive channel lifecycle fixture. Production users must not set it." + }, { "name": "NEMOCLAW_COMPAT_MODEL", "reason": "Internal E2E/test override for the model used by OpenAI-compatible endpoint scenarios. User-facing custom endpoint model selection is collected through onboard prompts or NEMOCLAW_MODEL." diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index 1ac19957754..f6ff64c91b7 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -291,6 +291,59 @@ describe("credential provider registration", () => { ]); }); + it.each([ + { + condition: "a gateway command failure", + result: () => ({ status: 2, stderr: "gateway unavailable" }), + expected: { kind: "indeterminate" as const }, + }, + { + condition: "malformed provider metadata", + result: () => ({ status: 0, stdout: "unexpected output" }), + expected: { kind: "collision" as const }, + }, + { + condition: "a thrown gateway command", + result: () => { + throw new Error("gateway unavailable"); + }, + expected: { kind: "indeterminate" as const }, + }, + ])("preserves $condition when inspecting a credential binding", ({ result, expected }) => { + const session = { stagedCredentialProviders: [] } as unknown as Session; + const registration = createCredentialProviderRegistration( + registrationDeps(vi.fn(result), session), + ); + + expect( + registration.inspectGatewayCredential( + "alpha-telegram-bridge", + "nemoclaw-mcp-v1", + "TELEGRAM_BOT_TOKEN", + ), + ).toEqual(expected); + }); + + it("treats a failed static profile inspection as indeterminate", () => { + const session = { stagedCredentialProviders: [] } as unknown as Session; + const runOpenshell = vi.fn((args: string[]) => + args.includes("profile") + ? { status: 2, stderr: "gateway unavailable" } + : providerMetadata("alpha-discord-bridge", "discord-hermes-static-v1", "DISCORD_BOT_TOKEN"), + ); + const deps = registrationDeps(runOpenshell, session); + deps.root = process.cwd(); + const registration = createCredentialProviderRegistration(deps); + + expect( + registration.inspectGatewayCredential( + "alpha-discord-bridge", + "discord-hermes-static-v1", + "DISCORD_BOT_TOKEN", + ), + ).toEqual({ kind: "indeterminate" }); + }); + it("rejects tokenless Hermes Discord profile drift before provider mutation", async () => { const session = { stagedCredentialProviders: [] } as unknown as Session; const runOpenshell = vi.fn((args: string[]) => diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 165b75bccd6..f87ad6df740 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -277,21 +277,36 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg binding: CheckpointProviderBinding, runOpenshell: OpenshellCliHelpers["runOpenshell"], ): boolean { + return inspectGatewayCredentialBinding(binding, runOpenshell).kind === "exact"; + } + + function inspectGatewayCredentialBinding( + binding: CheckpointProviderBinding, + runOpenshell: OpenshellCliHelpers["runOpenshell"], + ): gatewayProviderMetadata.GatewayCredentialOnlyProviderInspection { const staticProfileMatches = messagingBridgeProvider.matchesRegisteredStaticMessagingProfile( binding.type, { root: deps.root, runOpenshell }, ); - if (staticProfileMatches === false) return false; - return gatewayProviderMetadata.matchesGatewayCredentialFamilyProviderBinding( - providers.readGatewayProviderMetadata(binding.name, runOpenshell, deps.getGatewayName()), + if (staticProfileMatches === false) return { kind: "indeterminate" }; + return gatewayProviderMetadata.inspectGatewayCredentialFamilyProviderBinding( { name: binding.name, type: binding.type, credentialKey: binding.credentialEnv, }, + runOpenshell, ); } + function inspectGatewayCredential( + name: string, + type: string, + credentialEnv: string, + ): gatewayProviderMetadata.GatewayCredentialOnlyProviderInspection { + return inspectGatewayCredentialBinding({ name, type, credentialEnv }, gatewayRunner()); + } + function providerMatchesGatewayCredential( name: string, type: string, @@ -370,6 +385,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg } return { + inspectGatewayCredential, providerMatchesGatewayCredential, stageSandboxCredentialProviders, upsertProvider, From 2a83ba9242eac85fd9176ea4a4576f5c0010926f Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 07:55:59 -0700 Subject: [PATCH 15/17] fix(messaging): validate provider reuse boundaries Signed-off-by: Prekshi Vyas --- ci/env-var-doc-allowlist.json | 4 - .../enable-channels-during-onboarding.mdx | 2 + .../credential-provider-registration.test.ts | 102 +++------------ .../credential-provider-registration.ts | 68 ---------- ...andbox-messaging-legacy-checkpoint.test.ts | 122 ++++++++++++++++++ .../handlers/sandbox-messaging.test.ts | 6 +- .../machine/handlers/sandbox-messaging.ts | 44 ++++++- .../handlers/sandbox-ready-messaging.test.ts | 60 ++++++++- test/e2e/live/channels-stop-start-helpers.ts | 24 +--- .../channels-stop-start-googlechat.test.ts | 8 +- 10 files changed, 252 insertions(+), 188 deletions(-) create mode 100644 src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts diff --git a/ci/env-var-doc-allowlist.json b/ci/env-var-doc-allowlist.json index b7a4cfc04fa..8f3704cf60e 100644 --- a/ci/env-var-doc-allowlist.json +++ b/ci/env-var-doc-allowlist.json @@ -75,10 +75,6 @@ "name": "NEMOCLAW_E2E_USE_HOSTED_INFERENCE", "reason": "Internal E2E-only sentinel that tells CI to route the repository NVIDIA_INFERENCE_API_KEY secret through the hosted inference-api.nvidia.com OpenAI-compatible endpoint. Not user-facing." }, - { - "name": "NEMOCLAW_RUN_LIVE_E2E", - "reason": "Internal Vitest/live-E2E sentinel that permits the exact sandbox-scoped Google Chat fake-mint provider override used by the destructive channel lifecycle fixture. Production users must not set it." - }, { "name": "NEMOCLAW_COMPAT_MODEL", "reason": "Internal E2E/test override for the model used by OpenAI-compatible endpoint scenarios. User-facing custom endpoint model selection is collected through onboard prompts or NEMOCLAW_MODEL." diff --git a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx index 4a7b1e8ce14..05db89ad09b 100644 --- a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx +++ b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx @@ -98,6 +98,8 @@ $$nemoclaw channels remove ``` Accept the rebuild to remove the channel configuration and its network policy preset from the replacement sandbox. +When onboarding runs without a terminal on stdin or with `NEMOCLAW_NON_INTERACTIVE=1`, NemoClaw queues the removal. +Run `$$nemoclaw rebuild` to apply it. Clearing the channel's host environment variables is not a removal signal when the matching provider remains in OpenShell. Onboarding reuses that provider and keeps the channel selected, including its network egress. diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index f6ff64c91b7..1df29744888 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -10,7 +10,6 @@ import { credentialProviderRegistrationDependencies, type CredentialProviderRegistrationDeps, createCredentialProviderRegistration, - installLiveE2eCredentialProviderRegistrationOverride, } from "./credential-provider-registration"; import type { MessagingTokenDef } from "./messaging-prep"; @@ -94,87 +93,6 @@ function sandboxInput(bindings: ReturnType) { } describe("credential provider registration", () => { - it("restricts the process-global provider override to the destructive live E2E", () => { - vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "0"); - try { - expect(() => - installLiveE2eCredentialProviderRegistrationOverride({ - expectedName: "e2e-oc-ch-cycle-googlechat-bridge", - expectedType: "google-chat-bridge", - upsert: vi.fn(() => []), - }), - ).toThrow("restricted to its destructive live E2E"); - } finally { - vi.unstubAllEnvs(); - } - }); - - it("routes one exact Google Chat live E2E plan through the process-global override", () => { - vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "1"); - const tokenDefs: MessagingTokenDef[] = [ - { - name: "e2e-oc-ch-cycle-googlechat-bridge", - envKey: "GOOGLE_CHAT_ACCESS_TOKEN", - token: null, - providerType: "google-chat-bridge", - }, - ]; - const runOpenshell = vi.fn(); - const override = vi.fn(() => ["e2e-oc-ch-cycle-googlechat-bridge"]); - const restore = installLiveE2eCredentialProviderRegistrationOverride({ - expectedName: "e2e-oc-ch-cycle-googlechat-bridge", - expectedType: "google-chat-bridge", - upsert: override, - }); - try { - expect( - credentialProviderRegistrationDependencies.upsertMessagingProviders( - tokenDefs, - runOpenshell, - { replaceExisting: true }, - ), - ).toEqual(["e2e-oc-ch-cycle-googlechat-bridge"]); - expect(override).toHaveBeenCalledExactlyOnceWith(tokenDefs, runOpenshell, { - replaceExisting: true, - }); - } finally { - restore(); - vi.unstubAllEnvs(); - } - }); - - it("leaves unrelated provider batches on the production path while the live override is installed", () => { - vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "1"); - const tokenDefs: MessagingTokenDef[] = [ - { - name: "e2e-oc-ch-cycle-discord-bridge", - envKey: "DISCORD_BOT_TOKEN", - token: null, - providerType: "generic", - }, - ]; - const runOpenshell = vi.fn(); - const override = vi.fn(() => ["e2e-oc-ch-cycle-googlechat-bridge"]); - const restore = installLiveE2eCredentialProviderRegistrationOverride({ - expectedName: "e2e-oc-ch-cycle-googlechat-bridge", - expectedType: "google-chat-bridge", - upsert: override, - }); - try { - expect( - credentialProviderRegistrationDependencies.upsertMessagingProviders( - tokenDefs, - runOpenshell, - {}, - ), - ).toEqual([]); - expect(override).not.toHaveBeenCalled(); - } finally { - restore(); - vi.unstubAllEnvs(); - } - }); - it("resolves the provider upsert dependency when registration executes", () => { const session = { stagedCredentialProviders: [] } as unknown as Session; const runOpenshell = vi.fn(); @@ -285,9 +203,23 @@ describe("credential provider registration", () => { "DISCORD_BOT_TOKEN", ), ).toBe(true); - expect(runOpenshell.mock.calls.map(([args]) => args.join(" "))).toEqual([ - "provider profile -g test-gateway export discord-hermes-static-v1 --output json", - "provider get -g test-gateway alpha-discord-bridge", + const commands = runOpenshell.mock.calls.map(([args]) => args); + expect(commands).toContainEqual([ + "provider", + "profile", + "-g", + "test-gateway", + "export", + "discord-hermes-static-v1", + "--output", + "json", + ]); + expect(commands).toContainEqual([ + "provider", + "get", + "-g", + "test-gateway", + "alpha-discord-bridge", ]); }); diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index f87ad6df740..712328aaf3c 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -12,59 +12,6 @@ import { createGatewayScopedOpenshellRunner } from "./setup-inference"; const providers = require("./providers"); -type CredentialProviderRegistrationUpsert = ( - tokenDefs: MessagingTokenDef[], - runOpenshell: OpenshellCliHelpers["runOpenshell"], - options: MessagingProviderRegistrationOptions, -) => string[]; - -type LiveE2eCredentialProviderOverride = { - readonly expectedName: string; - readonly expectedType: string; - readonly upsert: CredentialProviderRegistrationUpsert; -}; - -const LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY = - "__nemoclawLiveE2eCredentialProviderRegistrationOverride" as const; - -function liveE2eCredentialProviderOverride(): LiveE2eCredentialProviderOverride | null { - const state = globalThis as typeof globalThis & { - [LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]?: LiveE2eCredentialProviderOverride; - }; - return state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY] ?? null; -} - -/** Install the exact Google Chat fake-mint boundary used by the destructive live E2E. */ -export function installLiveE2eCredentialProviderRegistrationOverride(input: { - readonly expectedName: string; - readonly expectedType: "google-chat-bridge" | "google-chat-hermes-bridge"; - readonly upsert: CredentialProviderRegistrationUpsert; -}): () => void { - if ( - process.env.NEMOCLAW_RUN_LIVE_E2E !== "1" || - !/^e2e-(?:oc|hm)-ch-[a-z0-9-]+-googlechat-bridge$/u.test(input.expectedName) - ) { - throw new Error("Google Chat provider override is restricted to its destructive live E2E."); - } - const state = globalThis as typeof globalThis & { - [LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]?: LiveE2eCredentialProviderOverride; - }; - if (state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]) { - throw new Error("A live E2E credential provider override is already installed."); - } - const installed = { ...input }; - state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY] = installed; - let restored = false; - return () => { - if (restored) return; - if (state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY] !== installed) { - throw new Error("The live E2E credential provider override changed before cleanup."); - } - delete state[LIVE_E2E_CREDENTIAL_PROVIDER_OVERRIDE_KEY]; - restored = true; - }; -} - /** Late-bound provider upsert seam used by live credential fixtures. */ export const credentialProviderRegistrationDependencies = { upsertMessagingProviders( @@ -72,21 +19,6 @@ export const credentialProviderRegistrationDependencies = { runOpenshell: OpenshellCliHelpers["runOpenshell"], options: MessagingProviderRegistrationOptions, ): string[] { - const override = liveE2eCredentialProviderOverride(); - if (override) { - const selected = tokenDefs.filter(({ name }) => name === override.expectedName); - if (selected.length === 0) { - return providers.upsertMessagingProviders(tokenDefs, runOpenshell, options) as string[]; - } - if ( - selected.length !== 1 || - selected[0]?.envKey !== "GOOGLE_CHAT_ACCESS_TOKEN" || - selected[0]?.providerType !== override.expectedType - ) { - throw new Error("Google Chat live E2E provider override received an unexpected plan."); - } - return override.upsert(tokenDefs, runOpenshell, options); - } return providers.upsertMessagingProviders(tokenDefs, runOpenshell, options) as string[]; }, }; diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts new file mode 100644 index 00000000000..20263a26e5f --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + SandboxMessagingCredentialBindingPlan, + SandboxMessagingPlan, +} from "../../../messaging/manifest"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; +import { hashCredential } from "../../../security/credential-hash"; +import { decisionSelected } from "../../../state/onboard-checkpoint-decision"; +import { deriveCheckpointFromSession } from "../../../state/onboard-checkpoint-migrate"; +import { createSession } from "../../../state/onboard-session"; +import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; +import { reconcileSandboxMessaging } from "./sandbox-messaging"; + +function slackBinding( + providerName: string, + providerEnvKey: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN", + credentialHash: string, +): SandboxMessagingCredentialBindingPlan { + const bot = providerEnvKey === "SLACK_BOT_TOKEN"; + return { + channelId: "slack", + credentialId: bot ? "slackBotToken" : "slackAppToken", + sourceInput: bot ? "botToken" : "appToken", + providerName, + providerEnvKey, + placeholder: `${bot ? "xoxb" : "xapp"}-OPENSHELL-RESOLVE-ENV-${providerEnvKey}`, + credentialAvailable: true, + credentialHash, + }; +} + +function slackPlan(): SandboxMessagingPlan { + return makeMessagingPlan({ + sandboxName: "alpha", + agent: "openclaw", + channels: ["slack"], + credentialBindings: [ + slackBinding( + "alpha-slack-bridge", + "SLACK_BOT_TOKEN", + hashCredential("xoxb-existing-slack-bot-token") ?? "", + ), + slackBinding( + "alpha-slack-app", + "SLACK_APP_TOKEN", + hashCredential("xapp-existing-slack-app-token") ?? "", + ), + ], + }); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("completed messaging checkpoint provider migration", () => { + it("normalizes legacy Slack bindings before gateway probes", async () => { + const currentPlan = slackPlan(); + const legacyPlan = { + ...currentPlan, + credentialBindings: currentPlan.credentialBindings.map((binding) => + binding.providerEnvKey === "SLACK_APP_TOKEN" + ? { ...binding, providerName: "alpha-slack-bridge" } + : binding, + ), + }; + const session = createSession({ sandboxName: "alpha", messagingPlan: legacyPlan }); + session.stagedCredentialProviders = ["alpha-slack-bridge", "alpha-slack-app"]; + session.checkpoint = { + ...deriveCheckpointFromSession(session), + messaging: decisionSelected({ selectedChannels: ["slack"], disabledChannels: [] }), + }; + const providerMatchesGatewayCredential = vi.fn(() => true); + const setupMessagingChannels = vi.fn(async () => ["slack"]); + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: true, + session, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps: { + note: vi.fn(), + showMessagingStage: vi.fn(), + getRecordedMessagingChannelsForResume: vi.fn(() => null), + setupMessagingChannels, + readMessagingPlanFromEnv: vi.fn(() => null), + writePlanToEnv: vi.fn(), + clearPlanEnv: vi.fn(), + getRegistrySandboxMessagingAuthority: vi.fn(() => ({ + authoritative: false, + plan: null, + })), + inspectGatewayCredential: vi.fn(() => ({ kind: "missing" as const })), + providerMatchesGatewayCredential, + }, + }); + + expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_BOT_TOKEN", + ); + expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + "alpha-slack-app", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(providerMatchesGatewayCredential).not.toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(setupMessagingChannels).not.toHaveBeenCalled(); + expect(result).toEqual({ plan: currentPlan, selectedChannels: ["slack"] }); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index c62a7df6f0a..33680ac362b 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -307,7 +307,7 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv, - inspectGatewayCredential: () => ({ kind: "missing" }), + inspectGatewayCredential: () => ({ kind: "exact" }), note: vi.fn(), writePlanToEnv: vi.fn(), }, @@ -346,7 +346,7 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv: vi.fn(), - inspectGatewayCredential: () => ({ kind: "missing" }), + inspectGatewayCredential: () => ({ kind: "exact" }), note: vi.fn(), writePlanToEnv: vi.fn(), }, @@ -543,7 +543,7 @@ describe("reconcileReusedSandboxMessaging", () => { { name: "openclaw" }, { clearPlanEnv() {}, - inspectGatewayCredential: () => ({ kind: "missing" }), + inspectGatewayCredential: () => ({ kind: "exact" }), note() {}, writePlanToEnv() {}, }, diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index f600f3f8913..45730f48c1d 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -293,6 +293,28 @@ function channelCredentialLivesAtGateway( return inspections.every(({ inspection }) => inspection.kind === "exact"); } +function reconcileGatewayCredentialChannels( + plan: SandboxMessagingPlan, + channelIds: readonly string[], + missingChannels: Set, + deps: Pick, "inspectGatewayCredential">, + addMissingChannels: boolean, +): void { + for (const channelId of channelIds) { + const providerBindings = requiredMessagingProviderBindings( + plan.sandboxName, + plan, + new Set([channelId]), + ); + if (providerBindings.length === 0) continue; + if (channelCredentialLivesAtGateway(plan, channelId, deps)) { + missingChannels.delete(channelId); + } else if (addMissingChannels) { + missingChannels.add(channelId); + } + } +} + function persistMessagingPlan( plan: SandboxMessagingPlan | null, deps: Pick, "clearPlanEnv" | "writePlanToEnv">, @@ -327,11 +349,17 @@ function filterUnconfiguredHostChannelsFromSelection( // - Without this, every later rebuild strips the channel's bindings and egress. const planForGatewayCheck = selection.plan; if (planForGatewayCheck) { - for (const channelId of [...unconfiguredChannels]) { - if (channelCredentialLivesAtGateway(planForGatewayCheck, channelId, deps)) { - unconfiguredChannels.delete(channelId); - } - } + const inspectAllActiveCredentialChannels = missingAction === "reject-ready-reuse"; + const channelsToInspect = inspectAllActiveCredentialChannels + ? selection.selectedChannels + : [...unconfiguredChannels]; + reconcileGatewayCredentialChannels( + planForGatewayCheck, + channelsToInspect, + unconfiguredChannels, + deps, + inspectAllActiveCredentialChannels, + ); } if (unconfiguredChannels.size === 0) return selection; if (missingAction === "reject-ready-reuse") { @@ -719,7 +747,11 @@ async function selectionFromCompletedMessagingCheckpoint( return { plan: null, selectedChannels: [] }; } - const filteredPlan = filterMessagingPlanForCurrentAgent(validationPlan, options.agent); + const normalizedValidationPlan = normalizeMessagingProviderBindings( + options.sandboxName, + validationPlan, + ); + const filteredPlan = filterMessagingPlanForCurrentAgent(normalizedValidationPlan, options.agent); if (!filteredPlan) { options.deps.clearPlanEnv(); options.deps.showMessagingStage?.(); diff --git a/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts index 817b72baa82..040d6d98799 100644 --- a/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { hashCredential } from "../../../security/credential-hash"; import { createSession } from "../../../state/onboard-session"; @@ -26,6 +26,10 @@ describe("handleSandboxState Ready sandbox messaging", () => { detectUnconfiguredMessagingChannelsMock.mockReturnValue([]); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("keeps a Ready channel when its credential remains at the gateway", async () => { const minimalPlan = makeMinimalPlan("saved", "openclaw", ["discord"]); const registryPlan = { @@ -128,4 +132,58 @@ describe("handleSandboxState Ready sandbox messaging", () => { expect(writePlanToEnv).not.toHaveBeenCalled(); expect(getSession().messagingPlan).toEqual(registryPlan); }); + + it("rejects Ready reuse when a matching host credential has no gateway provider", async () => { + const token = "123456:unchanged-telegram-token"; + const registryPlan = withTelegramCredentialHash( + makeMinimalPlan("saved", "openclaw", ["telegram"]), + hashCredential(token), + ); + const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); + session.steps.sandbox.status = "complete"; + const recordStateSkipped = vi.fn(async () => session); + const writePlanToEnv = vi.fn(); + const inspectGatewayCredential = vi.fn(() => ({ kind: "missing" as const })); + vi.stubEnv("TELEGRAM_BOT_TOKEN", token); + const { deps, calls, getSession } = createDeps( + { + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => ({ + name: "saved", + pendingRouteReservation: true, + reservationSessionId: session.sessionId, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + }), + getRegistrySandboxMessagingAuthority: () => ({ authoritative: true, plan: registryPlan }), + inspectGatewayCredential, + writePlanToEnv, + recordStateSkipped, + }, + session, + ); + + await expect( + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }), + ).rejects.toThrow(/Ready sandbox 'saved'.*durable messaging plan were not changed/u); + + expect(inspectGatewayCredential).toHaveBeenCalledWith( + "saved-telegram-bridge", + "nemoclaw-mcp-v1", + "TELEGRAM_BOT_TOKEN", + ); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(recordStateSkipped).not.toHaveBeenCalled(); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(getSession().messagingPlan).toEqual(registryPlan); + }); }); diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts index e484b00b26f..599b34201c0 100644 --- a/test/e2e/live/channels-stop-start-helpers.ts +++ b/test/e2e/live/channels-stop-start-helpers.ts @@ -59,9 +59,6 @@ type PolicyChannelDependenciesModule = type OpenshellRuntimeModule = typeof import("../../../src/lib/adapters/openshell/runtime.ts"); type CredentialProviderRegistrationModule = typeof import("../../../src/lib/onboard/credential-provider-registration.ts"); -type LiveE2eCredentialProviderOverrideInput = Parameters< - CredentialProviderRegistrationModule["installLiveE2eCredentialProviderRegistrationOverride"] ->[0]; type MessagingBridgeProviderModule = typeof import("../../../src/lib/onboard/messaging-bridge-provider.ts"); type StatePathsModule = typeof import("../../../src/lib/state/paths.ts"); @@ -323,21 +320,12 @@ export function installGooglechatCredentialFixture( const registered = new Set([...delegatedProviderNames, expectedName]); return tokenDefs.map(({ name }) => name).filter((name) => registered.has(name)); }; - let restore: () => void; - if (injectedProviderDependencies) { - providerDependencies.upsertMessagingProviders = fixtureUpsert; - effectiveLegacyProviderDependencies.upsertMessagingProviders = fixtureUpsert; - restore = () => { - providerDependencies.upsertMessagingProviders = originalRegistrationUpsert; - effectiveLegacyProviderDependencies.upsertMessagingProviders = originalLegacyUpsert; - }; - } else { - restore = credentialProviderRegistration.installLiveE2eCredentialProviderRegistrationOverride({ - expectedName, - expectedType, - upsert: fixtureUpsert as LiveE2eCredentialProviderOverrideInput["upsert"], - }); - } + providerDependencies.upsertMessagingProviders = fixtureUpsert; + effectiveLegacyProviderDependencies.upsertMessagingProviders = fixtureUpsert; + const restore = () => { + providerDependencies.upsertMessagingProviders = originalRegistrationUpsert; + effectiveLegacyProviderDependencies.upsertMessagingProviders = originalLegacyUpsert; + }; return Object.assign(restore, { upsertMessagingProviders: directUpsert }); } diff --git a/test/e2e/support/channels-stop-start-googlechat.test.ts b/test/e2e/support/channels-stop-start-googlechat.test.ts index 0b0baa0ae57..cac8b7bc15c 100644 --- a/test/e2e/support/channels-stop-start-googlechat.test.ts +++ b/test/e2e/support/channels-stop-start-googlechat.test.ts @@ -88,10 +88,10 @@ describe("channels stop/start Google Chat live composition", () => { expect(channelDependencies.upsertMessagingProviders).toBe(originalUpsert); }); - it("routes rebuild registration through the process-global live fixture", () => { - vi.stubEnv("NEMOCLAW_RUN_LIVE_E2E", "1"); + it("routes rebuild registration through the live fixture dependency and restores it", () => { const sandboxName = "e2e-oc-ch-cycle"; const expectedName = `${sandboxName}-googlechat-bridge`; + const originalUpsert = credentialProviderRegistrationDependencies.upsertMessagingProviders; const runMock = vi.fn((args: string[]) => ({ status: args[1] === "get" ? 1 : 0, stdout: "", @@ -122,8 +122,10 @@ describe("channels stop/start Google Chat live composition", () => { expect(runMock.mock.calls.some(([args]) => args.includes("refresh"))).toBe(false); } finally { restore(); - vi.unstubAllEnvs(); } + expect(credentialProviderRegistrationDependencies.upsertMessagingProviders).toBe( + originalUpsert, + ); }); it("grants a process-local audience capability to the exact live sandbox", async () => { From f4182089713ec083cde5f5b0e8528a9fd195bcb9 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 08:35:23 -0700 Subject: [PATCH 16/17] refactor(messaging): remove review-only registration seams Signed-off-by: Prekshi Vyas --- .../credential-provider-registration.test.ts | 21 --- .../credential-provider-registration.ts | 15 +-- ...andbox-messaging-legacy-checkpoint.test.ts | 122 ------------------ .../handlers/sandbox-messaging.test.ts | 107 +++++++-------- test/e2e/live/channels-stop-start-helpers.ts | 17 +-- .../channels-stop-start-googlechat.test.ts | 15 ++- 6 files changed, 68 insertions(+), 229 deletions(-) delete mode 100644 src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts diff --git a/src/lib/onboard/credential-provider-registration.test.ts b/src/lib/onboard/credential-provider-registration.test.ts index 1df29744888..bf2d29fdb0f 100644 --- a/src/lib/onboard/credential-provider-registration.test.ts +++ b/src/lib/onboard/credential-provider-registration.test.ts @@ -7,7 +7,6 @@ import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { Session } from "../state/onboard-session"; import { requiredMessagingProviderBindings } from "./checkpoint-replay"; import { - credentialProviderRegistrationDependencies, type CredentialProviderRegistrationDeps, createCredentialProviderRegistration, } from "./credential-provider-registration"; @@ -93,26 +92,6 @@ function sandboxInput(bindings: ReturnType) { } describe("credential provider registration", () => { - it("resolves the provider upsert dependency when registration executes", () => { - const session = { stagedCredentialProviders: [] } as unknown as Session; - const runOpenshell = vi.fn(); - const deps = registrationDeps(runOpenshell, session); - const registration = createCredentialProviderRegistration(deps); - const tokenDefs: MessagingTokenDef[] = [ - { name: "alpha-googlechat-bridge", envKey: "GOOGLE_CHAT_ACCESS_TOKEN", token: null }, - ]; - const upsert = vi - .spyOn(credentialProviderRegistrationDependencies, "upsertMessagingProviders") - .mockReturnValue(["alpha-googlechat-bridge"]); - - try { - expect(registration.upsertMessagingProviders(tokenDefs)).toEqual(["alpha-googlechat-bridge"]); - expect(upsert).toHaveBeenCalledExactlyOnceWith(tokenDefs, deps.runOpenshell, {}); - } finally { - upsert.mockRestore(); - } - }); - it.each([ { condition: "matches", endpoints: [], expected: true }, { diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 712328aaf3c..7cc88553dc9 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -12,17 +12,6 @@ import { createGatewayScopedOpenshellRunner } from "./setup-inference"; const providers = require("./providers"); -/** Late-bound provider upsert seam used by live credential fixtures. */ -export const credentialProviderRegistrationDependencies = { - upsertMessagingProviders( - tokenDefs: MessagingTokenDef[], - runOpenshell: OpenshellCliHelpers["runOpenshell"], - options: MessagingProviderRegistrationOptions, - ): string[] { - return providers.upsertMessagingProviders(tokenDefs, runOpenshell, options) as string[]; - }, -}; - export interface StageSandboxCredentialProvidersInput { sandboxName: string; enabledChannels: readonly string[]; @@ -191,11 +180,11 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg options: MessagingProviderRegistrationOptions = {}, runOpenshell: OpenshellCliHelpers["runOpenshell"] = deps.runOpenshell, ): string[] { - const upserted = credentialProviderRegistrationDependencies.upsertMessagingProviders( + const upserted = providers.upsertMessagingProviders( tokenDefs, runOpenshell, options, - ); + ) as string[]; recordMigratedLegacyMessagingCredentials( tokenDefs, upserted, diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts deleted file mode 100644 index 20263a26e5f..00000000000 --- a/src/lib/onboard/machine/handlers/sandbox-messaging-legacy-checkpoint.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { - SandboxMessagingCredentialBindingPlan, - SandboxMessagingPlan, -} from "../../../messaging/manifest"; -import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; -import { hashCredential } from "../../../security/credential-hash"; -import { decisionSelected } from "../../../state/onboard-checkpoint-decision"; -import { deriveCheckpointFromSession } from "../../../state/onboard-checkpoint-migrate"; -import { createSession } from "../../../state/onboard-session"; -import { makeMessagingPlan } from "../../../../../test/helpers/messaging-plan-fixtures"; -import { reconcileSandboxMessaging } from "./sandbox-messaging"; - -function slackBinding( - providerName: string, - providerEnvKey: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN", - credentialHash: string, -): SandboxMessagingCredentialBindingPlan { - const bot = providerEnvKey === "SLACK_BOT_TOKEN"; - return { - channelId: "slack", - credentialId: bot ? "slackBotToken" : "slackAppToken", - sourceInput: bot ? "botToken" : "appToken", - providerName, - providerEnvKey, - placeholder: `${bot ? "xoxb" : "xapp"}-OPENSHELL-RESOLVE-ENV-${providerEnvKey}`, - credentialAvailable: true, - credentialHash, - }; -} - -function slackPlan(): SandboxMessagingPlan { - return makeMessagingPlan({ - sandboxName: "alpha", - agent: "openclaw", - channels: ["slack"], - credentialBindings: [ - slackBinding( - "alpha-slack-bridge", - "SLACK_BOT_TOKEN", - hashCredential("xoxb-existing-slack-bot-token") ?? "", - ), - slackBinding( - "alpha-slack-app", - "SLACK_APP_TOKEN", - hashCredential("xapp-existing-slack-app-token") ?? "", - ), - ], - }); -} - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -describe("completed messaging checkpoint provider migration", () => { - it("normalizes legacy Slack bindings before gateway probes", async () => { - const currentPlan = slackPlan(); - const legacyPlan = { - ...currentPlan, - credentialBindings: currentPlan.credentialBindings.map((binding) => - binding.providerEnvKey === "SLACK_APP_TOKEN" - ? { ...binding, providerName: "alpha-slack-bridge" } - : binding, - ), - }; - const session = createSession({ sandboxName: "alpha", messagingPlan: legacyPlan }); - session.stagedCredentialProviders = ["alpha-slack-bridge", "alpha-slack-app"]; - session.checkpoint = { - ...deriveCheckpointFromSession(session), - messaging: decisionSelected({ selectedChannels: ["slack"], disabledChannels: [] }), - }; - const providerMatchesGatewayCredential = vi.fn(() => true); - const setupMessagingChannels = vi.fn(async () => ["slack"]); - vi.stubEnv("SLACK_BOT_TOKEN", ""); - vi.stubEnv("SLACK_APP_TOKEN", ""); - - const result = await reconcileSandboxMessaging({ - resume: true, - session, - sandboxName: "alpha", - agent: { name: "openclaw" }, - deps: { - note: vi.fn(), - showMessagingStage: vi.fn(), - getRecordedMessagingChannelsForResume: vi.fn(() => null), - setupMessagingChannels, - readMessagingPlanFromEnv: vi.fn(() => null), - writePlanToEnv: vi.fn(), - clearPlanEnv: vi.fn(), - getRegistrySandboxMessagingAuthority: vi.fn(() => ({ - authoritative: false, - plan: null, - })), - inspectGatewayCredential: vi.fn(() => ({ kind: "missing" as const })), - providerMatchesGatewayCredential, - }, - }); - - expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_BOT_TOKEN", - ); - expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-app", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(providerMatchesGatewayCredential).not.toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(setupMessagingChannels).not.toHaveBeenCalled(); - expect(result).toEqual({ plan: currentPlan, selectedChannels: ["slack"] }); - }); -}); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 33680ac362b..54e5efb34aa 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -358,7 +358,6 @@ describe("reconcileReusedSandboxMessaging", () => { it("keeps a bridge channel whose gateway credential outlived the onboarding process (#10660)", () => { const plan = googlechatPlan(); - // The pasted secret dies with its process, so a later rebuild sees empty env. vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); const inspectGatewayCredential = vi.fn(() => ({ kind: "exact" as const })); const note = vi.fn(); @@ -531,8 +530,7 @@ describe("reconcileReusedSandboxMessaging", () => { plan, ); - // The host environment holds no value that reports whether an in-sandbox - // QR channel is still paired, so reuse must keep it selected. + // QR pairing state lives in the sandbox, not the host environment. expect(result.selectedChannels).toEqual(["whatsapp"]); }); @@ -672,53 +670,61 @@ describe("reconcileSandboxMessaging plan authority", () => { expect(deps.note).not.toHaveBeenCalledWith(expect.stringContaining("disabling the channel")); }); - it("migrates legacy Slack bindings before start/rebuild gateway probes", async () => { - const currentPlan = { - ...slackPlan( - hashCredential("previous-slack-bot-token") ?? "", - hashCredential("previous-slack-app-token") ?? "", - ), - workflow: "start-channel" as const, - }; - const legacyPlan = { - ...currentPlan, - credentialBindings: currentPlan.credentialBindings.map((binding) => - binding.providerEnvKey === "SLACK_APP_TOKEN" - ? { ...binding, providerName: "alpha-slack-bridge" } - : binding, - ), - }; - const deps = registryDeps(legacyPlan); - deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); - vi.stubEnv("SLACK_BOT_TOKEN", ""); - vi.stubEnv("SLACK_APP_TOKEN", ""); - - const result = await reconcileSandboxMessaging({ - resume: false, - session: null, - sandboxName: "alpha", - agent: { name: "openclaw" }, - deps, - }); + it.each([false, true])( + "normalizes legacy Slack bindings before gateway probes (resume: %s)", + async (resume) => { + const currentPlan = { + ...slackPlan("previous-slack-bot-hash", "previous-slack-app-hash"), + workflow: "start-channel" as const, + }; + const legacyPlan = { + ...currentPlan, + credentialBindings: currentPlan.credentialBindings.map((binding) => + binding.providerEnvKey === "SLACK_APP_TOKEN" + ? { ...binding, providerName: "alpha-slack-bridge" } + : binding, + ), + }; + const deps = resume ? reconcileDeps([null]) : registryDeps(legacyPlan); + deps.inspectGatewayCredential.mockReturnValue({ kind: "exact" }); + deps.providerMatchesGatewayCredential.mockReturnValue(true); + const probe = resume ? deps.providerMatchesGatewayCredential : deps.inspectGatewayCredential; + vi.stubEnv("SLACK_BOT_TOKEN", ""); + vi.stubEnv("SLACK_APP_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume, + session: resume + ? withMessagingCheckpoint( + completedCheckpointSession(legacyPlan, ["alpha-slack-bridge", "alpha-slack-app"]), + ["slack"], + ) + : null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); - expect(result).toEqual({ plan: currentPlan, selectedChannels: ["slack"] }); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_BOT_TOKEN", - ); - expect(deps.inspectGatewayCredential).toHaveBeenCalledWith( - "alpha-slack-app", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(deps.inspectGatewayCredential).not.toHaveBeenCalledWith( - "alpha-slack-bridge", - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - "SLACK_APP_TOKEN", - ); - expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(currentPlan); - }); + expect(result).toEqual({ plan: currentPlan, selectedChannels: ["slack"] }); + expect(probe).toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_BOT_TOKEN", + ); + expect(probe).toHaveBeenCalledWith( + "alpha-slack-app", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(probe).not.toHaveBeenCalledWith( + "alpha-slack-bridge", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + "SLACK_APP_TOKEN", + ); + expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(currentPlan); + expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); + }, + ); it("uses the registry plan before a staged plan for an existing sandbox", async () => { const registryToken = "123456:registry-token"; @@ -937,9 +943,6 @@ describe("reconcileSandboxMessaging plan authority", () => { deps, }); - // A recorded selection is the previous run's choice, not the current host - // input; a channel the environment no longer configures must not re-enter - // the selection, or its egress preset is re-applied. expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); expect(deps.note).toHaveBeenCalledWith( expect.stringContaining("No host inputs configure discord"), diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts index 599b34201c0..59b158790d0 100644 --- a/test/e2e/live/channels-stop-start-helpers.ts +++ b/test/e2e/live/channels-stop-start-helpers.ts @@ -9,7 +9,6 @@ import type { AddSandboxChannelDependencies } from "../../../src/lib/actions/san import * as policyChannelDependenciesModule from "../../../src/lib/actions/sandbox/policy-channel-dependencies.ts"; import * as policyChannelModule from "../../../src/lib/actions/sandbox/policy-channel.ts"; import * as openshellRuntimeModule from "../../../src/lib/adapters/openshell/runtime.ts"; -import * as credentialProviderRegistrationModule from "../../../src/lib/onboard/credential-provider-registration.ts"; import * as messagingBridgeProviderModule from "../../../src/lib/onboard/messaging-bridge-provider.ts"; import * as legacyProvidersModule from "../../../src/lib/onboard/providers.ts"; import { clearStoppedSandboxStateRoots } from "../../../src/lib/sandbox/privileged-exec.ts"; @@ -57,8 +56,6 @@ type PolicyChannelModule = typeof import("../../../src/lib/actions/sandbox/polic type PolicyChannelDependenciesModule = typeof import("../../../src/lib/actions/sandbox/policy-channel-dependencies.ts"); type OpenshellRuntimeModule = typeof import("../../../src/lib/adapters/openshell/runtime.ts"); -type CredentialProviderRegistrationModule = - typeof import("../../../src/lib/onboard/credential-provider-registration.ts"); type MessagingBridgeProviderModule = typeof import("../../../src/lib/onboard/messaging-bridge-provider.ts"); type StatePathsModule = typeof import("../../../src/lib/state/paths.ts"); @@ -94,13 +91,6 @@ const messagingBridgeProvider = ( : messagingBridgeProviderModule ) as MessagingBridgeProviderModule; const { ensureMessagingBridgeProfiles } = messagingBridgeProvider; -const credentialProviderRegistration = ( - "default" in credentialProviderRegistrationModule - ? credentialProviderRegistrationModule.default - : credentialProviderRegistrationModule -) as CredentialProviderRegistrationModule; -const credentialProviderRegistrationDependencies = - credentialProviderRegistration.credentialProviderRegistrationDependencies as ProviderDependencies; const legacyProviderDependencies = ( "default" in legacyProvidersModule ? legacyProvidersModule.default : legacyProvidersModule ) as ProviderDependencies; @@ -234,12 +224,9 @@ export function installGooglechatCredentialFixture( const registered = new Set([...delegatedProviderNames, expectedName]); return tokenDefs.map(({ name }) => name).filter((name) => registered.has(name)); }; - const providerDependencies = - dependencies.providerDependencies ?? credentialProviderRegistrationDependencies; - const injectedProviderDependencies = dependencies.providerDependencies !== undefined; + const providerDependencies = dependencies.providerDependencies ?? legacyProviderDependencies; const effectiveLegacyProviderDependencies = - dependencies.legacyProviderDependencies ?? - (injectedProviderDependencies ? providerDependencies : legacyProviderDependencies); + dependencies.legacyProviderDependencies ?? providerDependencies; const root = dependencies.root ?? ROOT; const run = dependencies.run ?? runOpenshell; const originalRegistrationUpsert = providerDependencies.upsertMessagingProviders; diff --git a/test/e2e/support/channels-stop-start-googlechat.test.ts b/test/e2e/support/channels-stop-start-googlechat.test.ts index cac8b7bc15c..392ff8780a4 100644 --- a/test/e2e/support/channels-stop-start-googlechat.test.ts +++ b/test/e2e/support/channels-stop-start-googlechat.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { credentialProviderRegistrationDependencies } from "../../../src/lib/onboard/credential-provider-registration.ts"; +import * as legacyProvidersModule from "../../../src/lib/onboard/providers.ts"; import { addAndRebuildGooglechatForChannelsStopStartLiveE2e, GOOGLECHAT_E2E_ACCESS_TOKEN, @@ -12,6 +12,7 @@ import { } from "../live/channels-stop-start-helpers.ts"; type FixtureRunner = typeof import("../../../src/lib/adapters/openshell/runtime.ts").runOpenshell; +type LegacyProvidersModule = typeof import("../../../src/lib/onboard/providers.ts"); type FixtureProviderDependencies = { upsertMessagingProviders( tokenDefs: Parameters< @@ -37,6 +38,10 @@ type FixtureChannelDependencies = Pick< "runGatewayOpenshell" | "upsertMessagingProviders" >; +const legacyProviders = ( + "default" in legacyProvidersModule ? legacyProvidersModule.default : legacyProvidersModule +) as LegacyProvidersModule & FixtureProviderDependencies; + describe("channels stop/start Google Chat live composition", () => { it("intercepts the live policy-channel boundary before gateway refresh minting", () => { const sandboxName = "e2e-oc-ch-cycle"; @@ -91,7 +96,7 @@ describe("channels stop/start Google Chat live composition", () => { it("routes rebuild registration through the live fixture dependency and restores it", () => { const sandboxName = "e2e-oc-ch-cycle"; const expectedName = `${sandboxName}-googlechat-bridge`; - const originalUpsert = credentialProviderRegistrationDependencies.upsertMessagingProviders; + const originalUpsert = legacyProviders.upsertMessagingProviders; const runMock = vi.fn((args: string[]) => ({ status: args[1] === "get" ? 1 : 0, stdout: "", @@ -105,7 +110,7 @@ describe("channels stop/start Google Chat live composition", () => { }); try { expect( - credentialProviderRegistrationDependencies.upsertMessagingProviders( + legacyProviders.upsertMessagingProviders( [ { name: expectedName, @@ -123,9 +128,7 @@ describe("channels stop/start Google Chat live composition", () => { } finally { restore(); } - expect(credentialProviderRegistrationDependencies.upsertMessagingProviders).toBe( - originalUpsert, - ); + expect(legacyProviders.upsertMessagingProviders).toBe(originalUpsert); }); it("grants a process-local audience capability to the exact live sandbox", async () => { From e2e3c16f963b2cb0f685e76c7892d216c36e86c8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 08:52:27 -0700 Subject: [PATCH 17/17] test(policy): expect fatal OpenShell loss Signed-off-by: Prekshi Vyas --- test/runtime/policy/policies.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index 283d2c912a8..273c722fe93 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -631,8 +631,8 @@ exit 1 }) as never); try { - expect(policies.applyPreset("my-assistant", "npm")).toBe(false); - expect(exitSpy).not.toHaveBeenCalled(); + expect(() => policies.applyPreset("my-assistant", "npm")).toThrow(/__test_exit__/); + expect(exitSpy).toHaveBeenCalledWith(1); // No `nemoclaw-policy-*` temp dir should have been created before // the resolvability check exited. expect(