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 8685b31f10f..05db89ad09b 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,24 @@ 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. +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. -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 8279155474d..ef87afb5f05 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -892,8 +892,7 @@ const verifyDirectSandboxGpu = sandboxGpuPreflight.createDirectSandboxGpuVerifie redact, }); -const registeredCredentialProviders = - credentialProviderRegistration.createCredentialProviderRegistration({ +const registration = credentialProviderRegistration.createCredentialProviderRegistration({ root: ROOT, runOpenshell, getGatewayName: () => GATEWAY_NAME, @@ -902,9 +901,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, @@ -2520,7 +2518,7 @@ const sandboxCreateIntentResolver = sandboxCreateIntentResolution.createSandboxC const stageSandboxCredentialProviders = ( input: import("./onboard/credential-provider-registration").StageSandboxCredentialProvidersInput, ) => - registeredCredentialProviders.stageSandboxCredentialProviders( + registration.stageSandboxCredentialProviders( input, sandboxCreateIntentResolver.prepareCredentialProviders, ); @@ -3215,6 +3213,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..5f2e4b3f316 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,104 @@ describe("requiredMessagingProviderBindings", () => { }, ]); }); + + it("uses every credential and current provider identity for one active channel (#10660)", () => { + 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", + }, + ]); + 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", + }, + ]); + }); }); describe("planSandboxCreateReplay never opens a second sandbox (#5961)", () => { @@ -422,31 +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/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index 6c50775ee80..422249e795a 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"; @@ -139,17 +140,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 +167,67 @@ 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; +} + +/** Replace proven legacy provider names with the current manifest-owned names. */ +export function normalizeMessagingProviderBindings( + sandboxName: string, + plan: SandboxMessagingPlan, +): SandboxMessagingPlan { + const providerNamesByCredential = new Map( + listMessagingCredentialMetadata({ agent: plan.agent }).map((credential) => [ + `${credential.channelId}\0${credential.providerEnvKey}`, + 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); + } + 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, + 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 1ac19957754..bf2d29fdb0f 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"; @@ -94,107 +92,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(); - 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 }, { @@ -285,10 +182,77 @@ 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", + ]); + }); + + 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 () => { diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 165b75bccd6..7cc88553dc9 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -12,85 +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( - tokenDefs: MessagingTokenDef[], - 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[]; - }, -}; - export interface StageSandboxCredentialProvidersInput { sandboxName: string; enabledChannels: readonly string[]; @@ -259,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, @@ -277,21 +198,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 +306,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 5cf50806638..54e5efb34aa 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -6,16 +6,22 @@ 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 type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { wechatManifest } from "../../../messaging/channels/built-ins"; +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, decisionUnset } from "../../../state/onboard-checkpoint-decision"; -import { - CHECKPOINT_SCHEMA_VERSION, - type OnboardCheckpoint, -} from "../../../state/onboard-checkpoint-types"; +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, @@ -23,27 +29,106 @@ import { reconcileSandboxMessaging, } from "./sandbox-messaging"; -const channelIds = ["telegram", "unsupported"]; +const mixedChannelIds: MessagingChannelId[] = ["telegram", "unsupported"]; -function mixedChannelPlan(): SandboxMessagingPlan { +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 { - schemaVersion: 1, + 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: "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: [], - })), + 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"], - credentialBindings: channelIds.map((channelId) => ({ + }); + return { + ...plan, + credentialBindings: mixedChannelIds.map((channelId) => ({ channelId, credentialId: "token", sourceInput: "token", @@ -51,17 +136,18 @@ function mixedChannelPlan(): SandboxMessagingPlan { providerEnvKey: `${channelId.toUpperCase()}_TOKEN`, placeholder: `openshell:resolve:env:${channelId.toUpperCase()}_TOKEN`, credentialAvailable: true, + credentialHash: "", })), networkPolicy: { - presets: [...channelIds], - entries: channelIds.map((channelId) => ({ + presets: [...mixedChannelIds], + entries: mixedChannelIds.map((channelId) => ({ channelId, presetName: channelId, policyKeys: [`${channelId}_api`], source: "manifest", })), }, - agentRender: channelIds.map((channelId) => ({ + agentRender: mixedChannelIds.map((channelId) => ({ channelId, kind: "json-fragment", agent: "openclaw", @@ -70,7 +156,7 @@ function mixedChannelPlan(): SandboxMessagingPlan { value: { enabled: true }, templateRefs: [], })), - buildSteps: channelIds.map((channelId) => ({ + buildSteps: mixedChannelIds.map((channelId) => ({ channelId, kind: "build-arg", outputId: `${channelId}-arg`, @@ -78,32 +164,32 @@ function mixedChannelPlan(): SandboxMessagingPlan { value: "enabled", })), runtimeSetup: { - nodePreloads: channelIds.map((channelId) => ({ + nodePreloads: mixedChannelIds.map((channelId) => ({ channelId, module: `${channelId}-preload`, source: "manifest", target: "agent", })), - envAliases: channelIds.map((channelId) => ({ + envAliases: mixedChannelIds.map((channelId) => ({ channelId, envKey: `${channelId.toUpperCase()}_TOKEN`, match: "source", value: "target", })), - secretScans: channelIds.map((channelId) => ({ + secretScans: mixedChannelIds.map((channelId) => ({ channelId, path: `/sandbox/${channelId}`, pattern: "secret", message: "secret found", })), }, - stateUpdates: channelIds.map((channelId) => ({ + stateUpdates: mixedChannelIds.map((channelId) => ({ channelId, kind: "persist-inputs", stateKey: `${channelId}Config`, inputIds: ["token"], })), - healthChecks: channelIds.map((channelId) => ({ + healthChecks: mixedChannelIds.map((channelId) => ({ channelId, phase: "health-check", requiredBefore: "lifecycle-success", @@ -112,201 +198,11 @@ function mixedChannelPlan(): SandboxMessagingPlan { }; } -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 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, - ], - }; -} - -describe("hasMessagingCredentialDrift", () => { - const oldToken = "123456:old-telegram-token"; - const plan = telegramPlan(hashCredential(oldToken) ?? ""); - - it("detects only an explicitly supplied replacement credential", () => { - expect(hasMessagingCredentialDrift(plan, {})).toBe(false); - expect(hasMessagingCredentialDrift(plan, { TELEGRAM_BOT_TOKEN: oldToken })).toBe(false); - expect( - hasMessagingCredentialDrift(plan, { - TELEGRAM_BOT_TOKEN: "123456:new-telegram-token", - }), - ).toBe(true); - }); - - it("ignores replacement credentials for disabled channels", () => { - const disabledPlan = mixedChannelPlan(); - expect( - hasMessagingCredentialDrift(disabledPlan, { - UNSUPPORTED_TOKEN: "replacement-disabled-channel-token", - }), - ).toBe(false); - }); -}); function completedCheckpointSession( plan: SandboxMessagingPlan, stagedCredentialProviders: string[] = [], ) { - const session = createSession(); - session.sandboxName = plan.sandboxName; - session.messagingPlan = plan; + const session = createSession({ sandboxName: plan.sandboxName, messagingPlan: plan }); session.stagedCredentialProviders = stagedCredentialProviders; session.sandboxPromptProgress.sandboxName = true; session.sandboxPromptProgress.messaging = true; @@ -317,24 +213,11 @@ 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(), +) { + session.checkpoint = { + ...deriveCheckpointFromSession(session), messaging: decisionSelected({ selectedChannels, disabledChannels }), - resourceProfile: decisionUnset(), - gatewayAuthority: decisionUnset(), - effectGroups: {}, - bindings: { credentialEnvs: [], registeredProviders: [] }, - sandboxRecreate: null, }; - session.checkpoint = checkpoint; return session; } @@ -357,14 +240,52 @@ function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { .mockReturnValue(plans[1] ?? plans[0] ?? null), writePlanToEnv: vi.fn(), clearPlanEnv: vi.fn(), - getRegistrySandboxMessagingAuthority: vi.fn((): RegistryMessagingAuthority => ({ + 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"; + const plan = telegramPlan(hashCredential(oldToken) ?? ""); + + it("detects only an explicitly supplied replacement credential", () => { + expect(hasMessagingCredentialDrift(plan, {})).toBe(false); + expect(hasMessagingCredentialDrift(plan, { TELEGRAM_BOT_TOKEN: oldToken })).toBe(false); + expect( + hasMessagingCredentialDrift(plan, { + TELEGRAM_BOT_TOKEN: "123456:new-telegram-token", + }), + ).toBe(true); + }); + + it("ignores replacement credentials for disabled channels", () => { + const disabledPlan = mixedChannelPlan(); + expect( + hasMessagingCredentialDrift(disabledPlan, { + UNSUPPORTED_TOKEN: "replacement-disabled-channel-token", + }), + ).toBe(false); + }); +}); beforeEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); @@ -384,7 +305,12 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv, note: vi.fn(), writePlanToEnv: vi.fn() }, + { + clearPlanEnv, + inspectGatewayCredential: () => ({ kind: "exact" }), + note: vi.fn(), + writePlanToEnv: vi.fn(), + }, plan, ); @@ -392,40 +318,199 @@ 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(), note: vi.fn(), writePlanToEnv: vi.fn() }; + const deps = { + clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "missing" as const }), + note: vi.fn(), + writePlanToEnv: vi.fn(), + }; 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, + ); + expect(deps.writePlanToEnv).not.toHaveBeenCalled(); + expect(deps.clearPlanEnv).not.toHaveBeenCalled(); + }); + + it("keeps a still-configured channel in a reused sandbox selection (#9283)", () => { + const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); + vi.stubEnv("DISCORD_BOT_TOKEN", "123456:live-discord-token"); + const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - deps, + { + clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "exact" }), + note: vi.fn(), + writePlanToEnv: vi.fn(), + }, 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(deps.clearPlanEnv).not.toHaveBeenCalled(); + expect(result.selectedChannels).toEqual(["discord"]); }); - it("keeps a still-configured channel in a reused sandbox selection (#9283)", () => { + it("keeps a bridge channel whose gateway credential outlived the onboarding process (#10660)", () => { + const plan = googlechatPlan(); + vi.stubEnv("GOOGLECHAT_SERVICE_ACCOUNT", ""); + const inspectGatewayCredential = vi.fn(() => ({ kind: "exact" as const })); + const note = vi.fn(); + + const result = reconcileReusedSandboxMessaging( + structuredClone(plan), + { name: "openclaw" }, + { 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(inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-googlechat-bridge", + "google-chat-bridge", + "GOOGLE_CHAT_ACCESS_TOKEN", + ); + }); + + 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(); + + 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)", () => { const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); - vi.stubEnv("DISCORD_BOT_TOKEN", "123456:live-discord-token"); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + const inspectGatewayCredential = vi.fn(() => ({ kind: "exact" as const })); const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv: vi.fn(), note: vi.fn(), writePlanToEnv: vi.fn() }, + { + clearPlanEnv: vi.fn(), + inspectGatewayCredential, + note: vi.fn(), + writePlanToEnv: vi.fn(), + }, plan, ); - expect(result.selectedChannels).toEqual(["discord"]); + expect(result).toEqual({ plan, selectedChannels: ["discord"], changed: false }); + expect(inspectGatewayCredential).toHaveBeenCalledWith( + "alpha-discord-bridge", + expect.any(String), + "DISCORD_BOT_TOKEN", + ); + }); + + it.each([ + ["app-token", "SLACK_APP_TOKEN"], + ["bot-token", "SLACK_BOT_TOKEN"], + ] 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") ?? "", + ); + 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 === missing ? ({ kind: "missing" } as const) : ({ kind: "exact" } as const), + ); + + 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); + }); + + 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)", () => { @@ -436,12 +521,16 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv: vi.fn(), note: vi.fn(), writePlanToEnv: vi.fn() }, + { + clearPlanEnv: vi.fn(), + inspectGatewayCredential: () => ({ kind: "missing" }), + note: vi.fn(), + writePlanToEnv: vi.fn(), + }, 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"]); }); @@ -450,7 +539,12 @@ describe("reconcileReusedSandboxMessaging", () => { const result = reconcileReusedSandboxMessaging( mixedChannelPlan(), { name: "openclaw" }, - { clearPlanEnv() {}, note() {}, writePlanToEnv() {} }, + { + clearPlanEnv() {}, + inspectGatewayCredential: () => ({ kind: "exact" }), + note() {}, + writePlanToEnv() {}, + }, ); const filtered = result.plan; @@ -485,28 +579,153 @@ describe("reconcileReusedSandboxMessaging", () => { healthChecks: ["telegram"], }); }); +}); - 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", ""); +describe("reconcileSandboxMessaging plan authority", () => { + it("validates a changed lifecycle credential before persisting its hash", async () => { + const previousToken = "previous-telegram-token"; + const plan = { + ...telegramPlan(hashCredential(previousToken) ?? ""), + workflow: "start-channel" as const, + }; + const deps = registryDeps(plan); + deps.setupMessagingChannels.mockRejectedValue(new Error("invalid Telegram token")); + vi.stubEnv("TELEGRAM_BOT_TOKEN", previousToken); - const result = reconcileReusedSandboxMessaging( - plan, - { name: "openclaw" }, + 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(deps.setupMessagingChannels).toHaveBeenCalledOnce(); + expect(deps.writePlanToEnv).toHaveBeenCalledWith(plan); + expect(plan.credentialBindings[0]?.credentialHash).toBe(hashCredential(previousToken)); + }); + + 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, - structuredClone(plan), - ); - const disabledPlan = withChannelDisabled(plan, "discord"); + }); - 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")); + 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")); }); -}); -describe("reconcileSandboxMessaging plan authority", () => { + 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(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"; const registryPlan = telegramPlan(hashCredential(registryToken) ?? ""); @@ -556,6 +775,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 reused %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", ""); + 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([]); @@ -693,11 +943,10 @@ 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")); + 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: [] }); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index d179382d723..45730f48c1d 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -20,6 +20,11 @@ import { import { hashCredential } from "../../../security/credential-hash"; import { isDecisionSelected, isDecisionUnset } from "../../../state/onboard-checkpoint-decision"; import type { Session } from "../../../state/onboard-session"; +import { + normalizeMessagingProviderBindings, + requiredMessagingProviderBindings, +} from "../../checkpoint-replay"; +import type { GatewayCredentialOnlyProviderInspection } from "../../gateway-provider-metadata"; import { detectMessagingChannelsFromEnv, detectUnconfiguredMessagingChannels, @@ -61,6 +66,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; } @@ -119,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 }; @@ -205,29 +218,120 @@ export function filterMessagingPlanForCurrentAgent( }; } +interface PreparedReusablePlan extends SandboxMessagingSelection { + readonly changed: boolean; +} + +function prepareReusablePlan( + plan: SandboxMessagingPlan, + agent: Agent, + env: NodeJS.ProcessEnv, +): PreparedReusablePlan { + const refreshed = refreshCredentialHashesFromEnv(plan, env); + 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 || normalized !== refreshed.plan || filtered !== normalized, + }; +} + function selectionFromReusablePlan( plan: SandboxMessagingPlan, agent: Agent, writeToEnv: boolean, + env: NodeJS.ProcessEnv, deps: SandboxMessagingDeps, ): SandboxMessagingSelection { - const refreshed = refreshCredentialHashesFromEnv(plan); - const filtered = filterMessagingPlanForCurrentAgent(refreshed.plan, agent); - if (!filtered) { + const prepared = prepareReusablePlan(plan, agent, env); + if (!prepared.plan) { deps.clearPlanEnv(); return { plan: null, selectedChannels: [] }; } - if (writeToEnv || refreshed.changed || filtered !== refreshed.plan) deps.writePlanToEnv(filtered); - return { - plan: filtered, - selectedChannels: getActiveChannelsFromPlan(filtered), - }; + 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. + * - Same match the create intent uses to reuse a provider without its secret. + */ +function channelCredentialLivesAtGateway( + plan: SandboxMessagingPlan, + channelId: string, + deps: Pick, "inspectGatewayCredential">, +): boolean { + const providerBindings = requiredMessagingProviderBindings( + plan.sandboxName, + plan, + new Set([channelId]), + ); + if (providerBindings.length === 0) return false; + 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 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">, +): void { + if (plan) deps.writePlanToEnv(plan); + else deps.clearPlanEnv(); } function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, - deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, + deps: Pick< + SandboxMessagingDeps, + "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 @@ -240,13 +344,35 @@ 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) { + 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") { + 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.`, ); 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( @@ -375,6 +501,26 @@ async function selectionFromMessagingSetup( ); } +/** Probe gateway state before persisting a reusable plan. */ +function selectionFromReconciledReusablePlan( + plan: SandboxMessagingPlan, + agent: Agent, + writeToEnv: boolean, + env: NodeJS.ProcessEnv, + deps: Pick< + SandboxMessagingDeps, + "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" + >, +): SandboxMessagingSelection { + 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) { + persistMessagingPlan(reconciled.plan, deps); + } + return reconciled; +} + /** Reconcile checkpoint channels against current host inputs before reuse. */ function selectionFromRecordedChannels( recordedChannels: string[], @@ -386,10 +532,31 @@ 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.env as NodeJS.ProcessEnv, + options.deps, + ); + } else { + if (envPlan) { + selection = selectionFromReconciledReusablePlan( + envPlan, + options.agent, + false, + options.env as NodeJS.ProcessEnv, + 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(", ")}`, @@ -422,37 +589,21 @@ 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), + return selectionFromReconciledReusablePlan( + registryPlan, options.agent, - options.deps, - ); - } - const activeChannels = filterChannelNamesForCurrentAgent( - getActiveChannelsFromPlan(registryPlan), - options.agent, - ); - const credentialDriftChannels = messagingChannelsWithCredentialDrift( - registryPlan, - options.env ?? process.env, - 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, + options.env as NodeJS.ProcessEnv, + options.deps, ); } const detectedChannels = channelsForRegistryPlanRefresh(registryPlan, options.agent); if (!detectedChannels) { - return filterUnconfiguredHostChannelsFromSelection( - selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), + return selectionFromReconciledReusablePlan( + registryPlan, options.agent, + true, + options.env as NodeJS.ProcessEnv, options.deps, ); } @@ -470,7 +621,10 @@ async function selectionFromRegistryPlan( export function reconcileReusedSandboxMessaging( plan: SandboxMessagingPlan | null, agent: Agent, - deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, + deps: Pick< + SandboxMessagingDeps, + "clearPlanEnv" | "inspectGatewayCredential" | "note" | "writePlanToEnv" + >, recordedPlan: SandboxMessagingPlan | null = plan, ): SandboxMessagingSelection & { readonly changed: boolean } { const filtered = plan ? filterMessagingPlanForCurrentAgent(plan, agent) : null; @@ -478,6 +632,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(); @@ -591,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?.(); @@ -604,6 +764,7 @@ async function selectionFromCompletedMessagingCheckpoint( validationPlan, options.agent, envPlan !== validationPlan, + options.env as NodeJS.ProcessEnv, options.deps, ); options.deps.showMessagingStage?.(); @@ -632,6 +793,7 @@ async function selectionFromCompletedMessagingCheckpoint( validationPlan, options.agent, envPlan !== validationPlan, + options.env as NodeJS.ProcessEnv, options.deps, ); options.deps.showMessagingStage?.(); @@ -641,6 +803,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, @@ -650,17 +829,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(); @@ -681,18 +850,50 @@ 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); } +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 { @@ -719,50 +920,65 @@ 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 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; 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.test.ts b/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts index 1459d7f557d..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,12 +1,18 @@ // 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"; 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,51 @@ 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"], + afterEach(() => { + vi.unstubAllEnvs(); + }); + + 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 +79,111 @@ 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); + }); + + 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/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.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({ 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; diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts index e484b00b26f..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,11 +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 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"); @@ -97,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; @@ -237,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; @@ -323,21 +307,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..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"; @@ -88,10 +93,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 = 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, @@ -122,8 +127,8 @@ describe("channels stop/start Google Chat live composition", () => { expect(runMock.mock.calls.some(([args]) => args.includes("refresh"))).toBe(false); } finally { restore(); - vi.unstubAllEnvs(); } + expect(legacyProviders.upsertMessagingProviders).toBe(originalUpsert); }); it("grants a process-local audience capability to the exact live sandbox", async () => { 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(