diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index 1364785211e..63ff814f014 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -291,6 +291,16 @@ describe("initial sandbox policy real preset merge", () => { expect(discordBinaries).toContain("/opt/hermes/.venv/bin/python"); expect(discordBinaries).not.toContain("/usr/bin/node"); + const boundProviders = + policy.network_policies?.discord?.endpoints + ?.map((endpoint) => endpoint.credential_binding?.provider) + .filter(Boolean) ?? []; + expect(boundProviders).toEqual([ + "hermes-channel-discord-bridge", + "hermes-channel-discord-bridge", + "hermes-channel-discord-bridge", + ]); + const discordRules = policy.network_policies?.discord?.endpoints ?.find((endpoint) => endpoint.host === "discord.com") diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index ce6284cb32c..301999cd976 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -23,6 +23,11 @@ export interface MessagingTokenDef { providerType?: string; } +type MessagingCredentialDef = MessagingTokenDef & { + /** The stopped-channel policy still references this static provider. */ + retainWhileDisabled: boolean; +}; + export interface CreateSandboxMessagingPrepInput { sandboxName: string; agentName?: string | null; @@ -82,20 +87,25 @@ export function prepareCreateSandboxMessaging( ); const messagingProviderProfiles = messagingBridgeProfilesForAgent(input.agentName); - const messagingTokenDefs: MessagingTokenDef[] = listMessagingCredentialMetadata() - .map((credential) => ({ - name: credential.providerNameTemplate.replaceAll("{sandboxName}", input.sandboxName), - envKey: credential.providerEnvKey, - token: input.getValidatedMessagingTokenByEnvKey(input.channels, credential.providerEnvKey), - providerType: - staticMessagingProviderTypeForChannel( - credential.channelId, - input.agentName, - messagingProviderProfiles, - ) ?? MESSAGING_CREDENTIAL_PROVIDER_TYPE, - })) - .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)) - .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); + const messagingCredentialDefs: MessagingCredentialDef[] = listMessagingCredentialMetadata() + .map((credential) => { + const staticProviderType = staticMessagingProviderTypeForChannel( + credential.channelId, + input.agentName, + messagingProviderProfiles, + ); + return { + name: credential.providerNameTemplate.replaceAll("{sandboxName}", input.sandboxName), + envKey: credential.providerEnvKey, + token: input.getValidatedMessagingTokenByEnvKey(input.channels, credential.providerEnvKey), + providerType: staticProviderType ?? MESSAGING_CREDENTIAL_PROVIDER_TYPE, + retainWhileDisabled: staticProviderType !== null, + }; + }) + .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)); + const messagingTokenDefs: MessagingTokenDef[] = messagingCredentialDefs + .filter(({ envKey }) => !disabledEnvKeys.has(envKey)) + .map(({ retainWhileDisabled: _retainWhileDisabled, ...definition }) => definition); const webSearchEnabled = braveProviderProfile.shouldEnableWebSearch(input.webSearchConfig); const webSearchProvider = webSearch.webSearchProviderForConfig(input.webSearchConfig); @@ -178,10 +188,22 @@ export function prepareCreateSandboxMessaging( const reusableMessagingChannels: string[] = []; if (input.enabledChannels != null) { - for (const { name, envKey, token, providerType } of messagingTokenDefs) { - if (token) continue; + for (const { + name, + envKey, + token, + providerType, + retainWhileDisabled, + } of messagingCredentialDefs) { const channel = input.getMessagingChannelForEnvKey(envKey); if (!channel || !input.enabledChannels.includes(channel)) continue; + const channelDisabled = disabledChannelNames.has(channel); + if (channelDisabled && !retainWhileDisabled) continue; + // Disabled definitions are intentionally absent from messagingTokenDefs, + // so even a still-readable source token cannot recreate their provider. + // A static credential-bound policy must instead retain the exact gateway + // provider already holding that authority. + if (token && !channelDisabled) continue; const providerReusable = providerType ? input.providerMatchesGatewayCredential(name, providerType, envKey) : requiresExactOpenClawProviderBinding @@ -189,7 +211,7 @@ export function prepareCreateSandboxMessaging( : input.providerExistsInGateway(name); if (!providerReusable) continue; reusableMessagingProviders.push(name); - if (!reusableMessagingChannels.includes(channel)) { + if (!channelDisabled && !reusableMessagingChannels.includes(channel)) { reusableMessagingChannels.push(channel); } } diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index fe736607334..44d4bba1eb5 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -23,6 +23,12 @@ const selectedSandboxGpuConfig: SandboxGpuCreateConfig = { sandboxGpuDevice: "nvidia.com/gpu=0", }; +const disabledSandboxGpuConfig: SandboxGpuCreateConfig = { + sandboxGpuEnabled: false, + sandboxGpuDevice: null, + hostGpuDetected: false, +}; + afterEach(() => { vi.unstubAllEnvs(); }); @@ -237,6 +243,51 @@ describe("resolveSandboxCreateIntent", () => { expect(JSON.stringify(first)).not.toContain("/tmp/"); }); + it("attaches a retained static provider while its channel runtime is stopped (#9773)", () => { + const intent = resolveSandboxCreateIntent({ + basePolicyPath: "/repo/hermes-policy.yaml", + sandboxName: "sandbox", + channels, + enabledChannels: ["discord"], + disabledChannelNames: new Set(["discord"]), + // Disabled credential definitions are not provider requests: onboard must + // not create or update their gateway providers during the rebuild. + messagingProviderRequests: [], + primaryMessagingCredentialEnvKeys: ["DISCORD_BOT_TOKEN"], + reusableMessagingChannels: [], + reusableMessagingProviders: ["sandbox-discord-bridge"], + extraProviders: [], + hermesToolGateways: [], + sandboxGpuConfig: disabledSandboxGpuConfig, + gpuCreateArgs: [], + gpuRoutePlan: "none", + sandboxGpuLogMessage: null, + agentName: "hermes", + policyTier: "balanced", + }); + const upsertMessagingProviders = vi.fn(() => []); + + const plan = materializeSandboxCreatePlan({ + intent, + fromRef: "/tmp/Dockerfile", + messagingTokenDefs: [], + runProviderPreDeleteCleanup: vi.fn(), + upsertMessagingProviders, + getHermesToolGatewayProviderName: vi.fn(), + prepareInitialSandboxCreatePolicy: vi.fn(() => ({ + policyPath: "/tmp/policy.yaml", + appliedPresets: [], + })), + }); + + expect(upsertMessagingProviders).toHaveBeenCalledWith([], { + replaceExisting: true, + allowedSandboxes: ["sandbox"], + }); + expect(plan.messagingProviders).toEqual(["sandbox-discord-bridge"]); + expect(plan.createArgs).toContain("sandbox-discord-bridge"); + }); + it("keeps the real gateway provider while excluding direct host-local inference policy", () => { const intent = resolveSandboxCreateIntent({ basePolicyPath: "/repo/policy.yaml", diff --git a/test/cli-coverage-sequencer.test.ts b/test/cli-coverage-sequencer.test.ts index c7d864235f1..a89dbdcb1ef 100644 --- a/test/cli-coverage-sequencer.test.ts +++ b/test/cli-coverage-sequencer.test.ts @@ -117,9 +117,9 @@ describe("stable CLI coverage sharding", () => { expect(Object.fromEntries(owners)).toEqual({ "cli:src/lib/example.test.ts": 6, "e2e-support:test/e2e/support/example.test.ts": 8, - "integration:test/hermes-restart-config-seal-write-lock.test.ts": 6, - "integration:test/local-credential-helper-fields.test.ts": 7, - "integration:test/regular-0.test.ts": 6, + "integration:test/hermes-restart-config-seal-write-lock.test.ts": 8, + "integration:test/local-credential-helper-fields.test.ts": 3, + "integration:test/regular-0.test.ts": 8, }); }); diff --git a/test/e2e/e2e-cloud-experimental/check-docs.sh b/test/e2e/e2e-cloud-experimental/check-docs.sh index 197abb399e8..78ba340e49d 100755 --- a/test/e2e/e2e-cloud-experimental/check-docs.sh +++ b/test/e2e/e2e-cloud-experimental/check-docs.sh @@ -353,9 +353,23 @@ JSON _doc_flags="$( printf '%s\n' "$_section" \ | LC_ALL=C perl -CS -ne ' - if (/^```/) { $in_fence = !$in_fence; next; } + sub emit_flags { + my ($line) = @_; + while ($line =~ /--([a-z][a-z0-9-]+)/g) { print "--$1\n"; } + } + + if (/^```/) { + $in_fence = !$in_fence; + $in_nemoclaw_command = 0; + next; + } if ($in_fence) { - while (/--([a-z][a-z0-9-]+)/g) { print "--$1\n"; } + # Ignore flags belonging to shell tools shown alongside the + # CLI, while preserving flags on multiline NemoClaw examples. + if ($in_nemoclaw_command || /(?:^|\s)(?:\$\$)?nemoclaw(?:\s|$)/) { + emit_flags($_); + $in_nemoclaw_command = /\\\s*$/ ? 1 : 0; + } } else { while (/`--([a-z][a-z0-9-]+)/g) { print "--$1\n"; } } diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 8976095e791..388635c0cf0 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; +import { loadAgent } from "../../../src/lib/agent/defs"; import { shellQuote } from "../../../src/lib/core/shell-quote"; import { readSandboxBaseImageResolutionMetadata } from "../../../src/lib/sandbox-base-image"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; @@ -14,6 +15,7 @@ import { assertExitZero as expectExitZero } from "../fixtures/clients/command.ts import { type HostCliClient, resultText } from "../fixtures/clients/index.ts"; import { validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; +import { expectSandboxProviderAttachment } from "../fixtures/gateway-providers.ts"; import { readJsonFileOr, restoreFile, @@ -79,7 +81,6 @@ process.env.NEMOCLAW_CLI_BIN ??= CLI_ENTRYPOINT; // local NemoClaw registry/session state, and `nemoclaw rebuild --yes`. // Literal interactive issue #3025 reproduction paths (`hermes rebuild`, modal // prompt, and `Y` confirmation) remain outside this Vitest migration. -const HERMES_MANIFEST = path.join(REPO_ROOT, "agents", "hermes", "manifest.yaml"); const OLD_HERMES_VERSION = `v${REBUILD_HERMES_OLD_BASE_FIXTURE.hermesCalver}`; const OLD_HERMES_REGISTRY_VERSION = OLD_HERMES_VERSION.slice(1); const STALE_BASE_REBUILD = process.env.NEMOCLAW_HERMES_STALE_BASE_REBUILD_E2E === "1"; @@ -278,21 +279,10 @@ function fail(message: string): never { } function expectedHermesVersion(): string { - const manifest = fs.readFileSync(HERMES_MANIFEST, "utf8"); - const match = manifest.match(/^expected_version:\s*"?([^"\n]+)"?/m); - expect(match?.[1], `Could not parse expected Hermes version from ${HERMES_MANIFEST}`).toEqual( - expect.any(String), + return ( + loadAgent("hermes").expectedVersion ?? + fail("Hermes manifest must declare expected_version for live rebuild coverage") ); - return match![1].trim(); -} - -function expectEqual(actual: string | undefined, expected: string, message: string): void { - switch (actual === expected) { - case true: - return; - default: - throw new Error(message); - } } async function bestEffortPrecleanHermesResources( @@ -1225,6 +1215,16 @@ test(STALE_BASE_REBUILD /Hermes gateway (?:restarted and verified|recovered) after state restore/u, ); await waitForSandboxReady(host, apiKey, activeOpenshellBin, "phase-6-post-rebuild"); + await expectSandboxProviderAttachment( + sandbox, + SANDBOX_NAME, + `${SANDBOX_NAME}-discord-bridge`, + "present", + { + artifactName: "phase-6-post-rebuild-provider-attachments", + env: testEnv(apiKey), + }, + ); const backupPathText = rebuildOutput.match(/^\s*Backup:\s+(.+)$/mu)?.[1]?.trim(); const rebuildBackupPath = backupPathText @@ -1301,11 +1301,10 @@ test(STALE_BASE_REBUILD expectExitZero(hermesVersion, "Hermes version after rebuild"); const hermesVersionText = resultText(hermesVersion); const actualHermesVersion = hermesVersionText.match(/v(\d+\.\d+\.\d+)/)?.[1]; - expectEqual( + expect( actualHermesVersion, - expectedVersion, `Hermes version output did not include expected release ${expectedVersion}: ${hermesVersionText}`, - ); + ).toBe(expectedVersion); await cronRestore.verify(rebuildOutput, rebuildBackupPath); await cronRestore.verifyStrandedGateRecovery(); const restoredKanbanDatabase = await host.command( diff --git a/test/helpers/cli-coverage-sequencer.ts b/test/helpers/cli-coverage-sequencer.ts index 1d979dc7751..a86830cdec9 100644 --- a/test/helpers/cli-coverage-sequencer.ts +++ b/test/helpers/cli-coverage-sequencer.ts @@ -42,7 +42,7 @@ const cliCoverageProjects = new Set(["cli", "integration", "e2e-support"]); // Integration coverage is serialized, so it needs an independent salt instead // of relying on combined weight from the parallel CLI and E2E-support lanes. const stableShardSalt = "7257"; -const integrationShardSalt = "28320"; +const integrationShardSalt = "6941"; const e2eSupportShardSalt = "13930"; // Only measured outliers are stored; new and ordinary files share the // conservative fallback used to estimate each stable shard's load. diff --git a/test/hermes-discord-credential-binding.test.ts b/test/hermes-discord-credential-binding.test.ts index 459d0786bc4..8e00988478d 100644 --- a/test/hermes-discord-credential-binding.test.ts +++ b/test/hermes-discord-credential-binding.test.ts @@ -18,6 +18,7 @@ const PROVIDER_TYPE = "discord-hermes-static-v1"; function prepareDiscord( token: string | null, providerMatchesGatewayCredential: () => boolean = () => false, + disabled = false, ) { const discord = listChannels().filter((channel) => channel.name === "discord"); return prepareCreateSandboxMessaging({ @@ -25,7 +26,7 @@ function prepareDiscord( agentName: "hermes", channels: discord, enabledChannels: ["discord"], - disabledChannels: [], + disabledChannels: disabled ? ["discord"] : [], webSearchConfig: null, env: token ? { DISCORD_BOT_TOKEN: token } : {}, getValidatedMessagingTokenByEnvKey: (_channels, envKey) => @@ -74,6 +75,23 @@ describe("Hermes Discord credential endpoint binding", () => { expect(providerMatches).toHaveBeenCalledWith(PROVIDER_NAME, PROVIDER_TYPE, "DISCORD_BOT_TOKEN"); }); + it.each([null, "test-discord-token"])( + "retains the exact Discord provider for a stopped channel with source token %s (#9773)", + (token) => { + const providerMatches = vi.fn(() => true); + const result = prepareDiscord(token, providerMatches, true); + + expect(result.messagingTokenDefs).toEqual([]); + expect(result.reusableMessagingProviders).toEqual([PROVIDER_NAME]); + expect(result.reusableMessagingChannels).toEqual([]); + expect(providerMatches).toHaveBeenCalledWith( + PROVIDER_NAME, + PROVIDER_TYPE, + "DISCORD_BOT_TOKEN", + ); + }, + ); + it("binds Discord REST and WebSocket rewrites to the sandbox provider", () => { const content = loadMessagingChannelPolicyPreset("discord", { agent: "hermes",