diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 8404b38029..b4432763cd 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -167,8 +167,12 @@ $ nemoclaw my-assistant channels add whatsapp It prompts for Telegram, Discord, and Slack tokens, runs an interactive host-side QR scan for WeChat, and collects nothing for WhatsApp because pairing happens in-sandbox after rebuild. It registers bridge providers with the OpenShell gateway when tokens were captured, records the channel in the sandbox registry, and asks whether to rebuild immediately. The command accepts mixed-case input such as `Telegram`, then stores and prints the canonical lowercase channel name. -If a matching built-in network policy preset exists, `channels add` applies it to the sandbox automatically before the rebuild so the bridge has egress to its upstream API. -If applying the preset fails, NemoClaw warns and tells you to re-apply manually with `nemoclaw policy-add ` after the rebuild. +`channels add` requires the matching built-in network policy preset YAML to be present. +A missing or malformed preset YAML (no `network_policies:` section) aborts the command before any token prompt, registry write, or rebuild prompt, so the sandbox never advertises a channel without a matching network policy. +With the preset file in place, `channels add` applies it to the sandbox before the rebuild so the bridge has egress to its upstream API. +When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. +When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. +Restore the preset YAML and re-run `nemoclaw channels add `. Choose the rebuild so the running sandbox image picks up the new channel. For Telegram, Discord, and Slack, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning. If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 55787f2ecc..0a26f6db96 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -723,7 +723,12 @@ Channels fall into three login modes: After registering the channel, NemoClaw asks whether to rebuild immediately. Running `add` for an already-configured channel simply overwrites the stored credentials where applicable — the operation is idempotent. Channel names are trimmed and lowercased before NemoClaw stores credentials, names bridge providers, or prints rebuild messages. -If a matching built-in network policy preset exists, NemoClaw applies it to the sandbox before the rebuild so the bridge has egress to its upstream API; if applying the preset fails, NemoClaw warns and tells you to re-apply manually with `nemoclaw policy-add `. +NemoClaw requires the matching built-in network policy preset YAML to be present. +A missing or malformed preset YAML (no `network_policies:` section) aborts `channels add` before any token prompt, registry write, or rebuild prompt. +With the preset file in place, NemoClaw applies it to the sandbox before the rebuild so the bridge has egress to its upstream API. +When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and any staged environment credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean ` warning so the operator can clean up manually. +When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry, restores staged environment credentials when available, restores registry credential hashes, and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel. +Restore the preset YAML and re-run `nemoclaw channels add `. For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings. ```console @@ -732,7 +737,7 @@ $ nemoclaw my-assistant channels add telegram | Flag | Description | |------|-------------| -| `--dry-run` | Validate the channel and token inputs without saving credentials or rebuilding | +| `--dry-run` | Validate the channel name and matching policy preset without prompting for credentials, contacting the gateway, or rebuilding | Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (app-level Socket Mode token); the command prompts for each in turn. Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index c72578b890..f8b11d4b42 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -382,7 +382,12 @@ async function applyChannelRemoveToGatewayAndRegistry( sandboxName: string, channelName: string, channelTokenKeys: string[], -): Promise { + options: { bestEffort?: boolean } = {}, +): Promise<{ ok: boolean; residual: string[] }> { + const bestEffort = Boolean(options.bestEffort); + const residual: string[] = []; + let gatewayReachable = true; + if (channelTokenKeys.length > 0) { const recovery = await recoverNamedGatewayRuntime(); if (!recovery.recovered) { @@ -392,7 +397,9 @@ async function applyChannelRemoveToGatewayAndRegistry( console.error( " Re-run after starting the gateway, or run 'openshell gateway start --name nemoclaw'.", ); - process.exit(1); + if (!bestEffort) process.exit(1); + gatewayReachable = false; + residual.push("gateway-providers"); } } @@ -405,28 +412,33 @@ async function applyChannelRemoveToGatewayAndRegistry( // previous run may have already detached, or the channel may have been // configured for a sandbox that is no longer alive. const detachFailures: Array<{ name: string; output: string }> = []; - for (const envKey of channelTokenKeys) { - const name = bridgeProviderName(sandboxName, channelName, envKey); - const result = runOpenshell(["sandbox", "provider", "detach", sandboxName, name], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status !== 0) { - const output = `${result.stdout || ""}${result.stderr || ""}`; - if (!/\bNotFound\b|not found|not attached/i.test(output)) { - detachFailures.push({ name, output: output.trim() }); + if (gatewayReachable) { + for (const envKey of channelTokenKeys) { + const name = bridgeProviderName(sandboxName, channelName, envKey); + const result = runOpenshell(["sandbox", "provider", "detach", sandboxName, name], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + const output = `${result.stdout || ""}${result.stderr || ""}`; + if (!/\bNotFound\b|not found|not attached/i.test(output)) { + detachFailures.push({ name, output: output.trim() }); + } } } - } - if (detachFailures.length > 0) { - console.error( - ` Failed to detach bridge provider(s) from sandbox '${sandboxName}': ${detachFailures.map((f) => f.name).join(", ")}.`, - ); - for (const f of detachFailures) { - console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`); + if (detachFailures.length > 0) { + console.error( + ` Failed to detach bridge provider(s) from sandbox '${sandboxName}': ${detachFailures.map((f) => f.name).join(", ")}.`, + ); + for (const f of detachFailures) { + console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`); + } + if (!bestEffort) { + console.error(" Registry not updated; re-run after resolving the gateway error."); + process.exit(1); + } + if (!residual.includes("gateway-providers")) residual.push("gateway-providers"); } - console.error(" Registry not updated; re-run after resolving the gateway error."); - process.exit(1); } // Capture each delete's outcome. If any non-NotFound failure surfaces @@ -436,30 +448,35 @@ async function applyChannelRemoveToGatewayAndRegistry( // can't easily recover. Surface the underlying openshell output so the // operator can see exactly why the delete was rejected. const deleteFailures: Array<{ name: string; output: string }> = []; - for (const envKey of channelTokenKeys) { - const name = bridgeProviderName(sandboxName, channelName, envKey); - const result = runOpenshell(["provider", "delete", name], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status !== 0) { - const output = `${result.stdout || ""}${result.stderr || ""}`; - // Treat "not found" as success-equivalent — a previous run may - // have already deleted the provider. - if (!/\bNotFound\b|not found/i.test(output)) { - deleteFailures.push({ name, output: output.trim() }); + if (gatewayReachable) { + const detachFailedSet = new Set(detachFailures.map((f) => f.name)); + for (const envKey of channelTokenKeys) { + const name = bridgeProviderName(sandboxName, channelName, envKey); + if (!bestEffort && detachFailedSet.has(name)) continue; + const result = runOpenshell(["provider", "delete", name], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + const output = `${result.stdout || ""}${result.stderr || ""}`; + if (!/\bNotFound\b|not found/i.test(output)) { + deleteFailures.push({ name, output: output.trim() }); + } } } - } - if (deleteFailures.length > 0) { - console.error( - ` Failed to delete bridge provider(s) from the OpenShell gateway: ${deleteFailures.map((f) => f.name).join(", ")}.`, - ); - for (const f of deleteFailures) { - console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`); + if (deleteFailures.length > 0) { + console.error( + ` Failed to delete bridge provider(s) from the OpenShell gateway: ${deleteFailures.map((f) => f.name).join(", ")}.`, + ); + for (const f of deleteFailures) { + console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`); + } + if (!bestEffort) { + console.error(" Registry not updated; re-run after resolving the gateway error."); + process.exit(1); + } + if (!residual.includes("gateway-providers")) residual.push("gateway-providers"); } - console.error(" Registry not updated; re-run after resolving the gateway error."); - process.exit(1); } const entry = registry.getSandbox(sandboxName); @@ -475,6 +492,8 @@ async function applyChannelRemoveToGatewayAndRegistry( Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, }); } + + return { ok: residual.length === 0, residual }; } async function promptAndRebuild(sandboxName: string, actionDesc: string): Promise { @@ -804,6 +823,21 @@ export async function addSandboxChannel( process.exit(1); } + const presetContent = policies.loadPreset(canonical); + const presetPolicyKeys = + presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent); + if (presetContent === null || presetPolicyKeys.length === 0) { + if (presetContent !== null && presetPolicyKeys.length === 0) { + console.error( + ` Preset YAML for channel '${canonical}' has no parseable entries under 'network_policies:'.`, + ); + } + console.error( + ` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${canonical}`, + ); + process.exit(1); + } + if (dryRun) { console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); return; @@ -833,6 +867,21 @@ export async function addSandboxChannel( return; } + const priorEntry = registry.getSandbox(sandboxName); + const priorMessagingChannels: string[] = priorEntry?.messagingChannels + ? [...priorEntry.messagingChannels] + : []; + const wasAlreadyEnabled = priorMessagingChannels.includes(canonical); + const priorHashes: Record = { + ...((priorEntry?.providerCredentialHashes as Record) || {}), + }; + const channelTokenKeys = getChannelTokenKeys(channel); + const priorCreds: Record = {}; + for (const key of channelTokenKeys) { + const existing = getCredential(key); + if (existing != null) priorCreds[key] = existing; + } + const acquired: Record = {}; if (channel.loginMethod === "host-qr") { await acquireHostQrChannel(sandboxName, canonical, channel, acquired); @@ -860,28 +909,96 @@ export async function addSandboxChannel( await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, acquired); console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`); - applyChannelPresetIfAvailable(sandboxName, canonical); + if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { + await rollbackChannelAdd(sandboxName, channel, canonical, { + wasAlreadyEnabled, + priorMessagingChannels, + priorHashes, + priorCreds, + }); + process.exit(1); + } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); } -// Must run before promptAndRebuild — the rebuild's backup manifest only -// captures presets already applied (#3437). Without this, channel bridges -// boot without egress to their upstream API after rebuild. -function applyChannelPresetIfAvailable(sandboxName: string, channelName: string): boolean { - const builtinPresets = new Set(policies.listPresets().map((p) => p.name)); - if (!builtinPresets.has(channelName)) { - return true; +async function rollbackChannelAdd( + sandboxName: string, + channel: ChannelDef, + canonical: string, + snapshot: { + wasAlreadyEnabled: boolean; + priorMessagingChannels: string[]; + priorHashes: Record; + priorCreds: Record; + }, +): Promise<{ ok: boolean; residual: string[] }> { + if (snapshot.wasAlreadyEnabled) { + console.error( + ` ${YW}⚠${R} Restoring prior '${canonical}' configuration; new token rotation aborted.`, + ); + registry.updateSandbox(sandboxName, { + messagingChannels: snapshot.priorMessagingChannels, + providerCredentialHashes: + Object.keys(snapshot.priorHashes).length > 0 ? snapshot.priorHashes : undefined, + }); + clearChannelTokens(channel); + if (Object.keys(snapshot.priorCreds).length > 0) { + persistChannelTokens(snapshot.priorCreds); + } + const residual: string[] = ["gateway-providers"]; + console.error( + ` ${YW}⚠${R} Rollback could not fully clean ${residual.join(", ")}; run '${CLI_NAME} ${sandboxName} channels remove ${canonical}' once the gateway is reachable.`, + ); + if (Object.keys(snapshot.priorCreds).length > 0) { + try { + const priorTokenDefs = Object.entries(snapshot.priorCreds).map(([envKey, token]) => ({ + name: bridgeProviderName(sandboxName, canonical, envKey), + envKey, + token, + })); + onboardProviders.upsertMessagingProviders(priorTokenDefs, runOpenshell, { + bestEffort: true, + }); + } catch (err) { + console.error( + ` ${YW}⚠${R} Failed to restore gateway providers for '${canonical}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return { ok: false, residual }; } + + console.error( + ` ${YW}⚠${R} Rolling back '${canonical}' bridge registration to keep messagingChannels and policy state aligned.`, + ); + clearChannelTokens(channel); + const result = await applyChannelRemoveToGatewayAndRegistry( + sandboxName, + canonical, + getChannelTokenKeys(channel), + { bestEffort: true }, + ); + if (!result.ok) { + console.error( + ` ${YW}⚠${R} Rollback could not fully clean ${result.residual.join(", ")}; run '${CLI_NAME} ${sandboxName} channels remove ${canonical}' once the gateway is reachable.`, + ); + } + return result; +} + +function applyChannelPresetIfAvailable(sandboxName: string, channelName: string): boolean { try { const applied = policies.applyPreset(sandboxName, channelName); if (!applied) { console.error( - ` ${YW}⚠${R} Channel '${channelName}' bridge registered but its policy preset failed to apply.`, + ` ${YW}⚠${R} Cannot enable channel '${channelName}': policy preset failed to apply.`, ); console.error( - ` Re-apply manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-add ${channelName}`, + ` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${channelName}`, ); return false; } @@ -891,7 +1008,7 @@ function applyChannelPresetIfAvailable(sandboxName: string, channelName: string) const msg = err instanceof Error ? err.message : String(err); console.error(` ${YW}⚠${R} Failed to apply '${channelName}' policy preset: ${msg}`); console.error( - ` Re-apply manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-add ${channelName}`, + ` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${channelName}`, ); return false; } diff --git a/src/lib/onboard/providers.test.ts b/src/lib/onboard/providers.test.ts index 1d52faab79..f07760270c 100644 --- a/src/lib/onboard/providers.test.ts +++ b/src/lib/onboard/providers.test.ts @@ -35,7 +35,7 @@ const { buildProviderArgs, providerExistsInGateway, upsertProvider, upsertMessag providerType?: string; }>, runOpenshell: RunOpenshell, - options?: { replaceExisting?: boolean }, + options?: { replaceExisting?: boolean; bestEffort?: boolean }, ) => string[]; }; @@ -228,6 +228,33 @@ describe("onboard provider helpers", () => { ]); }); + it("throws instead of exiting when best-effort messaging provider upsert fails", () => { + const originalExit = process.exit; + process.exit = ((code?: number | string | null) => { + throw new Error(`unexpected process.exit(${code ?? 0})`); + }) as typeof process.exit; + try { + expect(() => + upsertMessagingProviders( + [ + { + name: "telegram-bridge", + envKey: "TELEGRAM_BOT_TOKEN", + token: "tg-test", + }, + ], + (command) => { + if (command.includes("get")) return { status: 0, stdout: "", stderr: "" }; + return { status: 1, stdout: "", stderr: "gateway unavailable" }; + }, + { bestEffort: true }, + ), + ).toThrow(/telegram-bridge: gateway unavailable/); + } finally { + process.exit = originalExit; + } + }); + it("replaces existing providers when the caller opts in (post-sandbox-delete path)", () => { const commands: string[] = []; // replaceExisting: true is only safe after the sandbox holding the diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index 30f4192e40..7737c5cc90 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -319,19 +319,22 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell, /** * Upsert all messaging providers that have tokens configured. * Returns the list of provider names that were successfully created/updated. - * Exits the process if any upsert fails. + * Exits the process if any upsert fails unless `options.bestEffort` is true. * * Pass `options.replaceExisting` true only when every entry is guaranteed * detached from any live sandbox (post-sandbox-delete on the recreate path); * reuse paths must omit it because `provider delete` fails for attached - * providers. + * providers. Pass `options.bestEffort` only from rollback paths that must + * continue restoring registry state and report residual gateway work instead + * of terminating the CLI. * @param {Array<{name: string, envKey: string, token: string|null, providerType?: string}>} tokenDefs * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. - * @param {{replaceExisting?: boolean}} options - Forwarded to every upsertProvider call. + * @param {{replaceExisting?: boolean, bestEffort?: boolean}} options - Forwarded to every upsertProvider call. * @returns {string[]} Provider names that were upserted. */ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { const upserted = []; + const failures = []; for (const { name, envKey, token, providerType } of tokenDefs) { if (!token) continue; const result = upsertProvider( @@ -344,11 +347,18 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { { replaceExisting: Boolean(options.replaceExisting) }, ); if (!result.ok) { + if (options.bestEffort) { + failures.push(`${name}: ${result.message}`); + continue; + } console.error(`\n ✗ Failed to create messaging provider '${name}': ${result.message}`); process.exit(1); } upserted.push(name); } + if (failures.length > 0) { + throw new Error(failures.join("; ")); + } return upserted; } diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index eaf46cf9b2..75c4497f9b 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -1284,6 +1284,7 @@ export { listSetupPolicyPresets, clampSetupPolicyPresetNames, extractPresetEntries, + parsePresetPolicyKeys, parseCurrentPolicy, buildPolicySetCommand, buildPolicyGetCommand, diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index f1d26fe77f..2659bf3fec 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -56,6 +56,9 @@ function buildPreamble({ sessionLoadThrows = false, sessionUpdateThrows = false, sessionMissing = false, + presetFileMissing = false, + presetMissingNetworkPolicies = false, + presetMalformedYaml = false, }: { presetNamesAvailable?: string[]; applyPresetResult?: boolean; @@ -66,6 +69,9 @@ function buildPreamble({ sessionLoadThrows?: boolean; sessionUpdateThrows?: boolean; sessionMissing?: boolean; + presetFileMissing?: boolean; + presetMissingNetworkPolicies?: boolean; + presetMalformedYaml?: boolean; } = {}): string { const j = (p: string) => JSON.stringify(path.join(repoRoot, "dist", "lib", p)); return String.raw` @@ -87,14 +93,20 @@ const gatewayRuntime = require(${j("gateway-runtime-action.js")}); gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true }); const credentials = require(${j("credentials/store.js")}); +const savedCredentialKeys = []; +const deletedCredentialKeys = []; const credentialSaveCalls = []; credentials.getCredential = (key) => process.env[key] || null; credentials.saveCredential = (key, value) => { + savedCredentialKeys.push(key); credentialSaveCalls.push({ key, value }); callOrder.push("saveCredential:" + key); return true; }; -credentials.deleteCredential = () => true; +credentials.deleteCredential = (key) => { + deletedCredentialKeys.push(key); + return true; +}; credentials.prompt = async (msg) => { throw new Error("unexpected prompt: " + msg); }; const onboard = require(${j("onboard.js")}); @@ -126,6 +138,12 @@ const appliedCalls = []; const removedCalls = []; const callOrder = []; policies.listPresets = () => ${JSON.stringify(presetNamesAvailable.map((name) => ({ name })))}; +policies.loadPreset = (name) => { + if (${JSON.stringify(presetFileMissing)}) return null; + if (${JSON.stringify(presetMissingNetworkPolicies)}) return "name: " + name + "\ndescription: \"stub preset without network_policies\"\n"; + if (${JSON.stringify(presetMalformedYaml)}) return "network_policies:\n - [unclosed\n"; + return "network_policies:\n " + name + ":\n egress:\n - host: example.com"; +}; policies.applyPreset = (sandboxName, presetName) => { appliedCalls.push({ sandboxName, presetName }); callOrder.push("applyPreset:" + presetName); @@ -215,6 +233,8 @@ module.exports = { providerCalls, registryUpdates, sessionUpdates, + savedCredentialKeys, + deletedCredentialKeys, credentialSaveCalls, slackProbeCalls, getSessionState: () => sessionState, @@ -378,25 +398,353 @@ process.exit = (code) => { ); }); - // Negative: when the channel name does not match any built-in preset, - // the helper short-circuits via listPresets() and applyPreset is not - // invoked at all. This guards against a future channel name that happens - // to collide with no preset (or a typo) from spamming "Cannot load preset" - // errors out of policies.applyPreset. - it("skips applyPreset when no matching built-in preset exists", () => { - const script = `${buildPreamble({ presetNamesAvailable: ["npm", "github"] })} + it("aborts non-QR channel when policy preset YAML is missing", () => { + const script = `${buildPreamble({ presetFileMissing: true })} const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; (async () => { try { await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + exitCodes, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual( + payload.appliedCalls, + [], + `missing preset YAML must abort before applyPreset; got ${JSON.stringify(payload.appliedCalls)}`, + ); + assert.deepEqual( + payload.providerCalls, + [], + `missing preset YAML must not register host-side providers; got ${JSON.stringify(payload.providerCalls)}`, + ); + assert.deepEqual( + payload.registryUpdates, + [], + `missing preset YAML must not register telegram in messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `missing preset YAML must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + result.stderr.includes(`Restore the preset YAML and re-run: nemoclaw test-sb channels add telegram`), + `expected restore-and-re-run hint on stderr; got:\n${result.stderr}`, + ); + }); + + it("aborts non-QR channel when policy preset YAML has no network_policies section", () => { + const script = `${buildPreamble({ presetMissingNetworkPolicies: true })} +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + savedCredentialKeys: ctx.savedCredentialKeys, + deletedCredentialKeys: ctx.deletedCredentialKeys, + exitCodes, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.appliedCalls, []); + assert.deepEqual(payload.providerCalls, []); + assert.deepEqual(payload.registryUpdates, []); + assert.deepEqual(payload.savedCredentialKeys, []); + assert.deepEqual(payload.deletedCredentialKeys, []); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `invalid preset must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + result.stderr.includes("has no parseable entries under 'network_policies:'"), + `expected diagnostic about unparseable network_policies section; got:\n${result.stderr}`, + ); + assert.ok( + result.stderr.includes("Restore the preset YAML and re-run: nemoclaw test-sb channels add telegram"), + `expected restore-and-re-run hint on stderr; got:\n${result.stderr}`, + ); + }); + + it("aborts non-QR channel when policy preset YAML body is malformed", () => { + const script = `${buildPreamble({ presetMalformedYaml: true })} +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + savedCredentialKeys: ctx.savedCredentialKeys, + exitCodes, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.appliedCalls, []); + assert.deepEqual(payload.providerCalls, []); + assert.deepEqual(payload.registryUpdates, []); + assert.deepEqual(payload.savedCredentialKeys, []); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `malformed preset must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + result.stderr.includes("has no parseable entries under 'network_policies:'"), + `expected parse-failure diagnostic; got:\n${result.stderr}`, + ); + assert.ok( + result.stderr.includes("Restore the preset YAML and re-run: nemoclaw test-sb channels add telegram"), + `expected restore-and-re-run hint on stderr; got:\n${result.stderr}`, + ); + }); + + it("dry-run validates the channel preset and avoids gateway, registry, and rebuild side effects", () => { + const script = `${buildPreamble()} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram", dryRun: true }); process.stdout.write("\\n__RESULT__" + JSON.stringify({ appliedCalls: ctx.appliedCalls, callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + savedCredentialKeys: ctx.savedCredentialKeys, }) + "\\n"); } catch (err) { process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); } })(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.appliedCalls, []); + assert.deepEqual(payload.providerCalls, []); + assert.deepEqual(payload.registryUpdates, []); + assert.deepEqual(payload.savedCredentialKeys, []); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `dry-run must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + result.stdout.includes("--dry-run: would enable channel 'telegram' for 'test-sb'"), + `expected dry-run preview; got:\n${result.stdout}`, + ); + }); + + it("dry-run fails when the matching policy preset YAML is missing", () => { + const script = `${buildPreamble({ presetFileMissing: true })} +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram", dryRun: true }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + savedCredentialKeys: ctx.savedCredentialKeys, + exitCodes, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.appliedCalls, []); + assert.deepEqual(payload.providerCalls, []); + assert.deepEqual(payload.registryUpdates, []); + assert.deepEqual(payload.savedCredentialKeys, []); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `dry-run preset failure must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + result.stderr.includes("Restore the preset YAML and re-run: nemoclaw test-sb channels add telegram"), + `expected restore-and-re-run hint on stderr; got:\n${result.stderr}`, + ); + }); + + it("aborts QR-paired WhatsApp before registry write when its preset YAML is missing", () => { + const script = `${buildPreamble({ presetFileMissing: true })} +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "whatsapp" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + exitCodes, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.appliedCalls, []); + assert.deepEqual(payload.providerCalls, []); + assert.deepEqual( + payload.registryUpdates, + [], + `missing whatsapp.yaml must not flip messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `missing whatsapp preset must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + result.stderr.includes("Restore the preset YAML and re-run: nemoclaw test-sb channels add whatsapp"), + `expected restore-and-re-run hint on stderr; got:\n${result.stderr}`, + ); + }); + + it("rolls back providers, registry, and credentials when applyPreset fails after a successful loadPreset", () => { + const script = `${buildPreamble({ applyPresetResult: false })} +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + savedCredentialKeys: ctx.savedCredentialKeys, + deletedCredentialKeys: ctx.deletedCredentialKeys, + sessionUpdates: ctx.sessionUpdates, + exitCodes, + }) + "\\n"); +})(); `; const result = runScript(script); assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); @@ -405,16 +753,259 @@ const ctx = module.exports; const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + assert.deepEqual(payload.exitCodes, [1]); assert.deepEqual( payload.appliedCalls, + [{ sandboxName: "test-sb", presetName: "telegram" }], + `expected one failed applyPreset call; got ${JSON.stringify(payload.appliedCalls)}`, + ); + assert.ok( + payload.registryUpdates.length === 2, + `expected one add update and one rollback update; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.deepEqual(payload.registryUpdates[0].updates.messagingChannels, ["telegram"]); + assert.deepEqual(payload.registryUpdates[1].updates.messagingChannels, []); + assert.deepEqual( + payload.deletedCredentialKeys, + ["TELEGRAM_BOT_TOKEN"], + `expected rollback to clear persisted credentials; got ${JSON.stringify(payload.deletedCredentialKeys)}`, + ); + assert.deepEqual( + payload.sessionUpdates, [], - `expected applyPreset NOT to be called when no built-in preset matches; got ${JSON.stringify(payload.appliedCalls)}`, + `applyPreset returned false before syncSessionPolicyPresetsWithRegistry; session must stay untouched; got ${JSON.stringify(payload.sessionUpdates)}`, ); - // Rebuild should still be triggered — channel registration succeeded, - // only the preset path was skipped. assert.ok( - payload.callOrder.includes("promptAndRebuild"), - `expected promptAndRebuild to still run; got order: ${JSON.stringify(payload.callOrder)}`, + !payload.callOrder.includes("promptAndRebuild"), + `apply failure must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + }); + + it("completes rollback registry update and reports residual gateway state when openshell detach fails", () => { + const script = `${buildPreamble({ applyPresetResult: false })} +openshellRuntime.runOpenshell = (args) => { + if (Array.isArray(args) && args[0] === "sandbox" && args[1] === "provider" && args[2] === "detach") { + return { status: 1, stdout: "", stderr: "permission denied" }; + } + return { status: 0, stdout: "", stderr: "" }; +}; +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +const stderrChunks = []; +const originalConsoleError = console.error; +console.error = (...args) => { + stderrChunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ") + "\\n"); + originalConsoleError.apply(console, args); +}; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + console.error = originalConsoleError; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + deletedCredentialKeys: ctx.deletedCredentialKeys, + exitCodes, + stderrCombined: stderrChunks.join(""), + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.appliedCalls, [{ sandboxName: "test-sb", presetName: "telegram" }]); + assert.ok( + payload.registryUpdates.length === 2, + `expected registry add + rollback even when openshell detach fails; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.deepEqual(payload.registryUpdates[1].updates.messagingChannels, []); + assert.deepEqual( + payload.deletedCredentialKeys, + ["TELEGRAM_BOT_TOKEN"], + `expected local credentials cleared before gateway rollback; got ${JSON.stringify(payload.deletedCredentialKeys)}`, + ); + assert.ok( + payload.stderrCombined.includes("Rollback could not fully clean gateway-providers"), + `expected residual-state warning on stderr; got:\n${payload.stderrCombined}`, + ); + assert.ok( + payload.stderrCombined.includes(`'nemoclaw test-sb channels remove telegram'`), + `expected manual cleanup hint on stderr; got:\n${payload.stderrCombined}`, + ); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `rollback path must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + }); + + it("restores prior channel config when re-add applyPreset fails on an already-enabled channel", () => { + const script = `${buildPreamble({ applyPresetResult: false })} +registry.getSandbox = () => ({ + name: "test-sb", + agent: "openclaw", + messagingChannels: ["telegram"], + disabledChannels: [], + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "prior-hash" }, +}); +credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + deletedCredentialKeys: ctx.deletedCredentialKeys, + savedCredentialKeys: ctx.savedCredentialKeys, + exitCodes, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.appliedCalls, [{ sandboxName: "test-sb", presetName: "telegram" }]); + const lastRegistry = payload.registryUpdates[payload.registryUpdates.length - 1]; + assert.deepEqual( + lastRegistry.updates.messagingChannels, + ["telegram"], + `re-add failure must keep prior 'telegram' in messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.deepEqual( + lastRegistry.updates.providerCredentialHashes, + { TELEGRAM_BOT_TOKEN: "prior-hash" }, + `re-add failure must restore prior credential hashes; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.ok( + payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), + `re-add failure must restore prior credentials via saveCredential; got ${JSON.stringify(payload.savedCredentialKeys)}`, + ); + const upsertNames = (payload.providerCalls as Array<{ name: string }>).map((d) => d.name); + assert.ok( + upsertNames.length >= 2, + `expected initial and restorative upsertMessagingProviders calls; got ${JSON.stringify(payload.providerCalls)}`, + ); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `re-add failure must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + result.stderr.includes("Rollback could not fully clean gateway-providers"), + `expected residual-state warning on stderr; got:\n${result.stderr}`, + ); + }); + + it("restores prior registry state even when re-upsert during re-add rollback throws", () => { + const script = `${buildPreamble({ applyPresetResult: false })} +registry.getSandbox = () => ({ + name: "test-sb", + agent: "openclaw", + messagingChannels: ["telegram"], + disabledChannels: [], + providerCredentialHashes: { TELEGRAM_BOT_TOKEN: "prior-hash" }, +}); +credentials.getCredential = (key) => key === "TELEGRAM_BOT_TOKEN" ? "prior-telegram-token" : null; +let upsertCalls = 0; +onboardProviders.upsertMessagingProviders = (defs) => { + upsertCalls += 1; + providerCalls.push(...defs); + if (upsertCalls >= 2) throw new Error("simulated gateway upsert failure during restore"); +}; +const ctx = module.exports; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + registryUpdates: ctx.registryUpdates, + savedCredentialKeys: ctx.savedCredentialKeys, + exitCodes, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + const lastRegistry = payload.registryUpdates[payload.registryUpdates.length - 1]; + assert.deepEqual( + lastRegistry.updates.messagingChannels, + ["telegram"], + `registry restoration must precede gateway re-upsert so an upsert failure cannot orphan the channel; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.deepEqual( + lastRegistry.updates.providerCredentialHashes, + { TELEGRAM_BOT_TOKEN: "prior-hash" }, + `prior credential hashes must be restored before any gateway side effect; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.ok( + payload.savedCredentialKeys.includes("TELEGRAM_BOT_TOKEN"), + `re-add failure must restore staged environment credentials; got ${JSON.stringify(payload.savedCredentialKeys)}`, + ); + assert.ok( + result.stderr.includes("Failed to restore gateway providers for 'telegram'"), + `expected gateway-provider restoration warning on stderr; got:\n${result.stderr}`, + ); + assert.ok( + result.stderr.includes("Rollback could not fully clean gateway-providers"), + `expected residual-state warning on stderr; got:\n${result.stderr}`, ); }); @@ -1218,3 +1809,32 @@ global.__testLog = ""; ); }); }); + +describe("channel preset source-of-truth", () => { + it("every channel registered in KNOWN_CHANNELS ships a preset YAML that parsePresetPolicyKeys() accepts", () => { + const { knownChannelNames } = require(path.join(repoRoot, "dist", "lib", "sandbox", "channels.js")) as { + knownChannelNames: () => string[]; + }; + const { loadPreset, parsePresetPolicyKeys } = require(path.join(repoRoot, "dist", "lib", "policy", "index.js")) as { + loadPreset: (name: string) => string | null; + parsePresetPolicyKeys: (content: string | null | undefined) => string[]; + }; + const failures: string[] = []; + for (const name of knownChannelNames()) { + const content = loadPreset(name); + if (content === null) { + failures.push(`${name}: preset YAML not found on disk`); + continue; + } + const keys = parsePresetPolicyKeys(content); + if (keys.length === 0) { + failures.push(`${name}: parsePresetPolicyKeys returned no entries`); + } + } + assert.deepEqual( + failures, + [], + `every channel in KNOWN_CHANNELS must ship a parseable preset YAML; failures: ${failures.join("; ")}`, + ); + }); +});