From dd3d9cca2f4c03cc3d40b0856a22a2682eda6f25 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:01:56 -0700 Subject: [PATCH 01/31] chore: start main E2E remediation draft Signed-off-by: Prekshi Vyas From 66ff91cdcccfa35c57bb6debd0eb9ec14d784e96 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:11:34 -0700 Subject: [PATCH 02/31] fix(policy): omit inactive Hermes messaging bindings Signed-off-by: Prekshi Vyas --- src/lib/messaging/channels/policy.ts | 62 ++++++++++++++++++++++++- src/lib/onboard/initial-policy.ts | 30 +++--------- src/lib/policy/index.ts | 17 ++++++- src/lib/shields/permissive-runtime.ts | 12 ++++- test/permissive-runtime.test.ts | 18 ++++++- test/policies-permissive-policy.test.ts | 58 ++++++++++++++--------- 6 files changed, 146 insertions(+), 51 deletions(-) diff --git a/src/lib/messaging/channels/policy.ts b/src/lib/messaging/channels/policy.ts index 8865086011f..da4cee8cc42 100644 --- a/src/lib/messaging/channels/policy.ts +++ b/src/lib/messaging/channels/policy.ts @@ -8,7 +8,10 @@ import YAML from "yaml"; import { isValidName } from "../../sandbox-name-contract"; import { ROOT } from "../../state/paths"; import type { MessagingAgentId } from "../manifest"; -import { listMessagingPolicyPresetMetadata } from "./metadata"; +import { + getMessagingPolicyKeysByChannel, + listMessagingPolicyPresetMetadata, +} from "./metadata"; type PolicyPresetLocator = { readonly channelId: string; @@ -67,6 +70,63 @@ export function materializeMessagingPolicySandboxName( return content.replaceAll("{sandboxName}", sandboxName); } +export function filterInactiveMessagingChannelPolicies( + content: string, + activeChannels: readonly string[], + agent: MessagingAgentId, +): { content: string; changed: boolean } { + let parsed: unknown; + try { + parsed = YAML.parse(content); + } catch { + return { content, changed: false }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { content, changed: false }; + } + const policy = parsed as Record; + const networkPolicies = policy.network_policies; + if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { + return { content, changed: false }; + } + + const active = new Set(activeChannels); + const entries = networkPolicies as Record; + let changed = false; + for (const [channel, policyKeys] of Object.entries( + getMessagingPolicyKeysByChannel({ agent }), + )) { + if (active.has(channel)) continue; + for (const key of policyKeys) { + if (!Object.hasOwn(entries, key)) continue; + delete entries[key]; + changed = true; + } + } + return { content: changed ? YAML.stringify(policy) : content, changed }; +} + +export function messagingChannelsPresentInPolicy( + content: string, + agent: MessagingAgentId, +): string[] { + let parsed: unknown; + try { + parsed = YAML.parse(content); + } catch { + return []; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return []; + const networkPolicies = (parsed as Record).network_policies; + if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { + return []; + } + const keys = new Set(Object.keys(networkPolicies)); + return Object.entries(getMessagingPolicyKeysByChannel({ agent })) + .filter(([, policyKeys]) => policyKeys.some((key) => keys.has(key))) + .map(([channel]) => channel); +} + function normalizeAgent( agent: MessagingAgentId | string | null | undefined, ): MessagingAgentId | null { diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 4e05baef0c2..7c77cf533be 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -7,7 +7,7 @@ import { TextDecoder } from "node:util"; import YAML from "yaml"; import { isObjectRecord } from "../core/json-types"; -import { getMessagingPolicyKeysByChannel } from "../messaging/channels"; +import { filterInactiveMessagingChannelPolicies } from "../messaging/channels"; import * as policies from "../policy"; import { applyBaselineExclusions, @@ -49,8 +49,6 @@ export function discloseInitialSandboxPolicy(policy: InitialSandboxPolicy): void ); } -const HERMES_MESSAGING_POLICY_KEYS = getMessagingPolicyKeysByChannel({ agent: "hermes" }); - const PROC_PATH = "/proc"; const PROC_COMM_READ_WRITE_PATHS = ["/proc/self/comm", "/proc/self/task/*/comm"]; const SYSFS_PATH = "/sys"; @@ -365,27 +363,11 @@ function filterHermesInactiveMessagingPolicies( policyContent: string, activeMessagingChannels: string[], ): { content: string; changed: boolean } { - const parsed = YAML.parse(policyContent); - if (!isObjectRecord(parsed) || !isObjectRecord(parsed.network_policies)) { - return { content: policyContent, changed: false }; - } - - const active = new Set(activeMessagingChannels); - let changed = false; - for (const [channel, policyKeys] of Object.entries(HERMES_MESSAGING_POLICY_KEYS)) { - if (active.has(channel)) continue; - for (const key of policyKeys) { - if (Object.prototype.hasOwnProperty.call(parsed.network_policies, key)) { - delete parsed.network_policies[key]; - changed = true; - } - } - } - - return { - content: changed ? YAML.stringify(parsed) : policyContent, - changed, - }; + return filterInactiveMessagingChannelPolicies( + policyContent, + activeMessagingChannels, + "hermes", + ); } function isHermesPolicyPath(policyPath: string): boolean { diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index b028fd0ea9f..f592f81f46a 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -18,6 +18,7 @@ import { CLI_NAME } from "../cli/branding"; import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, + filterInactiveMessagingChannelPolicies, isMessagingChannelPolicyPreset, listBuiltInMessagingChannelManifests, listMessagingChannelPolicyPresets, @@ -25,6 +26,7 @@ import { loadMessagingChannelPolicyPreset, materializeMessagingPolicySandboxName, } from "../messaging/channels"; +import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation"; import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; @@ -32,6 +34,7 @@ import { ROOT, run, runCapture } from "../runner"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import { redact } from "../security/redact"; import * as registry from "../state/registry"; +import { getMessagingPlanFromEntry } from "../state/registry-messaging"; import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; import { digestBaselineEntry, @@ -2920,8 +2923,20 @@ function applyPermissivePolicy(sandboxName: string): void { if (!fs.existsSync(policyPath)) { throw new Error(`Permissive policy not found: ${policyPath}`); } + const sandbox = registry.getSandbox(sandboxName); const policyDocument = fs.readFileSync(policyPath, "utf-8"); - const materializedPolicy = materializeMessagingPolicySandboxName(policyDocument, sandboxName); + const channelFilteredPolicy = + sandbox?.agent === "hermes" + ? filterInactiveMessagingChannelPolicies( + policyDocument, + getActiveChannelIdsFromPlan(getMessagingPlanFromEntry(sandbox)), + "hermes", + ).content + : policyDocument; + const materializedPolicy = materializeMessagingPolicySandboxName( + channelFilteredPolicy, + sandboxName, + ); if (materializedPolicy === null) { throw new Error("Cannot materialize the permissive policy credential provider binding"); } diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 47bf53ec331..dee49318467 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -16,7 +16,11 @@ import type { ExactManagedMcpPolicy, ManagedMcpPolicyOmission, } from "../actions/sandbox/mcp-bridge-policy"; -import { materializeMessagingPolicySandboxName } from "../messaging/channels/policy"; +import { + filterInactiveMessagingChannelPolicies, + materializeMessagingPolicySandboxName, + messagingChannelsPresentInPolicy, +} from "../messaging/channels/policy"; import { cleanupTempDir, secureTempFile } from "../onboard/temp-files"; export { @@ -141,7 +145,11 @@ export function buildRuntimePermissivePolicy( if (materialized === null) { throw new Error("Cannot materialize the Shields-down credential provider binding"); } - baseYaml = materialized; + baseYaml = filterInactiveMessagingChannelPolicies( + materialized, + messagingChannelsPresentInPolicy(deps.livePolicyYaml, "hermes"), + "hermes", + ).content; } const base = safeYamlObject(baseYaml); if (!base) { diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index 38f6e742d35..514d1d11da6 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -87,7 +87,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { it("keeps the Hermes Discord provider binding in Shields down", () => { let stagedPolicy = ""; const out = buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { - livePolicyYaml: "", + livePolicyYaml: HERMES_DISCORD_PERMISSIVE, readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, sandboxName: "hermes-box", writeTempPolicy: (yaml) => { @@ -121,6 +121,22 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(stagedPolicy).not.toContain("{sandboxName}"); }); + it("removes inactive Hermes Discord bindings before Shields down", () => { + let stagedPolicy = ""; + buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { + livePolicyYaml: BASE_PERMISSIVE, + readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, + sandboxName: "hermes-box", + writeTempPolicy: (yaml) => { + stagedPolicy = yaml; + return "/staged-hermes-permissive.yaml"; + }, + }); + + expect(YAML.parse(stagedPolicy).network_policies?.discord).toBeUndefined(); + expect(stagedPolicy).not.toContain("hermes-box-discord-bridge"); + }); + it("rejects an unsafe Hermes sandbox name before staging Shields down", () => { const writeTempPolicy = vi.fn(() => "/must-not-stage.yaml"); diff --git a/test/policies-permissive-policy.test.ts b/test/policies-permissive-policy.test.ts index d631ce40040..44f51edb296 100644 --- a/test/policies-permissive-policy.test.ts +++ b/test/policies-permissive-policy.test.ts @@ -12,6 +12,9 @@ import YAML from "yaml"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "policy", "index.ts")); const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "state", "registry.ts")); +const PLAN_FIXTURE_PATH = JSON.stringify( + path.join(REPO_ROOT, "test", "helpers", "messaging-plan-fixtures.ts"), +); const SOURCE_NODE_ARGS = ["--import", "tsx"]; function parseResultPayload(stdout: string): { error: string } { @@ -21,7 +24,7 @@ function parseResultPayload(stdout: string): { error: string } { return JSON.parse(stdout.slice(markerIndex + marker.length)); } -function runHermesPermissivePolicy(policySetStatus: number): { +function runHermesPermissivePolicy(policySetStatus: number, discordActive = false): { result: ReturnType; policy: string; stagedPath: string; @@ -35,7 +38,18 @@ function runHermesPermissivePolicy(policySetStatus: number): { const script = String.raw` const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); -registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes", policies: [] }); +const { makeMessagingPlan } = require(${PLAN_FIXTURE_PATH}); +registry.registerSandbox({ + name: "hermes-sandbox", + agent: "hermes", + policies: [], + ...(Boolean(${discordActive}) ? { + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan({ sandboxName: "hermes-sandbox", agent: "hermes", channels: ["discord"] }), + }, + } : {}), +}); policies.applyPermissivePolicy("hermes-sandbox"); `; fs.writeFileSync( @@ -90,7 +104,7 @@ describe("applyPermissivePolicy", () => { ["success", 0], ["OpenShell rejection", 17], ])( - "materializes the Hermes Discord provider and removes staged policy material after %s", + "removes inactive Hermes Discord policy and staged material after %s", (_case, policySetStatus) => { const observed = runHermesPermissivePolicy(policySetStatus); try { @@ -98,25 +112,7 @@ describe("applyPermissivePolicy", () => { expect(observed.stagedMode).toBe("600"); expect(fs.existsSync(observed.stagedPath)).toBe(false); const policy = YAML.parse(observed.policy); - const endpoints = policy.network_policies.discord.endpoints as Array<{ - host?: string; - credential_binding?: { provider?: string }; - }>; - const credentialEndpoints = endpoints.filter((endpoint) => - ["discord.com", "gateway.discord.gg", "*.discord.gg"].includes(endpoint.host ?? ""), - ); - expect(credentialEndpoints.map((endpoint) => endpoint.host).sort()).toEqual([ - "*.discord.gg", - "discord.com", - "gateway.discord.gg", - ]); - expect( - credentialEndpoints.map((endpoint) => endpoint.credential_binding?.provider), - ).toEqual([ - "hermes-sandbox-discord-bridge", - "hermes-sandbox-discord-bridge", - "hermes-sandbox-discord-bridge", - ]); + expect(policy.network_policies.discord).toBeUndefined(); expect(observed.policy).not.toContain("{sandboxName}"); } finally { observed.cleanup(); @@ -124,6 +120,24 @@ describe("applyPermissivePolicy", () => { }, ); + it("keeps materialized Hermes Discord bindings for an active channel", () => { + const observed = runHermesPermissivePolicy(0, true); + try { + const endpoints = YAML.parse(observed.policy).network_policies.discord.endpoints as Array<{ + credential_binding?: { provider?: string }; + }>; + expect(endpoints.filter((endpoint) => endpoint.credential_binding)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + credential_binding: { provider: "hermes-sandbox-discord-bridge" }, + }), + ]), + ); + } finally { + observed.cleanup(); + } + }); + it("rejects an invalid sandbox name before the permissive policy command", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-permissive-invalid-")); const fakeOpenshell = path.join(tmpDir, "openshell"); From 1bb1b1b292232b0c3ec86d45f6f9179ec8d2f734 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:16:07 -0700 Subject: [PATCH 03/31] test(e2e): use exact Hermes Discord provider Signed-off-by: Prekshi Vyas --- test/e2e/live/rebuild-hermes-bootstrap.ts | 50 +++++++++++++++++++ test/e2e/live/rebuild-hermes.test.ts | 32 ++++-------- .../support/rebuild-hermes-bootstrap.test.ts | 36 +++++++++++++ 3 files changed, 96 insertions(+), 22 deletions(-) diff --git a/test/e2e/live/rebuild-hermes-bootstrap.ts b/test/e2e/live/rebuild-hermes-bootstrap.ts index 7f8720c89bf..cbb483c9497 100644 --- a/test/e2e/live/rebuild-hermes-bootstrap.ts +++ b/test/e2e/live/rebuild-hermes-bootstrap.ts @@ -21,6 +21,10 @@ import type { ShellProbeOutputEvent, ShellProbeResult } from "../fixtures/shell- import { requireRebuildHermesCurrentBaseIdentity } from "./rebuild-hermes-base-identity.ts"; const CURRENT_BASE_MARKER = "__NEMOCLAW_REBUILD_HERMES_CURRENT_BASE__"; +const HERMES_DISCORD_PROVIDER_PROFILE = path.join( + REPO_ROOT, + "src/lib/messaging/channels/discord/provider-profile/hermes.yaml", +); export const GATEWAY_BOOTSTRAP_MARKER = "__NEMOCLAW_REBUILD_HERMES_GATEWAY_READY__"; export interface RebuildHermesCurrentBaseResult { @@ -56,6 +60,16 @@ interface RebuildHermesGatewayBootstrapOptions extends RebuildHermesBootstrapOpt sandboxName: string; } +interface RebuildHermesDiscordProviderOptions { + activeOpenshellBin: string; + apiKey: string; + discordToken: string; + envFactory: RebuildHermesChildEnvFactory; + host: HostCliClient; + redactionValues: string[]; + sandboxName: string; +} + interface RebuildHermesDashboardPortOptions { sandboxName: string; forwardListOutput: string; @@ -104,6 +118,42 @@ function requireResolutionMetadata(value: unknown): SandboxBaseImageResolutionMe return value as SandboxBaseImageResolutionMetadata; } +export async function createRebuildHermesDiscordProvider( + options: RebuildHermesDiscordProviderOptions, +): Promise { + const profile = await options.host.command( + options.activeOpenshellBin, + ["provider", "profile", "import", "--file", HERMES_DISCORD_PROVIDER_PROFILE], + { + artifactName: "phase-3-discord-provider-profile-import", + env: options.envFactory(options.apiKey), + redactionValues: options.redactionValues, + timeoutMs: 2 * 60_000, + }, + ); + assertExitZero(profile, "import Hermes Discord provider profile"); + const provider = await options.host.command( + options.activeOpenshellBin, + [ + "provider", + "create", + "--name", + `${options.sandboxName}-discord-bridge`, + "--type", + "discord-hermes-static-v1", + "--credential", + "DISCORD_BOT_TOKEN", + ], + { + artifactName: "phase-3-discord-provider-create", + env: options.envFactory(options.apiKey, { DISCORD_BOT_TOKEN: options.discordToken }), + redactionValues: options.redactionValues, + timeoutMs: 2 * 60_000, + }, + ); + assertExitZero(provider, "create Hermes Discord provider"); +} + export function buildRebuildHermesCurrentBaseScript(): string { return [ '"use strict";', diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 8976095e791..86e78789332 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -39,6 +39,7 @@ import { bootstrapRebuildHermesGateway, cleanupRebuildHermesForward as cleanupHermesForward, cleanupRebuildHermesTrackedForwards, + createRebuildHermesDiscordProvider, requireRebuildHermesDashboardPort, requireRebuildHermesHostedInferenceRoute, requireRebuildHermesOpenshellBin, @@ -959,28 +960,15 @@ test(STALE_BASE_REBUILD "utf8", ); try { - const provider = await host.command( - "bash", - [ - "-lc", - [ - "set -euo pipefail", - '"$OPENSHELL_BIN" provider create --name "$DISCORD_PROVIDER" --type generic --credential DISCORD_BOT_TOKEN ||', - ' "$OPENSHELL_BIN" provider update "$DISCORD_PROVIDER" --credential DISCORD_BOT_TOKEN', - ].join("\n"), - ], - { - artifactName: "phase-3-discord-provider-create-or-update", - env: testEnv(apiKey, { - DISCORD_BOT_TOKEN: DISCORD_FAKE_TOKEN, - DISCORD_PROVIDER: `${SANDBOX_NAME}-discord-bridge`, - OPENSHELL_BIN: activeOpenshellBin, - }), - redactionValues, - timeoutMs: OPENSHELL_TIMEOUT_MS, - }, - ); - expectExitZero(provider, "OpenShell Discord provider create/update"); + await createRebuildHermesDiscordProvider({ + activeOpenshellBin, + apiKey, + discordToken: DISCORD_FAKE_TOKEN, + envFactory: testEnv, + host, + redactionValues, + sandboxName: SANDBOX_NAME, + }); progress.phase("create the historical Hermes sandbox"); const createOldSandbox = await host.command( activeOpenshellBin, diff --git a/test/e2e/support/rebuild-hermes-bootstrap.test.ts b/test/e2e/support/rebuild-hermes-bootstrap.test.ts index 85cc2c92068..65aadb20740 100644 --- a/test/e2e/support/rebuild-hermes-bootstrap.test.ts +++ b/test/e2e/support/rebuild-hermes-bootstrap.test.ts @@ -14,6 +14,7 @@ import { buildRebuildHermesGatewayBootstrapScript, cleanupRebuildHermesForward, cleanupRebuildHermesTrackedForwards, + createRebuildHermesDiscordProvider, GATEWAY_BOOTSTRAP_MARKER, parseRebuildHermesCurrentBaseResult, requirePublishedRebuildHermesCurrentBase, @@ -235,6 +236,41 @@ describe("rebuild-Hermes direct bootstrap", () => { expect(script).not.toContain("sandbox create"); }); + it("attaches the historical sandbox to the exact Hermes Discord binding", async () => { + const fixture = fakeHost([probe("profile imported"), probe("provider created")]); + + await createRebuildHermesDiscordProvider({ + activeOpenshellBin: "/opt/openshell", + apiKey: "inference-secret", + discordToken: "discord-secret", + envFactory, + host: fixture.host, + redactionValues: ["inference-secret", "discord-secret"], + sandboxName: "e2e-rebuild-hermes", + }); + + expect(fixture.command.mock.calls[0]?.[1]).toEqual([ + "provider", + "profile", + "import", + "--file", + expect.stringMatching(/discord\/provider-profile\/hermes\.yaml$/u), + ]); + expect(fixture.command.mock.calls[1]?.[1]).toEqual([ + "provider", + "create", + "--name", + "e2e-rebuild-hermes-discord-bridge", + "--type", + "discord-hermes-static-v1", + "--credential", + "DISCORD_BOT_TOKEN", + ]); + expect(fixture.command.mock.calls[1]?.[2]).toMatchObject({ + env: { DISCORD_BOT_TOKEN: "discord-secret" }, + }); + }); + it("stops before gateway probes when bootstrap omits completion evidence (#7144)", async () => { const markerlessHost = fakeHost([probe("gateway setup returned without completion evidence")]); const writeJson = vi.fn(async (_name: string, _value: unknown) => "unused-artifact.json"); From 679f62974f7231deeb67ef8b5a7879c8df83fea9 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:22:12 -0700 Subject: [PATCH 04/31] fix(channels): reattach providers before policy Signed-off-by: Prekshi Vyas --- .../messaging-provider-attachments.test.ts | 163 ++++++++++++++++++ .../sandbox/messaging-provider-attachments.ts | 152 ++++++++++++++++ .../sandbox/policy-channel-conflict.test.ts | 45 +++++ .../sandbox/policy-channel-dependencies.ts | 18 ++ src/lib/actions/sandbox/policy-channel.ts | 32 ++++ 5 files changed, 410 insertions(+) create mode 100644 src/lib/actions/sandbox/messaging-provider-attachments.test.ts create mode 100644 src/lib/actions/sandbox/messaging-provider-attachments.ts diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.test.ts b/src/lib/actions/sandbox/messaging-provider-attachments.test.ts new file mode 100644 index 00000000000..c7f63af0585 --- /dev/null +++ b/src/lib/actions/sandbox/messaging-provider-attachments.test.ts @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../../messaging"; +import { + parseMessagingProviderAttachmentNames, + restoreChannelMessagingProviderAttachments, + rollbackMessagingProviderAttachments, +} from "./messaging-provider-attachments"; + +type OpenShellRunner = NonNullable< + Parameters[3] +>; + +function result(stdout = "", status = 0, stderr = "") { + return { + pid: 0, + output: [null, stdout, stderr], + stdout, + stderr, + status, + signal: null, + }; +} + +function queuedRunner(results: ReturnType[]) { + const run = vi.fn((..._args: unknown[]) => results.shift() ?? result()); + return { run: run as unknown as OpenShellRunner, spy: run }; +} + +function hermesDiscordPlan(): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "alpha", + agent: "hermes", + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [ + { + channelId: "discord", + credentialId: "botToken", + sourceInput: "botToken", + providerName: "alpha-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + credentialAvailable: true, + }, + ], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +const EXACT_PROVIDER = [ + "Name: alpha-discord-bridge", + "Type: discord-hermes-static-v1", + "Credential keys: DISCORD_BOT_TOKEN", + "Config keys: ", +].join("\n"); + +const ATTACHED_PROVIDER = [ + "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS", + "alpha-discord-bridge discord-hermes-static-v1 1 0", +].join("\n"); + +describe("messaging provider attachment lifecycle", () => { + it("parses empty and populated OpenShell attachment lists", () => { + expect( + parseMessagingProviderAttachmentNames("No providers attached to sandbox alpha."), + ).toEqual([]); + expect(parseMessagingProviderAttachmentNames(ATTACHED_PROVIDER)).toEqual([ + "alpha-discord-bridge", + ]); + }); + + it("restores an exact Hermes Discord provider before policy application", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result("No providers attached to sandbox alpha."), + result("Attached provider alpha-discord-bridge"), + result(ATTACHED_PROVIDER), + ]); + + expect( + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + fixture.run, + ), + ).toEqual(["alpha-discord-bridge"]); + expect(fixture.spy.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "get", "alpha-discord-bridge"], + ["sandbox", "provider", "list", "alpha"], + ["sandbox", "provider", "attach", "alpha", "alpha-discord-bridge"], + ["sandbox", "provider", "list", "alpha"], + ]); + }); + + it("does not mutate an attachment that already exists", () => { + const fixture = queuedRunner([result(EXACT_PROVIDER), result(ATTACHED_PROVIDER)]); + + expect( + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + fixture.run, + ), + ).toEqual([]); + expect(fixture.spy).toHaveBeenCalledTimes(2); + }); + + it("does not inspect attachments for a channel without credential bindings", () => { + const fixture = queuedRunner([]); + + expect( + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "whatsapp", + fixture.run, + ), + ).toEqual([]); + expect(fixture.spy).not.toHaveBeenCalled(); + }); + + it("rejects a same-name provider with the wrong Hermes binding", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER.replace("discord-hermes-static-v1", "generic")), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + fixture.run, + ), + ).toThrow(/does not match the required 'discord-hermes-static-v1'/u); + expect(fixture.spy).toHaveBeenCalledTimes(1); + }); + + it("reports rollback failures without hiding successful absent detaches", () => { + const fixture = queuedRunner([ + result("provider not attached", 1), + result("gateway unavailable", 1), + ]); + + expect( + rollbackMessagingProviderAttachments( + "alpha", + ["alpha-discord-bridge", "alpha-teams-bridge"], + fixture.run, + ), + ).toEqual(["alpha-discord-bridge: gateway unavailable"]); + }); +}); diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.ts b/src/lib/actions/sandbox/messaging-provider-attachments.ts new file mode 100644 index 00000000000..74d1ec3162d --- /dev/null +++ b/src/lib/actions/sandbox/messaging-provider-attachments.ts @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { stripAnsi } from "../../adapters/openshell/client"; +import { runOpenshell } from "../../adapters/openshell/runtime"; +import type { SandboxMessagingPlan } from "../../messaging"; +import { + matchesGatewayCredentialOnlyProviderBinding, + readGatewayProviderMetadata, +} from "../../onboard/gateway-provider-metadata"; +import { staticMessagingProviderTypeForChannel } from "../../onboard/messaging-bridge-provider"; + +type OpenShellRunner = typeof runOpenshell; +type OpenShellResult = ReturnType; + +function commandOutput(result: OpenShellResult): string { + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; + return stripAnsi(`${stdout ?? ""}\n${stderr ?? ""}`) + .replace(/\r/g, "") + .trim(); +} + +export function parseMessagingProviderAttachmentNames(output: string): string[] { + const clean = stripAnsi(output).replace(/\r/g, "").trim(); + if (/^No providers attached to sandbox\b/m.test(clean)) return []; + const lines = clean + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const headerIndex = lines.findIndex((line) => + /^NAME\s+TYPE\s+CREDENTIAL_KEYS\s+CONFIG_KEYS$/.test(line), + ); + if (headerIndex < 0) throw new Error("missing provider attachment table header"); + return lines.slice(headerIndex + 1).map((line) => { + const match = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$/); + if (!match?.[1]) throw new Error("invalid provider attachment table row"); + return match[1]; + }); +} + +function listMessagingProviderAttachments(sandboxName: string, run: OpenShellRunner): Set { + const result = run(["sandbox", "provider", "list", sandboxName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const output = commandOutput(result); + if (result.status !== 0) { + throw new Error(output || `Could not inspect providers attached to '${sandboxName}'.`); + } + try { + return new Set(parseMessagingProviderAttachmentNames(output)); + } catch (error) { + throw new Error( + `OpenShell returned invalid provider attachment metadata for '${sandboxName}': ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function channelCredentialBindings(plan: SandboxMessagingPlan, channelId: string) { + return [ + ...new Map( + plan.credentialBindings + .filter((binding) => binding.channelId === channelId) + .map((binding) => [binding.providerName, binding]), + ).values(), + ]; +} + +function assertMessagingProviderBinding( + plan: SandboxMessagingPlan, + binding: SandboxMessagingPlan["credentialBindings"][number], + run: OpenShellRunner, +): void { + const metadata = readGatewayProviderMetadata(binding.providerName, run); + const exactType = staticMessagingProviderTypeForChannel(binding.channelId, plan.agent); + const expectedType = exactType ?? metadata?.type ?? "generic"; + if ( + !matchesGatewayCredentialOnlyProviderBinding(metadata, { + name: binding.providerName, + type: expectedType, + credentialKey: binding.providerEnvKey, + }) + ) { + throw new Error( + `Existing provider '${binding.providerName}' does not match the required '${expectedType}' credential binding.`, + ); + } +} + +export function rollbackMessagingProviderAttachments( + sandboxName: string, + providerNames: readonly string[], + run: OpenShellRunner = runOpenshell, +): string[] { + const failures: string[] = []; + for (const providerName of [...providerNames].reverse()) { + const result = run(["sandbox", "provider", "detach", sandboxName, providerName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const output = commandOutput(result); + if ( + result.status !== 0 && + !/\bNotFound\b|not found|not attached|already detached/i.test(output) + ) { + failures.push(`${providerName}: ${output || `detach exited ${result.status}`}`); + } + } + return failures; +} + +export function restoreChannelMessagingProviderAttachments( + sandboxName: string, + plan: SandboxMessagingPlan, + channelId: string, + run: OpenShellRunner = runOpenshell, +): string[] { + const bindings = channelCredentialBindings(plan, channelId); + if (bindings.length === 0) return []; + for (const binding of bindings) assertMessagingProviderBinding(plan, binding, run); + + const attachedBefore = listMessagingProviderAttachments(sandboxName, run); + const newlyAttached: string[] = []; + try { + for (const binding of bindings) { + if (attachedBefore.has(binding.providerName)) continue; + const result = run(["sandbox", "provider", "attach", sandboxName, binding.providerName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error( + commandOutput(result) || `Failed to attach provider '${binding.providerName}'.`, + ); + } + const attachedAfter = listMessagingProviderAttachments(sandboxName, run); + if (!attachedAfter.has(binding.providerName)) { + throw new Error( + `OpenShell did not confirm provider '${binding.providerName}' was attached to '${sandboxName}'.`, + ); + } + newlyAttached.push(binding.providerName); + } + return newlyAttached; + } catch (error) { + const rollbackFailures = rollbackMessagingProviderAttachments(sandboxName, newlyAttached, run); + const detail = + rollbackFailures.length > 0 ? ` Rollback failed: ${rollbackFailures.join("; ")}` : ""; + throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`); + } +} diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 020cd6fd7fc..b11987f5d3e 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -237,6 +237,8 @@ let listSandboxesMock: MockInstance; let rebuildSandboxMock: MockInstance; let ensureMessagingHostForwardAfterRebuildMock: MockInstance; let scopeDisclosureMock: MockInstance; +let restoreMessagingProviderAttachmentsMock: MockInstance; +let rollbackMessagingProviderAttachmentsMock: MockInstance; function arrangeRegistry(opts: { current: SandboxEntry; others?: SandboxEntry[] }): void { const all = [opts.current, ...(opts.others ?? [])]; @@ -306,6 +308,12 @@ beforeEach(() => { // Lazy legacy-provider seam: no onboarding graph is loaded for this suite. upsertMock = vi.spyOn(policyChannelDependencies, "upsertMessagingProviders").mockReturnValue([]); + restoreMessagingProviderAttachmentsMock = vi + .spyOn(policyChannelDependencies, "restoreChannelMessagingProviderAttachments") + .mockReturnValue([]); + rollbackMessagingProviderAttachmentsMock = vi + .spyOn(policyChannelDependencies, "rollbackMessagingProviderAttachments") + .mockReturnValue([]); // openshell runtime + gateway recovery. runOpenshellMock = vi.spyOn(runtime, "runOpenshell").mockReturnValue(successfulOpenshellResult()); @@ -1228,9 +1236,17 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { await startSandboxChannel("alpha", { channel: "teams" }); + expect(restoreMessagingProviderAttachmentsMock).toHaveBeenCalledWith( + "alpha", + expect.any(Object), + "teams", + ); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { disclosedPresetState: "absent", }); + expect(restoreMessagingProviderAttachmentsMock.mock.invocationCallOrder[0]).toBeLessThan( + applyPresetMock.mock.invocationCallOrder[0], + ); expect(rebuildSandboxMock).not.toHaveBeenCalled(); expect(loggedText()).toContain("Change queued"); }); @@ -1268,6 +1284,7 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { Object.assign(current, updates); return true; }); + restoreMessagingProviderAttachmentsMock.mockReturnValue(["alpha-teams-bridge"]); applyPresetMock.mockReturnValue(false); await expect(startSandboxChannel("alpha", { channel: "teams" })).rejects.toThrow( @@ -1278,10 +1295,38 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { disclosedPresetState: "absent", }); expect(registry.getDisabledChannels("alpha")).toContain("teams"); + expect(rollbackMessagingProviderAttachmentsMock).toHaveBeenCalledWith("alpha", [ + "alpha-teams-bridge", + ]); expect(rebuildSandboxMock).not.toHaveBeenCalled(); expect(loggedText()).toContain("channels start teams"); }); + it("channels start restores the disabled plan when provider attachment fails", async () => { + const current = makeTeamsEntry("alpha", { disabled: true }); + arrangeRegistry({ current }); + getDisabledChannelsMock.mockImplementation( + () => current.messaging?.plan.disabledChannels ?? [], + ); + updateSandboxMock.mockImplementation((_name: string, updates: Partial) => { + Object.assign(current, updates); + return true; + }); + restoreMessagingProviderAttachmentsMock.mockImplementation(() => { + throw new Error("provider is not attached"); + }); + + await expect(startSandboxChannel("alpha", { channel: "teams" })).rejects.toThrow( + "process.exit(1)", + ); + + expect(registry.getDisabledChannels("alpha")).toContain("teams"); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(rollbackMessagingProviderAttachmentsMock).not.toHaveBeenCalled(); + expect(rebuildSandboxMock).not.toHaveBeenCalled(); + expect(loggedText()).toContain("Could not restore 'teams' credential provider attachments"); + }); + it("channels start prints recovery guidance when policy and disabled-plan rollback both fail", async () => { arrangeRegistry({ current: makeTeamsEntry("alpha", { disabled: true }) }); getDisabledChannelsMock.mockReturnValue(["teams"]); diff --git a/src/lib/actions/sandbox/policy-channel-dependencies.ts b/src/lib/actions/sandbox/policy-channel-dependencies.ts index 757ee909f6b..bb054a9934e 100644 --- a/src/lib/actions/sandbox/policy-channel-dependencies.ts +++ b/src/lib/actions/sandbox/policy-channel-dependencies.ts @@ -2,6 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { runOpenshell } from "../../adapters/openshell/runtime"; +import type { SandboxMessagingPlan } from "../../messaging"; +import { + restoreChannelMessagingProviderAttachments, + rollbackMessagingProviderAttachments, +} from "./messaging-provider-attachments"; type MessagingProviderTokenDefinition = { name: string; @@ -49,6 +54,19 @@ type GooglechatWebhookProxy = Pick< * onboarding and rebuild modules at policy-channel import time. */ export const policyChannelDependencies = { + restoreChannelMessagingProviderAttachments( + sandboxName: string, + plan: SandboxMessagingPlan, + channelId: string, + ): string[] { + return restoreChannelMessagingProviderAttachments(sandboxName, plan, channelId); + }, + rollbackMessagingProviderAttachments( + sandboxName: string, + providerNames: readonly string[], + ): string[] { + return rollbackMessagingProviderAttachments(sandboxName, providerNames); + }, isMessagingProviderBindingConflict( error: unknown, ): error is Error & { readonly mutatedProviderNames: readonly string[] } { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index d1038b387c8..540327abf1d 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -1930,6 +1930,29 @@ async function sandboxChannelsSetEnabled( console.error(` Could not persist messaging plan for '${sandboxName}'.`); process.exit(1); } + let restoredProviderAttachments: string[] = []; + if (!disabled) { + try { + restoredProviderAttachments = + policyChannelDependencies.restoreChannelMessagingProviderAttachments( + sandboxName, + plan, + canonical, + ); + } catch (error) { + console.error( + ` ${YW}⚠${R} Could not restore '${canonical}' credential provider attachments: ${error instanceof Error ? error.message : String(error)}`, + ); + const rolledBack = await persistManifestChannelDisabledPlan(sandboxName, canonical, true); + if (!rolledBack) { + console.error( + ` ${YW}⚠${R} Could not restore '${canonical}' to disabled state after provider attachment failed.`, + ); + console.error(` Re-run: ${CLI_NAME} ${sandboxName} channels stop ${canonical}`); + } + process.exit(1); + } + } // Rebuild persists only the presets it actually restores. Re-apply a // restarted channel's preset before a queued or immediate rebuild so the // registry and backup manifest carry the enabled plan's policy intent. @@ -1941,6 +1964,15 @@ async function sandboxChannelsSetEnabled( disclosedPresetState, }) ) { + const detachFailures = policyChannelDependencies.rollbackMessagingProviderAttachments( + sandboxName, + restoredProviderAttachments, + ); + if (detachFailures.length > 0) { + console.error( + ` ${YW}⚠${R} Could not roll back '${canonical}' provider attachment(s): ${detachFailures.join("; ")}`, + ); + } const rolledBack = await persistManifestChannelDisabledPlan(sandboxName, canonical, true); if (!rolledBack) { console.error( From cd6fe0897dd5166119185d84135e97d35c095333 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 23 Aug 2026 10:14:06 -0700 Subject: [PATCH 05/31] test(e2e): align upgrade credential boundary Signed-off-by: Apurv Kumaria --- .../e2e/live/openshell-gateway-upgrade.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index a8e51acc012..dd6b4aad469 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -429,18 +429,18 @@ function expectStatePreservedAcrossUpgrade( legacy: OpenClawStateContract, upgraded: OpenClawStateContract, ): void { - expect(upgraded.placeholderEnvKeys).toContain("COMPATIBLE_API_KEY"); - - // This custom-provider fixture sets COMPATIBLE_API_KEY, not - // NVIDIA_INFERENCE_API_KEY, so v0.0.89 intentionally does not create the - // NVIDIA auth-profile keyRef. Preserve any references the frozen runtime - // does emit without inventing one for this route. + expect(legacy.placeholderEnvKeys).toContain("COMPATIBLE_API_KEY"); + expect(upgraded.placeholderEnvKeys).toEqual([]); + + // The current rebuild intentionally omits COMPATIBLE_API_KEY from its host + // environment. After trusted post-restore finalization (#9946), the + // credential remains gateway-held instead of being projected back into the + // sandbox environment. The upgraded agent turn below proves that the exact + // credential still reaches the compatible endpoint. Preserve any key refs + // the frozen runtime emitted without inventing one for this route. for (const keyRefId of legacy.keyRefIds) { expect(upgraded.keyRefIds).toContain(keyRefId); } - for (const envKey of legacy.placeholderEnvKeys) { - expect(upgraded.placeholderEnvKeys).toContain(envKey); - } } async function assertOpenClawAgentSecretBoundary( From 800ad9832d401730b49b2f75d169760b19c4cf56 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:31:26 -0700 Subject: [PATCH 06/31] fix(deepagents): activate MCP progressive disclosure Signed-off-by: Prekshi Vyas --- .../patch-managed-deepagents-code.py | 8 ++-- .../validate-progressive-tool-disclosure.py | 2 + ...s-code-progressive-tool-disclosure.test.ts | 37 ++++++++++++++++--- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index 2ddc23dd88e..c6857596227 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -723,9 +723,11 @@ def create_cli_agent(model, assistant_id, *args, **kwargs): assert_unique_callable_tool_names( kwargs.get("tools"), kwargs.get("mcp_server_info") ) - has_loaded_mcp_tools = any( - getattr(info, "tools", ()) for info in kwargs.get("mcp_server_info") or () - ) + # Deep Agents Code 0.1.55 passes the exact loaded MCP tool objects + # separately from the status-oriented server metadata. The metadata can be + # empty or lag the executable catalog, so it must not decide whether the + # progressive middleware is installed. + has_loaded_mcp_tools = bool(kwargs.get("mcp_tools")) if has_loaded_mcp_tools: from deepagents_code.progressive_tool_disclosure import ( progressive_tool_disclosure_enabled, diff --git a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py index 69cf5fbbdad..60d7029a447 100644 --- a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py +++ b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py @@ -916,6 +916,7 @@ def direct_probe(value: str) -> str: enable_memory=False, enable_skills=False, enable_shell=False, + mcp_tools=[direct_probe], mcp_server_info=[info], ) agent.invoke( @@ -1049,6 +1050,7 @@ def isolated_probe() -> str: enable_memory=False, enable_skills=False, enable_shell=False, + mcp_tools=[isolated_probe], mcp_server_info=[info], ) agent.invoke( diff --git a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts index 64e808ac472..f92d6209655 100644 --- a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts +++ b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts @@ -504,15 +504,30 @@ def observability_counts(result): os.environ.pop("NEMOCLAW_TOOL_DISCLOSURE", None) no_mcp = counts(agent.create_cli_agent(None, "assistant")) -empty_mcp = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(())])) -active = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))])) +empty_mcp = counts( + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[], + mcp_server_info=[Info(("metadata_only",))], + ) +) +active = counts( + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], + ) +) parent_only = harness.BaseTool("parent_only", "Parent graph tool") subagent_only = harness.BaseTool("subagent_only", "Subagent graph tool") subagent_result = agent.create_cli_agent( None, "assistant", tools=[parent_only], - mcp_server_info=[Info(("mcp_echo",))], + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], subagents=[ {"name": "inherits", "middleware": []}, {"name": "overrides", "middleware": [], "tools": [subagent_only]}, @@ -544,7 +559,14 @@ subagent_catalogs = { "visible": [tool.name for tool in subagent_visible.tools], } os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "direct" -direct = counts(agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))])) +direct = counts( + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], + ) +) os.environ["NEMOCLAW_OBSERVABILITY"] = "true" observability_noncanonical = observability_counts( @@ -622,7 +644,12 @@ finally: os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = "invalid" try: - agent.create_cli_agent(None, "assistant", mcp_server_info=[Info(("mcp_echo",))]) + agent.create_cli_agent( + None, + "assistant", + mcp_tools=[NamedTool("mcp_echo")], + mcp_server_info=[Info(())], + ) except RuntimeError as exc: invalid = str(exc) else: From f3ab1d141b097400ca49cc243a976eda4b002e59 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:36:49 -0700 Subject: [PATCH 07/31] test(e2e): exercise Hermes late MCP discovery Signed-off-by: Prekshi Vyas --- test/e2e/live/mcp-bridge.test.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 9ebf6cfafe9..3a097df9c0d 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -74,7 +74,6 @@ import { } from "./mcp-bridge-servers.ts"; import { assertAuthenticatedMcpDiscovery, - assertAuthenticatedMcpDiscoveryWithOneRestart, assertAuthenticatedMcpRediscovery, assertAuthenticatedMcpToolDiscovery, } from "./mcp-bridge-tool-discovery.ts"; @@ -1123,22 +1122,16 @@ mcpBridgeShardTest("hermes")( expectedAdapter: "hermes-config", artifactPrefix: "hermes", }); - const initialDiscoveryOffset = fakeMcp.requests.length; const providerName = await addBridgeAndReadStatus(host, { sandboxName: HERMES_SANDBOX_NAME, mcpUrl, expectedAdapter: "hermes-config", artifactPrefix: "hermes", }); - await assertAuthenticatedMcpDiscoveryWithOneRestart(fakeMcp, { - requestOffset: initialDiscoveryOffset, - expectedSecret: HOST_SECRET, - label: "Hermes initial MCP discovery", - restart: async () => { - progress.event("Hermes MCP discovery did not reach the fixture; restarting once"); - await restartBridgeWithoutHostSecret(host, HERMES_SANDBOX_NAME, "hermes-discovery-retry"); - }, - }); + // Hermes discovery is intentionally allowed to finish on the next agent + // turn when the bounded startup window expires. Prove the product contract + // through the real gateway instead of requiring an eager startup request. + await assertHermesToolCall("hermes-real-mcp-tool-call-initial"); await assertAuthenticatedMcpToolDiscovery(host, fakeMcp, { artifacts, sandboxName: HERMES_SANDBOX_NAME, From 3882c3b08d66b320b4d71e6a8d7e954dcd690d51 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:43:36 -0700 Subject: [PATCH 08/31] fix(onboard): settle pairing for custom images Signed-off-by: Prekshi Vyas --- .../launch-readiness-ordinary-pairing.test.ts | 18 ++++++++++++++++++ src/lib/actions/sandbox/launch-readiness.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts index d5fddff9cea..3afbf9f60e8 100644 --- a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts +++ b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts @@ -72,11 +72,29 @@ describe("ordinary OpenClaw pairing target", () => { }); }); + it("resolves ordinary pairing for an explicit custom image without a recorded version", () => { + vi.mocked(deps.getSandbox!).mockReturnValue({ + ...openClawEntry(), + agentVersion: null, + nemoclawVersion: null, + fromDockerfile: "/tmp/custom-openclaw/Dockerfile", + }); + + expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toEqual({ + gatewayName: GATEWAY_NAME, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: FINGERPRINT, + stateDirectory: "/sandbox/.openclaw", + version: loadAgent("openclaw").expected_version, + }); + }); + it.each([ ["missing agent identity", { agent: undefined }], ["pending route reservation", { pendingRouteReservation: true }], ["changed gateway binding", { gatewayName: "nemoclaw-8081" }], ["missing lifecycle generation", { lifecycleGeneration: undefined }], + ["missing managed-image version", { agentVersion: null }], ])("rejects %s (#9844)", (_label, mutation) => { vi.mocked(deps.getSandbox!).mockReturnValue({ ...openClawEntry(), diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index ee6c2720ee6..ce05e878b99 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -927,8 +927,16 @@ function resolveOpenClawPairingSettlementTarget( } catch { return null; } - const version = normalizedString(entry.agentVersion); + const recordedVersion = normalizedString(entry.agentVersion); const expectedVersion = normalizedString(agent.expected_version); + // Custom --from images deliberately omit agentVersion because NemoClaw did + // not build their OpenClaw payload. Ordinary pairing settlement does not + // execute version-specific code; it reads the descriptor-pinned state + // schema through the exact lifecycle below. Keep that supported path stable + // against the trusted definition while managed images still fail closed on + // a missing recorded version. + const version = + recordedVersion ?? (normalizedString(entry.fromDockerfile) ? expectedVersion : null); const stateDirectory = normalizedString(agent.config?.dir); const lifecycleGeneration = normalizedString(entry.lifecycleGeneration); const lifecycleLiveIdentityFingerprint = normalizedString(entry.lifecycleLiveIdentityFingerprint); From 2032df2c5678b2f50ca14d9340219920226e2281 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:43:36 -0700 Subject: [PATCH 09/31] fix(onboard): release exact deleting replacement Signed-off-by: Prekshi Vyas --- .../onboard/docker-gpu-patch-finalize.test.ts | 9 ++++-- src/lib/onboard/docker-gpu-patch-finalize.ts | 2 +- .../docker-gpu-supervisor-reconnect.test.ts | 23 +++++++++++-- .../docker-gpu-supervisor-reconnect.ts | 32 +++++++++++-------- src/lib/runtime-recovery.test.ts | 2 ++ src/lib/runtime-recovery.ts | 1 + 6 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/lib/onboard/docker-gpu-patch-finalize.test.ts b/src/lib/onboard/docker-gpu-patch-finalize.test.ts index 3ba19ac1e88..83b3c54d911 100644 --- a/src/lib/onboard/docker-gpu-patch-finalize.test.ts +++ b/src/lib/onboard/docker-gpu-patch-finalize.test.ts @@ -162,7 +162,9 @@ describe("finalizeDockerGpuPatchBackup", () => { ]); }); - it("accepts Error only when the stopped replacement is the sole labeled container (#9962)", () => { + it.each(["Error", "Deleting"])( + "accepts %s only when the stopped replacement is the sole labeled container (#9962)", + (phase) => { const replacementContainerId = "a".repeat(64); const events: string[] = []; const dockerStop = vi.fn(() => { @@ -183,7 +185,7 @@ describe("finalizeDockerGpuPatchBackup", () => { }); const runOpenshell = vi.fn(() => { events.push("observe error"); - return { status: 0, stdout: "alpha 2026-08-23 01:40:35 Error\n" }; + return { status: 0, stdout: `alpha 2026-08-23 01:40:35 ${phase}\n` }; }); const outcome = finalizeDockerGpuPatchBackup( @@ -216,7 +218,8 @@ describe("finalizeDockerGpuPatchBackup", () => { ]), expect.objectContaining({ ignoreError: true }), ); - }); + }, + ); it("caps Error corroboration to the remaining lifecycle-release budget (#9962)", () => { const replacementContainerId = "a".repeat(64); diff --git a/src/lib/onboard/docker-gpu-patch-finalize.ts b/src/lib/onboard/docker-gpu-patch-finalize.ts index 20fa338cc5a..e464192d96c 100644 --- a/src/lib/onboard/docker-gpu-patch-finalize.ts +++ b/src/lib/onboard/docker-gpu-patch-finalize.ts @@ -128,7 +128,7 @@ export function finalizeDockerGpuPatchBackup( ? waitForOpenShellSandboxLifecycleRelease(sandboxName, lifecycleReleaseTimeoutSecs, { runOpenshell: deps.runOpenshell, sleep: deps.sleep, - soleLabeledReplacementCorroboratesError: (remainingMs) => + soleLabeledReplacementCorroboratesRetiringPhase: (remainingMs) => isSoleLabeledReplacement( sandboxName, options.result.newContainerId, diff --git a/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts b/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts index ef4a16aafc7..1c5a8adbefc 100644 --- a/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts +++ b/src/lib/onboard/docker-gpu-supervisor-reconnect.test.ts @@ -31,7 +31,6 @@ describe("Docker GPU final lifecycle release", () => { ["a gateway error", "Error: gateway unavailable\n"], ["a phase-free row", "beta 2026-08-21 05:53:18\n"], ["an unrecognized phase", "beta 2026-08-21 05:53:18 Retiring\n"], - ["the selected sandbox in Deleting", "alpha 2026-08-21 05:53:18 Deleting\n"], ["the selected sandbox in Ready", "alpha 2026-08-21 05:53:18 Ready\n"], ["the selected sandbox in Provisioning", "alpha 2026-08-21 05:53:18 Provisioning\n"], ["the selected sandbox in Error", "alpha 2026-08-21 05:53:18 Error\n"], @@ -48,6 +47,26 @@ describe("Docker GPU final lifecycle release", () => { expect(runOpenshell).toHaveBeenCalledTimes(2); }); + it.each(["Error", "Deleting"])( + "accepts a corroborated stopped replacement in %s", + (phase) => { + const corroborate = vi.fn(() => true); + const runOpenshell = vi.fn(() => ({ + status: 0, + stdout: `alpha 2026-08-23 01:40:35 ${phase}\n`, + })); + + expect( + waitForOpenShellSandboxLifecycleRelease("alpha", 1, { + runOpenshell, + sleep: vi.fn(), + soleLabeledReplacementCorroboratesRetiringPhase: corroborate, + }), + ).toBe(true); + expect(corroborate).toHaveBeenCalledOnce(); + }, + ); + it.each([ ["a failed probe", { status: 1, stderr: "gateway unavailable" }], ["a probe without an exit status", { status: null, stderr: "timed out" }], @@ -80,7 +99,7 @@ describe("Docker GPU final lifecycle release", () => { waitForOpenShellSandboxLifecycleRelease("alpha", 1, { runOpenshell, sleep: vi.fn(), - soleLabeledReplacementCorroboratesError: corroborate, + soleLabeledReplacementCorroboratesRetiringPhase: corroborate, }), ).toBe(false); } finally { diff --git a/src/lib/onboard/docker-gpu-supervisor-reconnect.ts b/src/lib/onboard/docker-gpu-supervisor-reconnect.ts index 29884386619..89f36d730bf 100644 --- a/src/lib/onboard/docker-gpu-supervisor-reconnect.ts +++ b/src/lib/onboard/docker-gpu-supervisor-reconnect.ts @@ -78,12 +78,13 @@ type DockerLifecycleReleaseDeps = Pick< "runOpenshell" | "sleep" > & { /** - * Corroborating evidence for an Error row from a Docker query that confirms - * the transaction-owned replacement is the sole labeled sandbox container. + * Corroborating evidence for an Error or Deleting row from a Docker query + * that confirms the transaction-owned replacement is the sole labeled + * sandbox container. * The callback must fail closed and keep its child within the supplied * remaining lifecycle-release budget. */ - soleLabeledReplacementCorroboratesError?: (remainingMs: number) => boolean; + soleLabeledReplacementCorroboratesRetiringPhase?: (remainingMs: number) => boolean; }; /** @@ -97,10 +98,12 @@ type DockerLifecycleReleaseDeps = Pick< * OpenShell processes the stale deletion before the new registration. * - The caller enters this wait only after the replacement reached Ready and * was deliberately stopped. A successful list normally omits the sandbox - * name. An Error row is also sufficient only when a separate bounded Docker - * query confirms that exact stopped replacement is the sole remaining - * labeled container. This corroborates the release condition; the OpenShell - * row alone is not an identity-bound ownership receipt. + * name. An Error or Deleting row is also sufficient only when a separate + * bounded Docker query confirms that exact stopped replacement is the sole + * remaining labeled container. This corroborates the release condition; + * the OpenShell row alone is not an identity-bound ownership receipt. The + * Deleting case breaks the otherwise circular wait where OpenShell retains + * the row until that exact replacement emits its restart event. * - `waits for the sandbox name to disappear before restarting the * replacement (#9531)` protects the event order. `rejects final handoff when * OpenShell never releases the deleting lifecycle record (#9531)` protects @@ -132,19 +135,22 @@ export function waitForOpenShellSandboxLifecycleRelease( const output = String(result.stdout ?? "").trim(); const entries = parseLiveSandboxEntries(output); const sandboxPresent = entries.some((entry) => entry.name === sandboxName); - const stoppedReplacementError = entries.some( - (entry) => entry.name === sandboxName && entry.phase === "Error", + const stoppedReplacementRetiring = entries.some( + (entry) => + entry.name === sandboxName && (entry.phase === "Error" || entry.phase === "Deleting"), ); const hasPhaseBearingEntry = entries.some((entry) => entry.phase !== null); const explicitEmptyList = output === "No sandboxes found" || output === "No sandboxes found."; const remainingBeforeCorroborationMs = deadline - Date.now(); - const soleLabeledReplacementCorroboratesError = - stoppedReplacementError && + const soleLabeledReplacementCorroboratesRetiringPhase = + stoppedReplacementRetiring && remainingBeforeCorroborationMs > 0 && - deps.soleLabeledReplacementCorroboratesError?.(remainingBeforeCorroborationMs) === true; + deps.soleLabeledReplacementCorroboratesRetiringPhase?.( + remainingBeforeCorroborationMs, + ) === true; if ( explicitEmptyList || - soleLabeledReplacementCorroboratesError || + soleLabeledReplacementCorroboratesRetiringPhase || (hasPhaseBearingEntry && !sandboxPresent) ) { return true; diff --git a/src/lib/runtime-recovery.test.ts b/src/lib/runtime-recovery.test.ts index bfd281465f2..0b1e7669be6 100644 --- a/src/lib/runtime-recovery.test.ts +++ b/src/lib/runtime-recovery.test.ts @@ -40,6 +40,7 @@ describe("runtime recovery helpers", () => { "beta 2026-06-25 09:41:00 CrashLoopBackOff", "gamma 2026-06-25 09:42:00 Creating", "delta 2026-06-25 09:43:00 Evicted", + "epsilon 2026-06-25 09:44:00 Deleting", ].join("\n"), ), ).toEqual([ @@ -47,6 +48,7 @@ describe("runtime recovery helpers", () => { { name: "beta", phase: "CrashLoopBackOff" }, { name: "gamma", phase: "Creating" }, { name: "delta", phase: "Evicted" }, + { name: "epsilon", phase: "Deleting" }, ]); }); diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts index 179d9de5198..0361fc7a24c 100644 --- a/src/lib/runtime-recovery.ts +++ b/src/lib/runtime-recovery.ts @@ -21,6 +21,7 @@ const LIVE_SANDBOX_DISPLAY_PHASES = new Set([ "Provisioning", "Creating", "Pending", + "Deleting", "Terminating", "Error", "Failed", From e370c9346c61d503f734ff918c729b49948d3773 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:53:47 -0700 Subject: [PATCH 10/31] test(e2e): bind fake messaging credentials Signed-off-by: Prekshi Vyas --- .../fixtures/hermes-discord-policy-binding.ts | 13 ++++--- .../e2e/live/openclaw-discord-pairing.test.ts | 1 + test/e2e/live/openclaw-pairing-helpers.ts | 32 +++++++++++++++++ test/e2e/live/openclaw-slack-pairing.test.ts | 2 ++ .../hermes-discord-policy-binding.test.ts | 36 ++++++++++++++++++- 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/test/e2e/fixtures/hermes-discord-policy-binding.ts b/test/e2e/fixtures/hermes-discord-policy-binding.ts index 7ce99ef38d6..b4e5a6bcbe8 100644 --- a/test/e2e/fixtures/hermes-discord-policy-binding.ts +++ b/test/e2e/fixtures/hermes-discord-policy-binding.ts @@ -18,6 +18,7 @@ export function bindHermesDiscordPolicyEndpoint( providerName: string, host: string, port: number, + protocol?: string, ): void { const source = fs.readFileSync(policyFile, "utf8"); const policy = parseOpenShellPolicy(source).policy; @@ -30,8 +31,12 @@ export function bindHermesDiscordPolicyEndpoint( if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) { return false; } - const value = candidate as { host?: unknown; port?: unknown }; - return value.host === host && value.port === port; + const value = candidate as { host?: unknown; port?: unknown; protocol?: unknown }; + return ( + value.host === host && + value.port === port && + (protocol === undefined || value.protocol === protocol) + ); }) as Record | undefined; if (!endpoint) throw new Error("fake Discord endpoint is missing from the base policy"); @@ -41,11 +46,11 @@ export function bindHermesDiscordPolicyEndpoint( } function main(): void { - const [policyFile, providerName, host, rawPort] = process.argv.slice(2); + const [policyFile, providerName, host, rawPort, protocol] = process.argv.slice(2); if (!policyFile || !providerName || !host || !rawPort) { throw new Error("usage: hermes-discord-policy-binding "); } - bindHermesDiscordPolicyEndpoint(policyFile, providerName, host, Number(rawPort)); + bindHermesDiscordPolicyEndpoint(policyFile, providerName, host, Number(rawPort), protocol); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/test/e2e/live/openclaw-discord-pairing.test.ts b/test/e2e/live/openclaw-discord-pairing.test.ts index 26cdba73be5..ed6c5cf0a6f 100644 --- a/test/e2e/live/openclaw-discord-pairing.test.ts +++ b/test/e2e/live/openclaw-discord-pairing.test.ts @@ -139,6 +139,7 @@ test("OpenClaw Discord pairing request is shared with connect-shell approval", { api: fakeGateway, protocol: "websocket", rewrite: "websocket-credential-rewrite", + providerName: `${SANDBOX_NAME}-discord-bridge`, env, redactions, artifactName: "apply-discord-gateway-policy", diff --git a/test/e2e/live/openclaw-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index fd92bf8fed0..9a6307bcd8c 100644 --- a/test/e2e/live/openclaw-pairing-helpers.ts +++ b/test/e2e/live/openclaw-pairing-helpers.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import path from "node:path"; import type { ArtifactSink } from "../fixtures/artifacts.ts"; import type { CleanupRegistry } from "../fixtures/cleanup.ts"; @@ -9,6 +10,7 @@ import type { HostCliClient } from "../fixtures/clients/host.ts"; import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { expect } from "../fixtures/e2e-test.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { REPO_ROOT } from "../fixtures/paths.ts"; import { type FakeDockerApi, startFakeDockerApi } from "./messaging-providers-helpers.ts"; import { cleanupSandbox, @@ -203,6 +205,7 @@ export async function applyFakePolicy(options: { api: FakeDockerApi; protocol: "rest" | "websocket"; rewrite: "request-body-credential-rewrite" | "websocket-credential-rewrite"; + providerName: string; env: NodeJS.ProcessEnv; redactions: string[]; artifactName: string; @@ -225,6 +228,35 @@ export async function applyFakePolicy(options: { timeoutMs: 120_000, }); expectExitZero(result, options.artifactName); + + const binding = await options.host.command( + "bash", + [ + "-lc", + String.raw`set -eu +policy_file="$(mktemp)" +trap 'rm -f "$policy_file"' EXIT +"$1" policy get --base "$2" >"$policy_file" +node --import tsx "$7" "$policy_file" "$3" "$4" "$5" "$6" +"$1" policy set --policy "$policy_file" --wait "$2"`, + `bind-fake-${options.protocol}-policy`, + options.host.openshellCommandPath, + options.sandboxName, + options.providerName, + "host.openshell.internal", + String(options.api.port), + options.protocol, + path.join(REPO_ROOT, "test/e2e/fixtures/hermes-discord-policy-binding.ts"), + ], + { + artifactName: `${options.artifactName}-credential-binding`, + cwd: REPO_ROOT, + env: options.env, + redactionValues: options.redactions, + timeoutMs: 120_000, + }, + ); + expectExitZero(binding, `${options.artifactName} credential binding`); } export async function assertOpenClawStateRoot( diff --git a/test/e2e/live/openclaw-slack-pairing.test.ts b/test/e2e/live/openclaw-slack-pairing.test.ts index 549beaf6d10..195cea5b89f 100644 --- a/test/e2e/live/openclaw-slack-pairing.test.ts +++ b/test/e2e/live/openclaw-slack-pairing.test.ts @@ -168,6 +168,7 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap api: fakeSlack, protocol: "rest", rewrite: "request-body-credential-rewrite", + providerName: `${SANDBOX_NAME}-slack-bridge`, env, redactions, artifactName: "apply-slack-rest-policy", @@ -178,6 +179,7 @@ test("OpenClaw Slack Socket Mode pairing request is shared with connect-shell ap api: fakeSlack, protocol: "websocket", rewrite: "websocket-credential-rewrite", + providerName: `${SANDBOX_NAME}-slack-app`, env, redactions, artifactName: "apply-slack-websocket-policy", diff --git a/test/e2e/support/hermes-discord-policy-binding.test.ts b/test/e2e/support/hermes-discord-policy-binding.test.ts index 55e47c99bc3..07b98e2ccc3 100644 --- a/test/e2e/support/hermes-discord-policy-binding.test.ts +++ b/test/e2e/support/hermes-discord-policy-binding.test.ts @@ -12,7 +12,7 @@ import YAML from "yaml"; const HELPER = path.resolve(import.meta.dirname, "../fixtures/hermes-discord-policy-binding.ts"); const tempDirs: string[] = []; -function runBinding(policyFile: string) { +function runBinding(policyFile: string, protocol?: string) { return spawnSync( process.execPath, [ @@ -23,6 +23,7 @@ function runBinding(policyFile: string) { "e2e-hermes-discord-discord-bridge", "host.docker.internal", "43117", + ...(protocol ? [protocol] : []), ], { encoding: "utf8", timeout: 15_000 }, ); @@ -77,4 +78,37 @@ describe("Hermes Discord E2E policy binding", () => { }); expect(fs.statSync(policyFile).mode & 0o777).toBe(0o600); }); + + it("binds only the requested protocol when a fake host and port are shared", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-messaging-policy-")); + tempDirs.push(tempDir); + const policyFile = path.join(tempDir, "policy.yaml"); + fs.writeFileSync( + policyFile, + [ + "version: 1", + "network_policies:", + " fake:", + " endpoints:", + " - host: host.docker.internal", + " port: 43117", + " protocol: rest", + " - host: host.docker.internal", + " port: 43117", + " protocol: websocket", + "", + ].join("\n"), + ); + + const result = runBinding(policyFile, "websocket"); + const endpoints = YAML.parse(fs.readFileSync(policyFile, "utf8")).network_policies.fake + .endpoints as Array>; + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(endpoints[0]).not.toHaveProperty("credential_binding"); + expect(endpoints[1]).toHaveProperty("credential_binding", { + provider: "e2e-hermes-discord-discord-bridge", + }); + }); }); From 4103ae0e1cc9b80114ac8cd25f2e0dbf343d3559 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 13:59:03 -0700 Subject: [PATCH 11/31] fix(onboard): produce scoped pairing before observation Co-authored-by: Julie Yaunches Signed-off-by: Prekshi Vyas --- src/lib/actions/sandbox/auto-pair-warmup.ts | 22 +++++++-- .../onboard/machine/finalization-deps.test.ts | 45 ++++++++++--------- src/lib/onboard/machine/finalization-deps.ts | 37 ++++++++------- 3 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 490548b91db..aa088c6cbb8 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -182,7 +182,11 @@ NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ exit 0 `; -function runSandboxWarmupScript(sandboxName: string, script: string): void { +function runSandboxWarmupScript( + sandboxName: string, + script: string, + gatewayName?: string, +): void { // Lazy require: `adapters/openshell/resolve` pulls in `runner`, whose // load-time `require("./platform")` cannot be resolved by the Vitest TS // loader. Importing it here keeps this module unit-testable in-process. @@ -197,7 +201,17 @@ function runSandboxWarmupScript(sandboxName: string, script: string): void { if (!openshellBinary) return; spawnSync( openshellBinary, - ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], + [ + "sandbox", + "exec", + "--name", + sandboxName, + ...(gatewayName ? ["-g", gatewayName] : []), + "--", + "sh", + "-c", + script, + ], { cwd: ROOT, env: process.env, @@ -216,8 +230,8 @@ function runSandboxWarmupScript(sandboxName: string, script: string): void { * missing openclaw, gateway unreachable) are swallowed. The finalization * settlement gate decides readiness from a later canonical observation. */ -export function runSandboxScopeWarmupRun(sandboxName: string): void { - runSandboxWarmupScript(sandboxName, WARMUP_SCRIPT); +export function runSandboxScopeWarmupRun(sandboxName: string, gatewayName: string): void { + runSandboxWarmupScript(sandboxName, WARMUP_SCRIPT, gatewayName); } /** diff --git a/src/lib/onboard/machine/finalization-deps.test.ts b/src/lib/onboard/machine/finalization-deps.test.ts index eae24feaf7b..6f2c73854ef 100644 --- a/src/lib/onboard/machine/finalization-deps.test.ts +++ b/src/lib/onboard/machine/finalization-deps.test.ts @@ -72,7 +72,7 @@ describe("ordinary OpenClaw pairing settlement", () => { vi.restoreAllMocks(); }); - it("accepts one already-settled canonical CLI device without pairing writes (#9844)", async () => { + it("accepts one already-settled canonical CLI device after one idempotent producer (#10014)", async () => { const scope = ordinaryPairingDeps(); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ @@ -85,27 +85,32 @@ describe("ordinary OpenClaw pairing settlement", () => { "2026.7.1", "/sandbox/.openclaw", ); - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); - it("waits for canonical pairing before one warm-up and approval pass (#9844)", async () => { + it("runs the canonical request probe before waiting for fresh pairing (#10014)", async () => { + const observePairing = vi.fn(() => PAIRING_ONLY); + observePairing.mockImplementationOnce(() => { + throw new Error("not published"); + }); const scope = ordinaryPairingDeps({ - observePairing: vi - .fn() - .mockImplementationOnce(() => { - throw new Error("not published"); - }) - .mockReturnValueOnce(PAIRING_ONLY) - .mockReturnValue(SETTLED), + observePairing, + runWarmup: vi.fn(() => { + scope.calls.push("warmup"); + }), + runApproval: vi.fn(() => { + scope.calls.push("approval"); + vi.mocked(scope.deps.observePairing).mockReturnValue(SETTLED); + }), }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ kind: "settled", }); - expect(scope.calls).toEqual(["sleep", "warmup", "approval"]); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(scope.calls).toEqual(["warmup", "sleep", "approval"]); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); }); @@ -149,8 +154,8 @@ describe("ordinary OpenClaw pairing settlement", () => { expect(events).toEqual([ "sandbox-lock:start", "gateway-lock:start", - "observe:baseline", "warmup", + "observe:baseline", "approval", "observe:final", "gateway-lock:end", @@ -299,7 +304,7 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); expect(scope.deps.runApproval).not.toHaveBeenCalled(); - expect(scope.deps.observePairing).toHaveBeenCalledOnce(); + expect(scope.deps.observePairing).not.toHaveBeenCalled(); }); it("does not observe replacement state when the runtime changes during approval (#9844)", async () => { @@ -378,7 +383,7 @@ describe("ordinary OpenClaw pairing settlement", () => { kind: "settled", }); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.observePairing).toHaveBeenCalledTimes(3); expect(now).toBe( @@ -410,11 +415,11 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); expect(scope.deps.sleep).not.toHaveBeenCalled(); - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); - it("performs no writes when a canonical CLI pairing never appears (#9844)", async () => { + it("performs one request-producer write when a canonical CLI pairing never appears (#10014)", async () => { const scope = ordinaryPairingDeps({ observePairing: vi.fn(() => { throw new Error("not published"); @@ -426,7 +431,7 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); @@ -456,7 +461,7 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.observePairing).not.toHaveBeenCalled(); - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); @@ -517,7 +522,7 @@ describe("ordinary OpenClaw pairing settlement", () => { await expect(finalizationHandlerDeps.settleOrdinaryOpenClawPairing("alpha")).resolves.toEqual({ kind: "settled", }); - expect(runSandboxScopeWarmupRun).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(runSandboxScopeWarmupRun).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(runConnectAutoPairApprovalPass).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); }); diff --git a/src/lib/onboard/machine/finalization-deps.ts b/src/lib/onboard/machine/finalization-deps.ts index a783d517e98..db85b279e25 100644 --- a/src/lib/onboard/machine/finalization-deps.ts +++ b/src/lib/onboard/machine/finalization-deps.ts @@ -63,7 +63,7 @@ interface OrdinaryOpenClawPairingSettlementDeps { version: string, stateDirectory: string, ): OpenClawPairingSettlementObservation; - runWarmup(name: string): Promise | void; + runWarmup(name: string, gatewayName: string): Promise | void; runApproval(name: string, gatewayName: string): Promise | void; withSandboxLock: SandboxLifecycleLock; withGatewayLock: GatewayRouteLock; @@ -154,8 +154,10 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { finalizationHandlerRuntime .loadPairingQualification() .observeOrdinaryOpenClawPairingSettlement(...args), - runWarmup: (name) => - finalizationHandlerRuntime.loadAutoPairWarmup().runSandboxScopeWarmupRun(name), + runWarmup: (name, gatewayName) => + finalizationHandlerRuntime + .loadAutoPairWarmup() + .runSandboxScopeWarmupRun(name, gatewayName), runApproval: (name, gatewayName) => finalizationHandlerRuntime .loadAutoPairApproval() @@ -174,8 +176,8 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { } /** - * Wait for the startup watcher to publish one canonical CLI pairing. When the - * device has only its pairing scope, request and approve the write scope once. + * Run one bounded request producer, then wait for one canonical CLI pairing. + * When the device has only its pairing scope, approve the write scope once. * A final read verifies the exact device and no pending request for that device. */ export async function settleOrdinaryOpenClawPairing( @@ -198,6 +200,20 @@ export async function settleOrdinaryOpenClawPairing( return { kind: "incomplete", reason: "runtime-identity-invalid" }; } const settlementDeadline = deps.now() + OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; + + // Fresh non-interactive onboarding can reach finalization before the + // startup watcher publishes its first CLI pairing request. Provoke + // that request once with the direct, device-authenticated + // sessions.create probe before observation. Approval and the final + // exact-device observation remain the only completion authority. + try { + await deps.runWarmup(name, target.gatewayName); + } catch { + // The bounded observation below remains fail closed. + } + if (!samePairingTarget(target, deps.getTarget(name))) { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } const pairingAppearanceDeadline = Math.min( settlementDeadline, deps.now() + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS, @@ -224,16 +240,7 @@ export async function settleOrdinaryOpenClawPairing( return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; } - let warmupFailed = false; - try { - await deps.runWarmup(name); - } catch { - warmupFailed = true; - } - if (!samePairingTarget(target, deps.getTarget(name))) { - return { kind: "incomplete", reason: "runtime-identity-invalid" }; - } - if (warmupFailed || deps.now() >= settlementDeadline) { + if (deps.now() >= settlementDeadline) { return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; } From 46c0370049f6010e10e840dfa724ea3ef932113c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:12:57 -0700 Subject: [PATCH 12/31] test(e2e): send initial Hermes Discord identify Signed-off-by: Prekshi Vyas --- test/e2e/live/hermes-discord.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/hermes-discord.test.ts b/test/e2e/live/hermes-discord.test.ts index 2db059e26ec..d887633ae5c 100644 --- a/test/e2e/live/hermes-discord.test.ts +++ b/test/e2e/live/hermes-discord.test.ts @@ -311,7 +311,10 @@ async def main(): kwargs = {"gateway": URL(f"${HERMES_DISCORD_HTTP_PROXY_GATEWAY_TEMPLATE}")} params = inspect.signature(from_client).parameters if "initial" in params: - kwargs["initial"] = False + # A fresh proof must identify immediately. discord.py deliberately + # sleeps before a non-initial IDENTIFY, which leaves only heartbeat + # traffic on this short-lived credential-rewrite connection. + kwargs["initial"] = True if "compress" in params: kwargs["compress"] = False elif "zlib" in params: From a0a6d2973db6afbdcf056ffe15243decbab70ee9 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:12:57 -0700 Subject: [PATCH 13/31] test(e2e): bind stock timestamp to quote source Signed-off-by: Prekshi Vyas --- test/e2e/live/openclaw-agent-assertion.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/openclaw-agent-assertion.ts b/test/e2e/live/openclaw-agent-assertion.ts index 1a0d0827c0a..70953b8a938 100644 --- a/test/e2e/live/openclaw-agent-assertion.ts +++ b/test/e2e/live/openclaw-agent-assertion.ts @@ -208,7 +208,8 @@ If progressive tool disclosure is active, you may use tool_search, tool_describe Do not invoke any other target tool. Do not use web_search, Brave Search, or Tavily Search. Set web_fetch maxChars to no more than 8000. Only after web_fetch returns a numeric NVDA price with its source date or timestamp, reply with one JSON object and no Markdown. -Set status to NVDA_PERSONAL_AGENT_OK, symbol to NVDA, price to a JSON number, source_url to the exact HTTPS URL passed to web_fetch, and as_of to the source's ISO 8601 date or timestamp.`; +Set status to NVDA_PERSONAL_AGENT_OK, symbol to NVDA, price to a JSON number, source_url to the exact HTTPS URL passed to web_fetch, and as_of to the quote's own market or update timestamp converted to ISO 8601. +For a Unix-epoch quote field such as regularMarketTime, convert that field to ISO 8601. Never use the current clock, fetch time, or an unrelated date for as_of.`; export async function runPersonalStockAgentAssertion( host: HostCliClient, From 9aeecc82b2bacf8c2b2af01cdf89133b74c7d11d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:25:00 -0700 Subject: [PATCH 14/31] fix(channels): compensate unconfirmed attachments Signed-off-by: Prekshi Vyas --- .../messaging-provider-attachments.test.ts | 52 +++++++++++++++++++ .../sandbox/messaging-provider-attachments.ts | 5 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.test.ts b/src/lib/actions/sandbox/messaging-provider-attachments.test.ts index c7f63af0585..74511069f4f 100644 --- a/src/lib/actions/sandbox/messaging-provider-attachments.test.ts +++ b/src/lib/actions/sandbox/messaging-provider-attachments.test.ts @@ -116,6 +116,58 @@ describe("messaging provider attachment lifecycle", () => { expect(fixture.spy).toHaveBeenCalledTimes(2); }); + it("rolls back an attachment when confirmation fails", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result("No providers attached to sandbox alpha."), + result("Attached provider alpha-discord-bridge"), + result("gateway unavailable", 1), + result("Detached provider alpha-discord-bridge"), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + fixture.run, + ), + ).toThrow(/gateway unavailable/u); + expect(fixture.spy.mock.calls.at(-1)?.[0]).toEqual([ + "sandbox", + "provider", + "detach", + "alpha", + "alpha-discord-bridge", + ]); + }); + + it("rolls back an attachment when confirmation omits it", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result("No providers attached to sandbox alpha."), + result("Attached provider alpha-discord-bridge"), + result("No providers attached to sandbox alpha."), + result("provider was not attached to sandbox alpha"), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + fixture.run, + ), + ).toThrow(/did not confirm provider 'alpha-discord-bridge'/u); + expect(fixture.spy.mock.calls.at(-1)?.[0]).toEqual([ + "sandbox", + "provider", + "detach", + "alpha", + "alpha-discord-bridge", + ]); + }); + it("does not inspect attachments for a channel without credential bindings", () => { const fixture = queuedRunner([]); diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.ts b/src/lib/actions/sandbox/messaging-provider-attachments.ts index 74d1ec3162d..f6fdda768ae 100644 --- a/src/lib/actions/sandbox/messaging-provider-attachments.ts +++ b/src/lib/actions/sandbox/messaging-provider-attachments.ts @@ -134,13 +134,16 @@ export function restoreChannelMessagingProviderAttachments( commandOutput(result) || `Failed to attach provider '${binding.providerName}'.`, ); } + // The attach command has crossed the mutation boundary. Record it before + // observation so a failed or negative confirmation still compensates + // only the provider that was absent at the initial attachment check. + newlyAttached.push(binding.providerName); const attachedAfter = listMessagingProviderAttachments(sandboxName, run); if (!attachedAfter.has(binding.providerName)) { throw new Error( `OpenShell did not confirm provider '${binding.providerName}' was attached to '${sandboxName}'.`, ); } - newlyAttached.push(binding.providerName); } return newlyAttached; } catch (error) { From 5b9b217ad80af63a29f447372c0509ee758678da Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:25:53 -0700 Subject: [PATCH 15/31] test(e2e): clean up Hermes rebuild swap Signed-off-by: Prekshi Vyas --- test/e2e/fixtures/hermes-rebuild-swap.ts | 16 ++++++++++ test/e2e/live/rebuild-hermes.test.ts | 31 ++++++++++++++---- test/e2e/support/hermes-rebuild-swap.test.ts | 33 ++++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/test/e2e/fixtures/hermes-rebuild-swap.ts b/test/e2e/fixtures/hermes-rebuild-swap.ts index 3897eeadb4a..bcf481c0892 100644 --- a/test/e2e/fixtures/hermes-rebuild-swap.ts +++ b/test/e2e/fixtures/hermes-rebuild-swap.ts @@ -2,6 +2,22 @@ // SPDX-License-Identifier: Apache-2.0 export const HERMES_REBUILD_SWAP_BYTES = 32 * 1024 * 1024 * 1024; +export const HERMES_REBUILD_SWAP_FILE = "/mnt/nemoclaw-hermes-rebuild.swap"; + +export function hermesRebuildSwapCleanupArgs(): string[] { + return [ + "bash", + "-c", + `set -euo pipefail +swap_file="$1" +if swapon --show --noheadings --output NAME | grep -Fx -- "$swap_file" >/dev/null; then + swapoff "$swap_file" +fi +rm -f -- "$swap_file"`, + "hermes-rebuild-swap-cleanup", + HERMES_REBUILD_SWAP_FILE, + ]; +} export function parseActiveSwapBytes(output: string): number { return output diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 86e78789332..88e02c914cc 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -22,6 +22,8 @@ import { } from "../fixtures/file-state.ts"; import { HERMES_REBUILD_SWAP_BYTES, + HERMES_REBUILD_SWAP_FILE, + hermesRebuildSwapCleanupArgs, needsHermesRebuildSwap, parseActiveSwapBytes, } from "../fixtures/hermes-rebuild-swap.ts"; @@ -141,11 +143,9 @@ const LIVE_TIMEOUT_MS = 70 * 60_000; // generous diagnostic tail without letting a stuck child exhaust the hosted // runner by growing the fixture's in-memory stdout/stderr buffers forever. const LONG_COMMAND_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024; -const HERMES_REBUILD_SWAP_FILE = "/mnt/nemoclaw-hermes-rebuild.swap"; - -async function ensureHermesRebuildSwap(host: HostCliClient): Promise { +async function ensureHermesRebuildSwap(host: HostCliClient): Promise { const githubActions = process.env.GITHUB_ACTIONS === "true"; - if (!githubActions) return; + if (!githubActions) return false; const probeOptions = { env: buildAvailabilityProbeEnv(), @@ -166,7 +166,7 @@ async function ensureHermesRebuildSwap(host: HostCliClient): Promise { githubActions, }) ) { - return; + return false; } const provision = await host.command( @@ -195,6 +195,14 @@ swapon "$swap_file"`, ); expectExitZero(provision, "provision swap for Hermes rebuild"); + return true; +} + +async function verifyHermesRebuildSwap(host: HostCliClient): Promise { + const probeOptions = { + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }; const verified = await host.command( "swapon", ["--show", "--bytes", "--noheadings", "--output", "SIZE"], @@ -685,7 +693,18 @@ test(STALE_BASE_REBUILD "rebuild-Hermes must invoke the checked-out CLI through NEMOCLAW_CLI_BIN", ).toBe(CLI_ENTRYPOINT); await ensureRebuildHermesHostTools(host); - await ensureHermesRebuildSwap(host); + const createdRebuildSwap = await ensureHermesRebuildSwap(host); + if (createdRebuildSwap) { + cleanup.trackDisposable("remove Hermes rebuild swap", async () => { + const removed = await host.command("sudo", hermesRebuildSwapCleanupArgs(), { + artifactName: "cleanup-hermes-rebuild-swap", + env: buildAvailabilityProbeEnv(), + timeoutMs: 2 * 60_000, + }); + expectExitZero(removed, "remove Hermes rebuild swap"); + }); + await verifyHermesRebuildSwap(host); + } const dockerInfo = await host.command("docker", ["info"], { artifactName: "prereq-docker-info", diff --git a/test/e2e/support/hermes-rebuild-swap.test.ts b/test/e2e/support/hermes-rebuild-swap.test.ts index ea4984ad43b..6e59ac52219 100644 --- a/test/e2e/support/hermes-rebuild-swap.test.ts +++ b/test/e2e/support/hermes-rebuild-swap.test.ts @@ -6,6 +6,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { HERMES_REBUILD_SWAP_BYTES, + HERMES_REBUILD_SWAP_FILE, + hermesRebuildSwapCleanupArgs, needsHermesRebuildSwap, parseActiveSwapBytes, } from "../fixtures/hermes-rebuild-swap.ts"; @@ -53,4 +55,35 @@ describe("Hermes rebuild swap", () => { expect(ensureSwap).toBeGreaterThan(-1); expect(dockerProbe).toBeGreaterThan(ensureSwap); }); + + it("cleans only the exact swap file created by the rebuild fixture", () => { + const cleanup = hermesRebuildSwapCleanupArgs(); + + expect(cleanup.at(-1)).toBe(HERMES_REBUILD_SWAP_FILE); + expect(cleanup.join("\n")).toContain('grep -Fx -- "$swap_file"'); + expect(cleanup.join("\n")).toContain('swapoff "$swap_file"'); + expect(cleanup.join("\n")).toContain('rm -f -- "$swap_file"'); + expect(cleanup.join("\n")).not.toContain("swapoff -a"); + }); + + it("registers observable cleanup before verifying created swap", () => { + const source = fs.readFileSync( + path.resolve(import.meta.dirname, "../live/rebuild-hermes.test.ts"), + "utf8", + ); + const ensureSwap = source.indexOf("const createdRebuildSwap = await ensureHermesRebuildSwap(host);"); + const registerCleanup = source.indexOf( + 'cleanup.trackDisposable("remove Hermes rebuild swap"', + ensureSwap, + ); + const verifySwap = source.indexOf("await verifyHermesRebuildSwap(host);", registerCleanup); + const observableFailure = source.indexOf( + 'expectExitZero(removed, "remove Hermes rebuild swap");', + registerCleanup, + ); + + expect(registerCleanup).toBeGreaterThan(ensureSwap); + expect(observableFailure).toBeGreaterThan(registerCleanup); + expect(verifySwap).toBeGreaterThan(registerCleanup); + }); }); From 874d6e27ce89597fc1528682cdd614d06697758c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:26:12 -0700 Subject: [PATCH 16/31] fix(images): bind managed builds to target architecture Signed-off-by: Prekshi Vyas --- .github/workflows/managed-images.yaml | 3 +++ test/managed-image-publication-workflow.test.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 9b78f4faf64..a5c6bf1b376 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1886,6 +1886,7 @@ jobs: - name: Validate production build args env: + ARCH: ${{ matrix.arch }} BASE_IMAGE: ${{ steps.base.outputs.ref }} DOCKERFILE: ${{ matrix.dockerfile }} run: | @@ -1895,6 +1896,7 @@ jobs: --build-arg "BASE_IMAGE=${BASE_IMAGE}" --build-arg "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1" --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" + --build-arg "TARGETARCH=${ARCH}" ) scripts/check-production-build-args.sh "${build_args[@]}" @@ -1924,6 +1926,7 @@ jobs: BASE_IMAGE=${{ steps.base.outputs.ref }} NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root + TARGETARCH=${{ matrix.arch }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }} cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }},mode=max provenance: mode=max diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index aa81a87231b..5d0eafb47e4 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -957,13 +957,15 @@ fi expect(releaseIdentity.run).toContain("git describe --tags --match 'v*' \"$GITHUB_SHA\""); expect(releaseIdentity.run).toContain("managed image release identity does not match"); expect(guard.run).toContain('scripts/check-production-build-args.sh "${build_args[@]}"'); + expect(guard.env?.ARCH).toBe("${{ matrix.arch }}"); + expect(guard.run).toContain('--build-arg "TARGETARCH=${ARCH}"'); expect(build.uses).toBe("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"); expect(build.with).toMatchObject({ context: ".", file: "${{ matrix.dockerfile }}", platforms: "${{ matrix.platform }}", "build-args": - "BASE_IMAGE=${{ steps.base.outputs.ref }}\nNEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1\nNEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root\n", + "BASE_IMAGE=${{ steps.base.outputs.ref }}\nNEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1\nNEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root\nTARGETARCH=${{ matrix.arch }}\n", provenance: "mode=max", sbom: true, }); From 030d9b042f5eec5b97031b06e642d99b1cb181fe Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:27:03 -0700 Subject: [PATCH 17/31] fix(e2e): wait for managed image publication Signed-off-by: Prekshi Vyas --- .github/workflows/e2e.yaml | 1 + ...mage-publication-workflow-boundary.test.ts | 4 ++ .../support/base-image-publication.test.ts | 51 ++++++++++++++++++- tools/e2e/base-image-publication.mts | 20 +++++++- tools/e2e/operations-workflow-boundary.mts | 1 + 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 038650b0ccb..f40d3a3489e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -152,6 +152,7 @@ jobs: env: EXPECTED_SHA: ${{ inputs.checkout_sha || github.sha }} GITHUB_TOKEN: ${{ github.token }} + REQUIRE_MANAGED_IMAGE_PUBLICATION: "1" shell: bash run: | set -euo pipefail diff --git a/test/e2e/support/base-image-publication-workflow-boundary.test.ts b/test/e2e/support/base-image-publication-workflow-boundary.test.ts index 23c3570d682..52221a79491 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -196,6 +196,10 @@ describe("base-image publication workflow boundary (#7372)", () => { "verifier SHA", (value) => (gateSteps(value)[3].env!.EXPECTED_SHA = "${{ inputs.checkout_sha }}"), ], + [ + "managed-image publication requirement", + (value) => (gateSteps(value)[3].env!.REQUIRE_MANAGED_IMAGE_PUBLICATION = "0"), + ], [ "verifier command", (value) => { diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index ab8b3fc8718..4ee20b8b78b 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -611,7 +611,7 @@ describe("base-image publication evidence", () => { ); it("reconfirms the selected run identity after reading job history (#9549)", () => { - expect(() => validateBoundRun(workflowRun(), selectedRun())).not.toThrow(); + expect(validateBoundRun(workflowRun(), selectedRun())).toEqual(selectedRun()); expect(() => validateBoundRun( workflowRun({ conclusion: "cancelled" }), @@ -738,6 +738,55 @@ describe("base-image publication evidence", () => { expect(sleeps).toBe(0); }); + it("waits for managed-image publication when downstream E2E requires it", async () => { + const inProgressRun = workflowRun({ status: "in_progress", conclusion: null }); + const responses = [ + workflowMetadata(), + runsPayload([inProgressRun]), + { total_count: 3, jobs: successfulJobs() }, + inProgressRun, + runsPayload([workflowRun()]), + { total_count: 3, jobs: successfulJobs() }, + workflowRun(), + ]; + let currentTime = 0; + + await expect( + waitForBaseImagePublication({ + history: history(), + request: async () => responses.shift(), + requireWorkflowSuccess: true, + waitMs: 100, + pollMs: 10, + now: () => currentTime, + sleep: async (milliseconds) => { + currentTime += milliseconds; + }, + }), + ).resolves.toMatchObject({ id: RUN_ID, conclusion: "success" }); + expect(currentTime).toBe(10); + }); + + it("rejects failed managed-image publication before E2E consumers start", async () => { + const failedRun = workflowRun({ conclusion: "failure" }); + const responses = [ + workflowMetadata(), + runsPayload([failedRun]), + { total_count: 3, jobs: successfulJobs() }, + failedRun, + ]; + + await expect( + waitForBaseImagePublication({ + history: history(), + request: async () => responses.shift(), + requireWorkflowSuccess: true, + waitMs: 100, + pollMs: 10, + }), + ).rejects.toThrow(/managed-image publication workflow did not complete successfully/u); + }); + it.each(["failure", "cancelled"] as const)( "accepts required publishers after unrelated downstream work concludes %s (#9549)", async (conclusion) => { diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 7954d84a462..595a8d105ec 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -102,6 +102,7 @@ export type PublicationSelection = export interface PublicationWaitOptions { history: FirstParentHistory; request: (path: string) => Promise; + requireWorkflowSuccess?: boolean; waitMs: number; pollMs: number; now?: () => number; @@ -515,7 +516,7 @@ export function validatePublisherJobs(payload: unknown, run: PublicationRun): "p return pending ? "pending" : "ready"; } -export function validateBoundRun(payload: unknown, expected: PublicationRun): void { +export function validateBoundRun(payload: unknown, expected: PublicationRun): PublicationRun { const actual = validateRun(payload, 0, expected.workflowId); if ( actual.id !== expected.id || @@ -526,6 +527,7 @@ export function validateBoundRun(payload: unknown, expected: PublicationRun): vo `selected base-image workflow changed while evidence was verified; ${expected.url}`, ); } + return actual; } async function collectPaginationAttempt( @@ -642,10 +644,19 @@ export async function waitForBaseImagePublication( const jobs = await collectPaginated(options.request, jobsPath, "jobs"); publisherState = validatePublisherJobs(jobs, selection.run); if (publisherState === "ready") { - validateBoundRun( + const boundRun = validateBoundRun( await options.request(`/repos/${REPOSITORY}/actions/runs/${selection.run.id}`), selection.run, ); + if (options.requireWorkflowSuccess === true) { + if (boundRun.status !== "completed") { + publisherState = "pending"; + } else if (boundRun.conclusion !== "success") { + throw new Error( + `managed-image publication workflow did not complete successfully; ${boundRun.url}`, + ); + } + } } } catch (error) { throw publicationEvidenceError(error, selection.run); @@ -789,6 +800,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro const token = env.GITHUB_TOKEN ?? ""; const expectedSha = env.EXPECTED_SHA ?? ""; const outputPath = env.GITHUB_OUTPUT ?? ""; + const requireManagedImagePublication = env.REQUIRE_MANAGED_IMAGE_PUBLICATION ?? "0"; const workspace = env.GITHUB_WORKSPACE ?? process.cwd(); if (token.length === 0 || token.includes("\r") || token.includes("\n")) { throw new Error("GITHUB_TOKEN must be a non-empty single-line value"); @@ -806,6 +818,9 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro if (env.GITHUB_SHA !== expectedSha) { throw new Error("EXPECTED_SHA must match GITHUB_SHA"); } + if (requireManagedImagePublication !== "0" && requireManagedImagePublication !== "1") { + throw new Error("REQUIRE_MANAGED_IMAGE_PUBLICATION must be 0 or 1"); + } const workflowSource = readFileSync(resolve(workspace, WORKFLOW_PATH), "utf8"); const paths = parseBaseImagePushPaths(workflowSource); @@ -813,6 +828,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro const run = await waitForBaseImagePublication({ history, request: (path) => githubRequest(path, token), + requireWorkflowSuccess: requireManagedImagePublication === "1", waitMs: waitSeconds * 1000, pollMs: pollSeconds * 1000, }); diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index e0f59fae096..090b63b02e1 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -640,6 +640,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): env: { EXPECTED_SHA: "${{ inputs.checkout_sha || github.sha }}", GITHUB_TOKEN: "${{ github.token }}", + REQUIRE_MANAGED_IMAGE_PUBLICATION: "1", }, shell: "bash", run: [ From a171c04a073d9a79da95be5d352c1024f1ecbaf4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:37:52 -0700 Subject: [PATCH 18/31] fix(e2e): close remaining main failure gaps Signed-off-by: Prekshi Vyas --- .github/workflows/managed-images.yaml | 2 + .../sandbox/mcp-bridge-provider-inspection.ts | 21 +- .../messaging-provider-attachments.test.ts | 163 ----------- .../sandbox/messaging-provider-attachments.ts | 152 ---------- .../messaging-provider/attachments.test.ts | 277 ++++++++++++++++++ .../sandbox/messaging-provider/attachments.ts | 227 ++++++++++++++ .../sandbox/policy-channel-conflict.test.ts | 13 +- .../sandbox/policy-channel-dependencies.ts | 18 +- src/lib/actions/sandbox/policy-channel.ts | 5 +- src/lib/adapters/openshell/ansi.ts | 8 + src/lib/adapters/openshell/client.ts | 7 +- .../provider-attachment-table.test.ts | 31 ++ .../openshell/provider-attachment-table.ts | 23 ++ .../onboard/gateway-provider-metadata.test.ts | 24 ++ src/lib/onboard/gateway-provider-metadata.ts | 66 ++++- src/lib/policy/index.ts | 4 +- src/lib/state/registry-messaging.ts | 7 + src/lib/state/registry.ts | 1 + test/e2e/live/rebuild-hermes-swap.ts | 124 ++++++++ test/e2e/live/rebuild-hermes.test.ts | 73 +---- test/e2e/support/hermes-rebuild-swap.test.ts | 33 ++- ...managed-image-publication-workflow.test.ts | 3 +- 22 files changed, 857 insertions(+), 425 deletions(-) delete mode 100644 src/lib/actions/sandbox/messaging-provider-attachments.test.ts delete mode 100644 src/lib/actions/sandbox/messaging-provider-attachments.ts create mode 100644 src/lib/actions/sandbox/messaging-provider/attachments.test.ts create mode 100644 src/lib/actions/sandbox/messaging-provider/attachments.ts create mode 100644 src/lib/adapters/openshell/ansi.ts create mode 100644 src/lib/adapters/openshell/provider-attachment-table.test.ts create mode 100644 src/lib/adapters/openshell/provider-attachment-table.ts create mode 100644 test/e2e/live/rebuild-hermes-swap.ts diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 9b78f4faf64..740a4786570 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1893,6 +1893,7 @@ jobs: build_args=( -f "$DOCKERFILE" --build-arg "BASE_IMAGE=${BASE_IMAGE}" + --build-arg "TARGETARCH=${{ matrix.arch }}" --build-arg "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1" --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" ) @@ -1922,6 +1923,7 @@ jobs: ${{ matrix.agent == 'langchain-deepagents-code' && format('com.nvidia.nemoclaw.base-resolution={0}', steps.base.outputs.resolution_label) || '' }} build-args: | BASE_IMAGE=${{ steps.base.outputs.ref }} + TARGETARCH=${{ matrix.arch }} NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }} diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index 0cbe4c3f04f..c0150c7b2e1 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { stripAnsi } from "../../adapters/openshell/client"; +import { parseProviderAttachmentNames } from "../../adapters/openshell/provider-attachment-table"; import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; import { listExtraProviders, type McpBridgeEntry } from "../../state/registry"; @@ -105,23 +106,7 @@ export function inspectMcpProvider(providerName: string | undefined): McpProvide }; } -export function parseMcpProviderAttachmentNames(output: string): string[] { - const clean = stripAnsi(output).replace(/\r/g, "").trim(); - if (/^No providers attached to sandbox\b/m.test(clean)) return []; - const lines = clean - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - const headerIndex = lines.findIndex((line) => - /^NAME\s+TYPE\s+CREDENTIAL_KEYS\s+CONFIG_KEYS$/.test(line), - ); - if (headerIndex < 0) throw new Error("missing provider attachment table header"); - return lines.slice(headerIndex + 1).map((line) => { - const match = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$/); - if (!match?.[1]) throw new Error("invalid provider attachment table row"); - return match[1]; - }); -} +export { parseProviderAttachmentNames as parseMcpProviderAttachmentNames } from "../../adapters/openshell/provider-attachment-table"; export function inspectMcpProviderAttachments( sandboxName: string, @@ -137,7 +122,7 @@ export function inspectMcpProviderAttachments( try { const clean = stripAnsi(output).replace(/\r/g, "").trim(); if (/^No providers attached to sandbox\b/m.test(clean)) return { attachments: [] }; - const names = parseMcpProviderAttachmentNames(clean); + const names = parseProviderAttachmentNames(clean); const attachments = names.map((name) => { const provider = inspectMcpProvider(name); if ( diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.test.ts b/src/lib/actions/sandbox/messaging-provider-attachments.test.ts deleted file mode 100644 index c7f63af0585..00000000000 --- a/src/lib/actions/sandbox/messaging-provider-attachments.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; -import type { SandboxMessagingPlan } from "../../messaging"; -import { - parseMessagingProviderAttachmentNames, - restoreChannelMessagingProviderAttachments, - rollbackMessagingProviderAttachments, -} from "./messaging-provider-attachments"; - -type OpenShellRunner = NonNullable< - Parameters[3] ->; - -function result(stdout = "", status = 0, stderr = "") { - return { - pid: 0, - output: [null, stdout, stderr], - stdout, - stderr, - status, - signal: null, - }; -} - -function queuedRunner(results: ReturnType[]) { - const run = vi.fn((..._args: unknown[]) => results.shift() ?? result()); - return { run: run as unknown as OpenShellRunner, spy: run }; -} - -function hermesDiscordPlan(): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName: "alpha", - agent: "hermes", - workflow: "onboard", - channels: [], - disabledChannels: [], - credentialBindings: [ - { - channelId: "discord", - credentialId: "botToken", - sourceInput: "botToken", - providerName: "alpha-discord-bridge", - providerEnvKey: "DISCORD_BOT_TOKEN", - placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", - credentialAvailable: true, - }, - ], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - -const EXACT_PROVIDER = [ - "Name: alpha-discord-bridge", - "Type: discord-hermes-static-v1", - "Credential keys: DISCORD_BOT_TOKEN", - "Config keys: ", -].join("\n"); - -const ATTACHED_PROVIDER = [ - "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS", - "alpha-discord-bridge discord-hermes-static-v1 1 0", -].join("\n"); - -describe("messaging provider attachment lifecycle", () => { - it("parses empty and populated OpenShell attachment lists", () => { - expect( - parseMessagingProviderAttachmentNames("No providers attached to sandbox alpha."), - ).toEqual([]); - expect(parseMessagingProviderAttachmentNames(ATTACHED_PROVIDER)).toEqual([ - "alpha-discord-bridge", - ]); - }); - - it("restores an exact Hermes Discord provider before policy application", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER), - result("No providers attached to sandbox alpha."), - result("Attached provider alpha-discord-bridge"), - result(ATTACHED_PROVIDER), - ]); - - expect( - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - fixture.run, - ), - ).toEqual(["alpha-discord-bridge"]); - expect(fixture.spy.mock.calls.map(([args]) => args)).toEqual([ - ["provider", "get", "alpha-discord-bridge"], - ["sandbox", "provider", "list", "alpha"], - ["sandbox", "provider", "attach", "alpha", "alpha-discord-bridge"], - ["sandbox", "provider", "list", "alpha"], - ]); - }); - - it("does not mutate an attachment that already exists", () => { - const fixture = queuedRunner([result(EXACT_PROVIDER), result(ATTACHED_PROVIDER)]); - - expect( - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - fixture.run, - ), - ).toEqual([]); - expect(fixture.spy).toHaveBeenCalledTimes(2); - }); - - it("does not inspect attachments for a channel without credential bindings", () => { - const fixture = queuedRunner([]); - - expect( - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "whatsapp", - fixture.run, - ), - ).toEqual([]); - expect(fixture.spy).not.toHaveBeenCalled(); - }); - - it("rejects a same-name provider with the wrong Hermes binding", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER.replace("discord-hermes-static-v1", "generic")), - ]); - - expect(() => - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - fixture.run, - ), - ).toThrow(/does not match the required 'discord-hermes-static-v1'/u); - expect(fixture.spy).toHaveBeenCalledTimes(1); - }); - - it("reports rollback failures without hiding successful absent detaches", () => { - const fixture = queuedRunner([ - result("provider not attached", 1), - result("gateway unavailable", 1), - ]); - - expect( - rollbackMessagingProviderAttachments( - "alpha", - ["alpha-discord-bridge", "alpha-teams-bridge"], - fixture.run, - ), - ).toEqual(["alpha-discord-bridge: gateway unavailable"]); - }); -}); diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.ts b/src/lib/actions/sandbox/messaging-provider-attachments.ts deleted file mode 100644 index 74d1ec3162d..00000000000 --- a/src/lib/actions/sandbox/messaging-provider-attachments.ts +++ /dev/null @@ -1,152 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { stripAnsi } from "../../adapters/openshell/client"; -import { runOpenshell } from "../../adapters/openshell/runtime"; -import type { SandboxMessagingPlan } from "../../messaging"; -import { - matchesGatewayCredentialOnlyProviderBinding, - readGatewayProviderMetadata, -} from "../../onboard/gateway-provider-metadata"; -import { staticMessagingProviderTypeForChannel } from "../../onboard/messaging-bridge-provider"; - -type OpenShellRunner = typeof runOpenshell; -type OpenShellResult = ReturnType; - -function commandOutput(result: OpenShellResult): string { - const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; - const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; - return stripAnsi(`${stdout ?? ""}\n${stderr ?? ""}`) - .replace(/\r/g, "") - .trim(); -} - -export function parseMessagingProviderAttachmentNames(output: string): string[] { - const clean = stripAnsi(output).replace(/\r/g, "").trim(); - if (/^No providers attached to sandbox\b/m.test(clean)) return []; - const lines = clean - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - const headerIndex = lines.findIndex((line) => - /^NAME\s+TYPE\s+CREDENTIAL_KEYS\s+CONFIG_KEYS$/.test(line), - ); - if (headerIndex < 0) throw new Error("missing provider attachment table header"); - return lines.slice(headerIndex + 1).map((line) => { - const match = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$/); - if (!match?.[1]) throw new Error("invalid provider attachment table row"); - return match[1]; - }); -} - -function listMessagingProviderAttachments(sandboxName: string, run: OpenShellRunner): Set { - const result = run(["sandbox", "provider", "list", sandboxName], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - const output = commandOutput(result); - if (result.status !== 0) { - throw new Error(output || `Could not inspect providers attached to '${sandboxName}'.`); - } - try { - return new Set(parseMessagingProviderAttachmentNames(output)); - } catch (error) { - throw new Error( - `OpenShell returned invalid provider attachment metadata for '${sandboxName}': ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -function channelCredentialBindings(plan: SandboxMessagingPlan, channelId: string) { - return [ - ...new Map( - plan.credentialBindings - .filter((binding) => binding.channelId === channelId) - .map((binding) => [binding.providerName, binding]), - ).values(), - ]; -} - -function assertMessagingProviderBinding( - plan: SandboxMessagingPlan, - binding: SandboxMessagingPlan["credentialBindings"][number], - run: OpenShellRunner, -): void { - const metadata = readGatewayProviderMetadata(binding.providerName, run); - const exactType = staticMessagingProviderTypeForChannel(binding.channelId, plan.agent); - const expectedType = exactType ?? metadata?.type ?? "generic"; - if ( - !matchesGatewayCredentialOnlyProviderBinding(metadata, { - name: binding.providerName, - type: expectedType, - credentialKey: binding.providerEnvKey, - }) - ) { - throw new Error( - `Existing provider '${binding.providerName}' does not match the required '${expectedType}' credential binding.`, - ); - } -} - -export function rollbackMessagingProviderAttachments( - sandboxName: string, - providerNames: readonly string[], - run: OpenShellRunner = runOpenshell, -): string[] { - const failures: string[] = []; - for (const providerName of [...providerNames].reverse()) { - const result = run(["sandbox", "provider", "detach", sandboxName, providerName], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - const output = commandOutput(result); - if ( - result.status !== 0 && - !/\bNotFound\b|not found|not attached|already detached/i.test(output) - ) { - failures.push(`${providerName}: ${output || `detach exited ${result.status}`}`); - } - } - return failures; -} - -export function restoreChannelMessagingProviderAttachments( - sandboxName: string, - plan: SandboxMessagingPlan, - channelId: string, - run: OpenShellRunner = runOpenshell, -): string[] { - const bindings = channelCredentialBindings(plan, channelId); - if (bindings.length === 0) return []; - for (const binding of bindings) assertMessagingProviderBinding(plan, binding, run); - - const attachedBefore = listMessagingProviderAttachments(sandboxName, run); - const newlyAttached: string[] = []; - try { - for (const binding of bindings) { - if (attachedBefore.has(binding.providerName)) continue; - const result = run(["sandbox", "provider", "attach", sandboxName, binding.providerName], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status !== 0) { - throw new Error( - commandOutput(result) || `Failed to attach provider '${binding.providerName}'.`, - ); - } - const attachedAfter = listMessagingProviderAttachments(sandboxName, run); - if (!attachedAfter.has(binding.providerName)) { - throw new Error( - `OpenShell did not confirm provider '${binding.providerName}' was attached to '${sandboxName}'.`, - ); - } - newlyAttached.push(binding.providerName); - } - return newlyAttached; - } catch (error) { - const rollbackFailures = rollbackMessagingProviderAttachments(sandboxName, newlyAttached, run); - const detail = - rollbackFailures.length > 0 ? ` Rollback failed: ${rollbackFailures.join("; ")}` : ""; - throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`); - } -} diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.test.ts b/src/lib/actions/sandbox/messaging-provider/attachments.test.ts new file mode 100644 index 00000000000..0c8439dfc5c --- /dev/null +++ b/src/lib/actions/sandbox/messaging-provider/attachments.test.ts @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../../../messaging"; +import { + parseMessagingProviderAttachmentNames, + restoreChannelMessagingProviderAttachments, + rollbackMessagingProviderAttachments, + type MessagingProviderAttachmentReceipt, +} from "./attachments"; + +type OpenShellRunner = NonNullable< + Parameters[4] +>; + +function result(stdout = "", status = 0, stderr = "") { + return { + pid: 0, + output: [null, stdout, stderr], + stdout, + stderr, + status, + signal: null, + }; +} + +function queuedRunner(results: ReturnType[]) { + const run = vi.fn((..._args: unknown[]) => results.shift() ?? result()); + return { run: run as unknown as OpenShellRunner, spy: run }; +} + +function hermesDiscordPlan(): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "alpha", + agent: "hermes", + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [ + { + channelId: "discord", + credentialId: "botToken", + sourceInput: "botToken", + providerName: "alpha-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + credentialAvailable: true, + }, + ], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +const EXACT_PROVIDER = [ + "Id: provider-alpha-discord", + "Name: alpha-discord-bridge", + "Type: discord-hermes-static-v1", + "Resource version: 7", + "Credential keys: DISCORD_BOT_TOKEN", + "Config keys: ", +].join("\n"); + +const RECEIPT: MessagingProviderAttachmentReceipt = { + credentialKey: "DISCORD_BOT_TOKEN", + gatewayName: "nemoclaw-9090", + providerId: "provider-alpha-discord", + providerName: "alpha-discord-bridge", + providerType: "discord-hermes-static-v1", + resourceVersion: 7, +}; + +const ATTACHED_PROVIDER = [ + "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS", + "alpha-discord-bridge discord-hermes-static-v1 1 0", +].join("\n"); + +describe("messaging provider attachment lifecycle", () => { + it("parses empty and populated OpenShell attachment lists", () => { + expect( + parseMessagingProviderAttachmentNames("No providers attached to sandbox alpha."), + ).toEqual([]); + expect(parseMessagingProviderAttachmentNames(ATTACHED_PROVIDER)).toEqual([ + "alpha-discord-bridge", + ]); + }); + + it("restores an exact Hermes Discord provider before policy application", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result("No providers attached to sandbox alpha."), + result(EXACT_PROVIDER), + result("Attached provider alpha-discord-bridge"), + result(ATTACHED_PROVIDER), + result(EXACT_PROVIDER), + ]); + + expect( + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + "nemoclaw-9090", + fixture.run, + ), + ).toEqual([RECEIPT]); + expect(fixture.spy.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], + ["sandbox", "provider", "-g", "nemoclaw-9090", "list", "alpha"], + ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], + ["sandbox", "provider", "-g", "nemoclaw-9090", "attach", "alpha", "alpha-discord-bridge"], + ["sandbox", "provider", "-g", "nemoclaw-9090", "list", "alpha"], + ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], + ]); + }); + + it("does not mutate an attachment that already exists", () => { + const fixture = queuedRunner([result(EXACT_PROVIDER), result(ATTACHED_PROVIDER)]); + + expect( + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + "nemoclaw-9090", + fixture.run, + ), + ).toEqual([]); + expect(fixture.spy).toHaveBeenCalledTimes(2); + }); + + it("does not inspect attachments for a channel without credential bindings", () => { + const fixture = queuedRunner([]); + + expect( + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "whatsapp", + "nemoclaw-9090", + fixture.run, + ), + ).toEqual([]); + expect(fixture.spy).not.toHaveBeenCalled(); + }); + + it("rejects a same-name provider with the wrong Hermes binding", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER.replace("discord-hermes-static-v1", "generic")), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + "nemoclaw-9090", + fixture.run, + ), + ).toThrow(/does not match the required 'discord-hermes-static-v1'/u); + expect(fixture.spy).toHaveBeenCalledTimes(1); + }); + + it("reports rollback failures without hiding successful absent detaches", () => { + const teamsReceipt = { + ...RECEIPT, + providerId: "provider-alpha-teams", + providerName: "alpha-teams-bridge", + }; + const fixture = queuedRunner([ + result( + EXACT_PROVIDER.replace("provider-alpha-discord", "provider-alpha-teams").replace( + "alpha-discord-bridge", + "alpha-teams-bridge", + ), + ), + result("provider not attached", 1), + result(EXACT_PROVIDER), + result("gateway unavailable", 1), + ]); + + expect( + rollbackMessagingProviderAttachments("alpha", [RECEIPT, teamsReceipt], fixture.run), + ).toEqual(["alpha-discord-bridge: gateway unavailable"]); + }); + + it("detaches a provisional attachment when confirmation fails", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result("No providers attached to sandbox alpha."), + result(EXACT_PROVIDER), + result("Attached provider alpha-discord-bridge"), + result("gateway unavailable", 1), + result(EXACT_PROVIDER), + result("Detached provider alpha-discord-bridge"), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + "nemoclaw-9090", + fixture.run, + ), + ).toThrow("gateway unavailable"); + expect(fixture.spy.mock.calls.at(-1)?.[0]).toEqual([ + "sandbox", + "provider", + "-g", + "nemoclaw-9090", + "detach", + "alpha", + "alpha-discord-bridge", + ]); + }); + + it("detaches a provisional attachment when confirmation omits it", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result("No providers attached to sandbox alpha."), + result(EXACT_PROVIDER), + result("Attached provider alpha-discord-bridge"), + result("No providers attached to sandbox alpha."), + result(EXACT_PROVIDER), + result("Detached provider alpha-discord-bridge"), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + "nemoclaw-9090", + fixture.run, + ), + ).toThrow("did not confirm provider 'alpha-discord-bridge'"); + expect(fixture.spy.mock.calls.at(-1)?.[0]).toContain("detach"); + }); + + it("does not attach or detach a provider replaced after the metadata precheck", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result("No providers attached to sandbox alpha."), + result(EXACT_PROVIDER.replace("provider-alpha-discord", "provider-replacement")), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + "nemoclaw-9090", + fixture.run, + ), + ).toThrow("changed across the attachment boundary"); + const commands = fixture.spy.mock.calls.map(([args]) => (args as string[]).join(" ")); + expect(commands.some((command) => command.includes(" attach "))).toBe(false); + expect(commands.some((command) => command.includes(" detach "))).toBe(false); + }); + + it("refuses to detach a replacement provider during rollback", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER.replace("provider-alpha-discord", "provider-replacement")), + ]); + + expect(rollbackMessagingProviderAttachments("alpha", [RECEIPT], fixture.run)).toEqual([ + "alpha-discord-bridge: provider identity changed; refusing detach", + ]); + expect(fixture.spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.ts b/src/lib/actions/sandbox/messaging-provider/attachments.ts new file mode 100644 index 00000000000..3d7ea8bfc59 --- /dev/null +++ b/src/lib/actions/sandbox/messaging-provider/attachments.ts @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * OpenShell attachment commands accept a provider name but no caller-supplied + * provider ID. The sandbox lifecycle lock serializes NemoClaw mutations. Exact + * gateway, provider ID, resource version, and credential-shape checks reject + * identity drift before attachment and compensation. Remove these checks when + * OpenShell exposes an immutable-ID attachment precondition. + */ + +import { stripAnsi } from "../../../adapters/openshell/ansi"; +import { parseProviderAttachmentNames } from "../../../adapters/openshell/provider-attachment-table"; +import type { SandboxMessagingPlan } from "../../../messaging"; +import { + matchesGatewayCredentialOnlyProviderBinding, + readGatewayProviderIdentity, + type GatewayProviderIdentity, + type GatewayProviderRunner, +} from "../../../onboard/gateway-provider-metadata"; +import { staticMessagingProviderTypeForChannel } from "../../../onboard/messaging-bridge-provider"; + +type OpenShellRunner = GatewayProviderRunner; +type OpenShellResult = ReturnType; + +export type MessagingProviderAttachmentReceipt = { + readonly credentialKey: string; + readonly gatewayName: string; + readonly providerId: string; + readonly providerName: string; + readonly providerType: string; + readonly resourceVersion: number; +}; + +function commandOutput(result: OpenShellResult): string { + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; + return stripAnsi(`${stdout ?? ""}\n${stderr ?? ""}`) + .replace(/\r/g, "") + .trim(); +} + +export { parseProviderAttachmentNames as parseMessagingProviderAttachmentNames } from "../../../adapters/openshell/provider-attachment-table"; + +function gatewayScopedArgs(args: string[], gatewayName: string): string[] { + return [...args.slice(0, 2), "-g", gatewayName, ...args.slice(2)]; +} + +function listMessagingProviderAttachments( + sandboxName: string, + gatewayName: string, + run: OpenShellRunner, +): Set { + const result = run(gatewayScopedArgs(["sandbox", "provider", "list", sandboxName], gatewayName), { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const output = commandOutput(result); + if (result.status !== 0) { + throw new Error(output || `Could not inspect providers attached to '${sandboxName}'.`); + } + try { + return new Set(parseProviderAttachmentNames(output)); + } catch (error) { + throw new Error( + `OpenShell returned invalid provider attachment metadata for '${sandboxName}': ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function channelCredentialBindings(plan: SandboxMessagingPlan, channelId: string) { + return [ + ...new Map( + plan.credentialBindings + .filter((binding) => binding.channelId === channelId) + .map((binding) => [binding.providerName, binding]), + ).values(), + ]; +} + +function providerIdentityMatchesReceipt( + identity: GatewayProviderIdentity | null, + receipt: MessagingProviderAttachmentReceipt, +): boolean { + return ( + identity?.id === receipt.providerId && + identity.resourceVersion === receipt.resourceVersion && + matchesGatewayCredentialOnlyProviderBinding(identity, { + name: receipt.providerName, + type: receipt.providerType, + credentialKey: receipt.credentialKey, + }) + ); +} + +function readMessagingProviderReceipt( + plan: SandboxMessagingPlan, + binding: SandboxMessagingPlan["credentialBindings"][number], + gatewayName: string, + run: OpenShellRunner, +): MessagingProviderAttachmentReceipt { + const identity = readGatewayProviderIdentity(binding.providerName, run, gatewayName); + const exactType = staticMessagingProviderTypeForChannel(binding.channelId, plan.agent); + const expectedType = exactType ?? identity?.type ?? "generic"; + if ( + !identity || + !matchesGatewayCredentialOnlyProviderBinding(identity, { + name: binding.providerName, + type: expectedType, + credentialKey: binding.providerEnvKey, + }) + ) { + throw new Error( + `Existing provider '${binding.providerName}' does not match the required '${expectedType}' credential binding.`, + ); + } + return { + credentialKey: binding.providerEnvKey, + gatewayName, + providerId: identity.id, + providerName: binding.providerName, + providerType: expectedType, + resourceVersion: identity.resourceVersion, + }; +} + +function assertProviderIdentityUnchanged( + receipt: MessagingProviderAttachmentReceipt, + run: OpenShellRunner, +): void { + const identity = readGatewayProviderIdentity(receipt.providerName, run, receipt.gatewayName); + if (!providerIdentityMatchesReceipt(identity, receipt)) { + throw new Error( + `Provider '${receipt.providerName}' changed across the attachment boundary. Refusing to mutate it.`, + ); + } +} + +export function rollbackMessagingProviderAttachments( + sandboxName: string, + receipts: readonly MessagingProviderAttachmentReceipt[], + run: OpenShellRunner, +): string[] { + const failures: string[] = []; + for (const receipt of [...receipts].reverse()) { + const identity = readGatewayProviderIdentity(receipt.providerName, run, receipt.gatewayName); + if (!providerIdentityMatchesReceipt(identity, receipt)) { + failures.push(`${receipt.providerName}: provider identity changed; refusing detach`); + continue; + } + const result = run( + gatewayScopedArgs( + ["sandbox", "provider", "detach", sandboxName, receipt.providerName], + receipt.gatewayName, + ), + { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const output = commandOutput(result); + if ( + result.status !== 0 && + !/\bNotFound\b|not found|not attached|already detached/i.test(output) + ) { + failures.push(`${receipt.providerName}: ${output || `detach exited ${result.status}`}`); + } + } + return failures; +} + +export function restoreChannelMessagingProviderAttachments( + sandboxName: string, + plan: SandboxMessagingPlan, + channelId: string, + gatewayName: string, + run: OpenShellRunner, +): MessagingProviderAttachmentReceipt[] { + const bindings = channelCredentialBindings(plan, channelId); + if (bindings.length === 0) return []; + const receipts = new Map( + bindings.map((binding) => [ + binding.providerName, + readMessagingProviderReceipt(plan, binding, gatewayName, run), + ]), + ); + + const attachedBefore = listMessagingProviderAttachments(sandboxName, gatewayName, run); + const newlyAttached: MessagingProviderAttachmentReceipt[] = []; + try { + for (const binding of bindings) { + if (attachedBefore.has(binding.providerName)) continue; + const receipt = receipts.get(binding.providerName); + if (!receipt) throw new Error(`Provider '${binding.providerName}' has no identity receipt.`); + assertProviderIdentityUnchanged(receipt, run); + const result = run( + gatewayScopedArgs( + ["sandbox", "provider", "attach", sandboxName, binding.providerName], + gatewayName, + ), + { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (result.status !== 0) { + throw new Error( + commandOutput(result) || `Failed to attach provider '${binding.providerName}'.`, + ); + } + newlyAttached.push(receipt); + const attachedAfter = listMessagingProviderAttachments(sandboxName, gatewayName, run); + if (!attachedAfter.has(binding.providerName)) { + throw new Error( + `OpenShell did not confirm provider '${binding.providerName}' was attached to '${sandboxName}'.`, + ); + } + assertProviderIdentityUnchanged(receipt, run); + } + return newlyAttached; + } catch (error) { + const rollbackFailures = rollbackMessagingProviderAttachments(sandboxName, newlyAttached, run); + const detail = + rollbackFailures.length > 0 ? ` Rollback failed: ${rollbackFailures.join("; ")}` : ""; + throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`); + } +} diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index b11987f5d3e..9f0b034897f 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -1240,6 +1240,7 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { "alpha", expect.any(Object), "teams", + "nemoclaw", ); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "teams", { disclosedPresetState: "absent", @@ -1284,7 +1285,15 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { Object.assign(current, updates); return true; }); - restoreMessagingProviderAttachmentsMock.mockReturnValue(["alpha-teams-bridge"]); + const restoredReceipt = { + credentialKey: "MSTEAMS_APP_PASSWORD", + gatewayName: "nemoclaw", + providerId: "provider-alpha-teams", + providerName: "alpha-teams-bridge", + providerType: "teams-openclaw-static-v1", + resourceVersion: 7, + }; + restoreMessagingProviderAttachmentsMock.mockReturnValue([restoredReceipt]); applyPresetMock.mockReturnValue(false); await expect(startSandboxChannel("alpha", { channel: "teams" })).rejects.toThrow( @@ -1296,7 +1305,7 @@ describe("Teams host-forward lifecycle (PRA-2)", () => { }); expect(registry.getDisabledChannels("alpha")).toContain("teams"); expect(rollbackMessagingProviderAttachmentsMock).toHaveBeenCalledWith("alpha", [ - "alpha-teams-bridge", + restoredReceipt, ]); expect(rebuildSandboxMock).not.toHaveBeenCalled(); expect(loggedText()).toContain("channels start teams"); diff --git a/src/lib/actions/sandbox/policy-channel-dependencies.ts b/src/lib/actions/sandbox/policy-channel-dependencies.ts index bb054a9934e..1a965537c2c 100644 --- a/src/lib/actions/sandbox/policy-channel-dependencies.ts +++ b/src/lib/actions/sandbox/policy-channel-dependencies.ts @@ -6,7 +6,8 @@ import type { SandboxMessagingPlan } from "../../messaging"; import { restoreChannelMessagingProviderAttachments, rollbackMessagingProviderAttachments, -} from "./messaging-provider-attachments"; + type MessagingProviderAttachmentReceipt, +} from "./messaging-provider/attachments"; type MessagingProviderTokenDefinition = { name: string; @@ -58,14 +59,21 @@ export const policyChannelDependencies = { sandboxName: string, plan: SandboxMessagingPlan, channelId: string, - ): string[] { - return restoreChannelMessagingProviderAttachments(sandboxName, plan, channelId); + gatewayName: string, + ): MessagingProviderAttachmentReceipt[] { + return restoreChannelMessagingProviderAttachments( + sandboxName, + plan, + channelId, + gatewayName, + runOpenshell, + ); }, rollbackMessagingProviderAttachments( sandboxName: string, - providerNames: readonly string[], + receipts: readonly MessagingProviderAttachmentReceipt[], ): string[] { - return rollbackMessagingProviderAttachments(sandboxName, providerNames); + return rollbackMessagingProviderAttachments(sandboxName, receipts, runOpenshell); }, isMessagingProviderBindingConflict( error: unknown, diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index c768716f371..c5acc9521df 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -80,6 +80,7 @@ import * as registry from "../../state/registry"; import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; import { getSandboxTargetGatewayName } from "./gateway-target"; import { ensureMessagingHostForwardAfterRebuild } from "./messaging-host-forward-lifecycle"; +import type { MessagingProviderAttachmentReceipt } from "./messaging-provider/attachments"; import { policyChannelDependencies } from "./policy-channel-dependencies"; import { refreshSandboxPolicyContextFile } from "./policy-context-refresh"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; @@ -1932,14 +1933,16 @@ async function sandboxChannelsSetEnabled( console.error(` Could not persist messaging plan for '${sandboxName}'.`); process.exit(1); } - let restoredProviderAttachments: string[] = []; + let restoredProviderAttachments: MessagingProviderAttachmentReceipt[] = []; if (!disabled) { try { + const gatewayName = getSandboxTargetGatewayName(sandboxName); restoredProviderAttachments = policyChannelDependencies.restoreChannelMessagingProviderAttachments( sandboxName, plan, canonical, + gatewayName, ); } catch (error) { console.error( diff --git a/src/lib/adapters/openshell/ansi.ts b/src/lib/adapters/openshell/ansi.ts new file mode 100644 index 00000000000..a7eccaf7344 --- /dev/null +++ b/src/lib/adapters/openshell/ansi.ts @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +export function stripAnsi(value = ""): string { + return String(value).replace(ANSI_RE, ""); +} diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 012546d0422..335ed6bd246 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -14,6 +14,7 @@ import { redirectInheritedChildStdoutToStderr } from "../../cli/stdout-guard"; import { buildSubprocessEnv } from "../../subprocess-env"; export { openshellSandboxSshHost, resolveOpenshellSandboxSshHost } from "./sandbox-ssh-host"; +export { stripAnsi } from "./ansi"; export type OpenshellSpawnSync = ( command: string, @@ -82,12 +83,6 @@ export interface CaptureOpenshellResult { signal?: NodeJS.Signals | null; } -const ANSI_RE = /\x1b\[[0-9;]*m/g; - -export function stripAnsi(value = ""): string { - return String(value).replace(ANSI_RE, ""); -} - function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/lib/adapters/openshell/provider-attachment-table.test.ts b/src/lib/adapters/openshell/provider-attachment-table.test.ts new file mode 100644 index 00000000000..dd4f442c11f --- /dev/null +++ b/src/lib/adapters/openshell/provider-attachment-table.test.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { parseProviderAttachmentNames } from "./provider-attachment-table"; + +describe("OpenShell provider attachment table", () => { + it("parses empty, populated, and ANSI-decorated attachment output", () => { + expect(parseProviderAttachmentNames("No providers attached to sandbox alpha.")).toEqual([]); + expect( + parseProviderAttachmentNames( + "\u001b[1mNAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\u001b[0m\nalpha-token generic 1 0\n", + ), + ).toEqual(["alpha-token"]); + }); + + it("rejects output without the attachment table header", () => { + expect(() => parseProviderAttachmentNames("alpha-token generic 1 0\n")).toThrow( + "missing provider attachment table header", + ); + }); + + it("rejects malformed attachment table rows", () => { + expect(() => + parseProviderAttachmentNames( + "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-token generic one zero\n", + ), + ).toThrow("invalid provider attachment table row"); + }); +}); diff --git a/src/lib/adapters/openshell/provider-attachment-table.ts b/src/lib/adapters/openshell/provider-attachment-table.ts new file mode 100644 index 00000000000..3b84ce153f0 --- /dev/null +++ b/src/lib/adapters/openshell/provider-attachment-table.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { stripAnsi } from "./ansi"; + +/** Parse the provider names from `openshell sandbox provider list`. */ +export function parseProviderAttachmentNames(output: string): string[] { + const clean = stripAnsi(output).replace(/\r/g, "").trim(); + if (/^No providers attached to sandbox\b/m.test(clean)) return []; + const lines = clean + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const headerIndex = lines.findIndex((line) => + /^NAME\s+TYPE\s+CREDENTIAL_KEYS\s+CONFIG_KEYS$/.test(line), + ); + if (headerIndex < 0) throw new Error("missing provider attachment table header"); + return lines.slice(headerIndex + 1).map((line) => { + const match = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$/); + if (!match?.[1]) throw new Error("invalid provider attachment table row"); + return match[1]; + }); +} diff --git a/src/lib/onboard/gateway-provider-metadata.test.ts b/src/lib/onboard/gateway-provider-metadata.test.ts index 020a7873d46..4f97b3ea7a4 100644 --- a/src/lib/onboard/gateway-provider-metadata.test.ts +++ b/src/lib/onboard/gateway-provider-metadata.test.ts @@ -7,7 +7,9 @@ import { inspectGatewayCredentialOnlyProviderBinding, matchesGatewayCredentialOnlyProviderBinding, matchesGatewayProviderBinding, + parseGatewayProviderIdentity, parseGatewayProviderMetadata, + readGatewayProviderIdentity, readGatewayProviderMetadata, } from "./gateway-provider-metadata"; @@ -139,6 +141,28 @@ describe("gateway provider metadata", () => { }); }); + it("parses and reads the exact gateway-scoped provider mutation identity", () => { + const runOpenshell = vi.fn(() => ({ status: 0, stdout: COMPLETE_OUTPUT })); + const expected = { + ...parseGatewayProviderMetadata(COMPLETE_OUTPUT), + id: "2ca3b7c7-eff4-4399-af5a-13c4984d7343", + resourceVersion: 1, + }; + + expect(parseGatewayProviderIdentity(COMPLETE_OUTPUT)).toEqual(expected); + expect( + readGatewayProviderIdentity("compatible-endpoint", runOpenshell, "nemoclaw-9090"), + ).toEqual(expected); + expect(runOpenshell).toHaveBeenCalledWith( + ["provider", "get", "-g", "nemoclaw-9090", "compatible-endpoint"], + { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + }); + it.each([ [ "OSC injection inside the provider name", diff --git a/src/lib/onboard/gateway-provider-metadata.ts b/src/lib/onboard/gateway-provider-metadata.ts index f5a7f20df92..909dbae74d6 100644 --- a/src/lib/onboard/gateway-provider-metadata.ts +++ b/src/lib/onboard/gateway-provider-metadata.ts @@ -8,6 +8,7 @@ const PROVIDER_PROBE_DIAGNOSTIC_LIMIT = 64 * 1024; const PROVIDER_PROBE_TIMEOUT_MS = 5_000; const MAX_PROVIDER_NAME_LENGTH = 128; const MAX_PROVIDER_TYPE_LENGTH = 64; +const MAX_PROVIDER_ID_LENGTH = 128; const MAX_PROVIDER_KEYS = 32; const MAX_PROVIDER_KEY_LENGTH = 128; const SAFE_PROVIDER_IDENTIFIER = /^[A-Za-z0-9._:-]+$/; @@ -24,6 +25,11 @@ export type GatewayProviderMetadata = { configKeys: string[]; }; +export type GatewayProviderIdentity = GatewayProviderMetadata & { + id: string; + resourceVersion: number; +}; + export type GatewayProviderBinding = { name: string; type: string; @@ -77,12 +83,12 @@ type GatewayProviderCommandResult = { signal?: unknown; }; -type GatewayProviderRunner = ( +export type GatewayProviderRunner = ( args: string[], options: { ignoreError: true; maxBuffer?: number; - suppressOutput: true; + suppressOutput?: true; stdio: ["ignore", "pipe", "pipe"]; timeout?: number; }, @@ -97,6 +103,7 @@ export type GatewayCredentialOnlyProviderInspection = type ProviderField = "Name" | "Type" | "Credential keys" | "Config keys"; const PROVIDER_FIELD_PATTERN = /^\s*(Name|Type|Credential keys|Config keys):\s*(.*?)\s*$/i; +const PROVIDER_IDENTITY_FIELD_PATTERN = /^\s*(Id|Resource version):\s*(.*?)\s*$/i; const CANONICAL_PROVIDER_FIELDS = new Map([ ["name", "Name"], ["type", "Type"], @@ -195,6 +202,38 @@ export function parseGatewayProviderMetadata(output: string): GatewayProviderMet return { name, type, credentialKeys, configKeys }; } +/** Parse the immutable ID and resource version with the provider binding shape. */ +export function parseGatewayProviderIdentity(output: string): GatewayProviderIdentity | null { + const metadata = parseGatewayProviderMetadata(output); + if (!metadata) return null; + + const fields = new Map(); + for (const rawLine of output.split(/\r?\n/u)) { + const line = rawLine.replace(ANSI_OSC_PATTERN, "").replace(ANSI_CSI_PATTERN, ""); + const match = line.match(PROVIDER_IDENTITY_FIELD_PATTERN); + if (!match) continue; + if (hasUnsafeRawProviderFieldValue(rawLine)) return null; + const field = match[1].toLowerCase(); + if (fields.has(field)) return null; + fields.set(field, match[2].trim()); + } + + const id = fields.get("id"); + const resourceVersionText = fields.get("resource version"); + if ( + !id || + !isSafeIdentifier(id, MAX_PROVIDER_ID_LENGTH) || + !resourceVersionText || + !/^\d+$/u.test(resourceVersionText) + ) { + return null; + } + const resourceVersion = Number.parseInt(resourceVersionText, 10); + if (!Number.isSafeInteger(resourceVersion) || resourceVersion < 0) return null; + + return { ...metadata, id, resourceVersion }; +} + /** Distinguish an exact credential-only binding from absence and lookup failure. */ export function inspectGatewayCredentialOnlyProviderBinding( expected: GatewayCredentialOnlyProviderBinding, @@ -251,3 +290,26 @@ export function readGatewayProviderMetadata( const metadata = parseGatewayProviderMetadata(output); return metadata?.name === name ? metadata : null; } + +/** Read one gateway-scoped provider identity for a mutation precondition. */ +export function readGatewayProviderIdentity( + name: string, + runOpenshell: GatewayProviderRunner, + gatewayName?: string | null, +): GatewayProviderIdentity | null { + if (!isSafeIdentifier(name, MAX_PROVIDER_NAME_LENGTH)) return null; + + const args = ["provider", "get"]; + if (gatewayName) args.push("-g", gatewayName); + args.push(name); + const result = runOpenshell(args, { + ignoreError: true, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) return null; + + const output = `${commandStreamText(result.stdout)}\n${commandStreamText(result.stderr)}`; + const identity = parseGatewayProviderIdentity(output); + return identity?.name === name ? identity : null; +} diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index f592f81f46a..9f67200a8a7 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -26,7 +26,6 @@ import { loadMessagingChannelPolicyPreset, materializeMessagingPolicySandboxName, } from "../messaging/channels"; -import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation"; import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; @@ -34,7 +33,6 @@ import { ROOT, run, runCapture } from "../runner"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import { redact } from "../security/redact"; import * as registry from "../state/registry"; -import { getMessagingPlanFromEntry } from "../state/registry-messaging"; import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; import { digestBaselineEntry, @@ -2929,7 +2927,7 @@ function applyPermissivePolicy(sandboxName: string): void { sandbox?.agent === "hermes" ? filterInactiveMessagingChannelPolicies( policyDocument, - getActiveChannelIdsFromPlan(getMessagingPlanFromEntry(sandbox)), + registry.getActiveMessagingChannelsFromEntry(sandbox), "hermes", ).content : policyDocument; diff --git a/src/lib/state/registry-messaging.ts b/src/lib/state/registry-messaging.ts index 279ffb4a8c5..e3f3f411cce 100644 --- a/src/lib/state/registry-messaging.ts +++ b/src/lib/state/registry-messaging.ts @@ -5,6 +5,7 @@ import { hydrateDerivedSandboxMessagingPlanFields } from "../messaging/hydration import type { SandboxMessagingPlan } from "../messaging/manifest"; import { compactSandboxMessagingPlanForPersistence } from "../messaging/persistence"; import { + getActiveChannelIdsFromPlan, getConfiguredChannelIdsFromPlan, getDisabledChannelIdsFromPlan, parseSandboxMessagingPlan, @@ -68,6 +69,12 @@ export function getConfiguredMessagingChannelsFromEntry( return getConfiguredChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); } +export function getActiveMessagingChannelsFromEntry( + entry: EntryWithMessaging | null | undefined, +): string[] { + return getActiveChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); +} + export function getDisabledMessagingChannelsFromEntry( entry: EntryWithMessaging | null | undefined, ): string[] { diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 233ccafcd81..611db5726b5 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -109,6 +109,7 @@ export type { } from "./registry/types"; export type { McpBridgeEntry, SandboxMcpState } from "./registry-mcp"; export { + getActiveMessagingChannelsFromEntry, getConfiguredMessagingChannelsFromEntry, getDisabledMessagingChannelsFromEntry, getHydratedMessagingPlanFromEntry, diff --git a/test/e2e/live/rebuild-hermes-swap.ts b/test/e2e/live/rebuild-hermes-swap.ts new file mode 100644 index 00000000000..2e70225502f --- /dev/null +++ b/test/e2e/live/rebuild-hermes-swap.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; +import { assertExitZero } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/index.ts"; +import { + HERMES_REBUILD_SWAP_BYTES, + needsHermesRebuildSwap, + parseActiveSwapBytes, +} from "../fixtures/hermes-rebuild-swap.ts"; + +const HERMES_REBUILD_SWAP_FILE = "/mnt/nemoclaw-hermes-rebuild.swap"; + +async function createHermesRebuildSwap(host: HostCliClient): Promise { + const githubActions = process.env.GITHUB_ACTIONS === "true"; + if (!githubActions) return false; + + const probeOptions = { + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }; + const current = await host.command( + "swapon", + ["--show", "--bytes", "--noheadings", "--output", "SIZE"], + { + ...probeOptions, + artifactName: "prereq-hermes-rebuild-swap-before", + }, + ); + assertExitZero(current, "inspect active swap before Hermes rebuild"); + if ( + !needsHermesRebuildSwap({ + activeSwapBytes: parseActiveSwapBytes(current.stdout), + githubActions, + }) + ) { + return false; + } + + const provision = await host.command( + "sudo", + [ + "bash", + "-c", + `set -euo pipefail +swap_file="$1" +swap_size_bytes="$2" +if test -e "$swap_file"; then + printf 'refusing to replace existing swap path: %s\n' "$swap_file" >&2 + exit 1 +fi +fallocate -l "$swap_size_bytes" "$swap_file" +chmod 0600 "$swap_file" +mkswap "$swap_file" +swapon "$swap_file"`, + "hermes-rebuild-swap", + HERMES_REBUILD_SWAP_FILE, + String(HERMES_REBUILD_SWAP_BYTES), + ], + { + ...probeOptions, + artifactName: "prereq-hermes-rebuild-swap-provision", + timeoutMs: 2 * 60_000, + }, + ); + assertExitZero(provision, "provision swap for Hermes rebuild"); + return true; +} + +async function verifyHermesRebuildSwap(host: HostCliClient): Promise { + const verified = await host.command( + "swapon", + ["--show", "--bytes", "--noheadings", "--output", "SIZE"], + { + artifactName: "prereq-hermes-rebuild-swap-after", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }, + ); + assertExitZero(verified, "inspect active swap after Hermes rebuild provisioning"); + if (parseActiveSwapBytes(verified.stdout) < HERMES_REBUILD_SWAP_BYTES) { + throw new Error("Hermes rebuild swap remains below the required capacity after provisioning"); + } +} + +async function cleanupHermesRebuildSwap(host: HostCliClient): Promise { + const result = await host.command( + "sudo", + [ + "bash", + "-c", + `set -euo pipefail +swap_file="$1" +status=0 +swapoff "$swap_file" || status=$? +rm -f -- "$swap_file" || status=$? +test ! -e "$swap_file" || status=1 +if swapon --show --noheadings --output NAME | grep -Fqx -- "$swap_file"; then + status=1 +fi +exit "$status"`, + "hermes-rebuild-swap-cleanup", + HERMES_REBUILD_SWAP_FILE, + ], + { + artifactName: "cleanup-hermes-rebuild-swap", + env: buildAvailabilityProbeEnv(), + timeoutMs: 2 * 60_000, + }, + ); + assertExitZero(result, "remove Hermes rebuild swap"); +} + +export async function prepareHermesRebuildSwap( + host: HostCliClient, + cleanup: Pick, +): Promise { + const created = await createHermesRebuildSwap(host); + if (!created) return; + cleanup.trackDisposable("remove Hermes rebuild swap", () => cleanupHermesRebuildSwap(host)); + await verifyHermesRebuildSwap(host); +} diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 86e78789332..dc29c7f834b 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -20,11 +20,6 @@ import { snapshotFile, writeJsonFile, } from "../fixtures/file-state.ts"; -import { - HERMES_REBUILD_SWAP_BYTES, - needsHermesRebuildSwap, - parseActiveSwapBytes, -} from "../fixtures/hermes-rebuild-swap.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import { listCredentialLeakPaths } from "../fixtures/phases/state-validation.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -65,6 +60,7 @@ import { import { buildRebuildHermesOldSandboxDockerfile } from "./rebuild-hermes-old-sandbox.ts"; import { REBUILD_HERMES_PHASES } from "./rebuild-hermes-phases.ts"; import { buildHermesRuntimeExecArgs } from "./rebuild-hermes-runtime-exec.ts"; +import { prepareHermesRebuildSwap } from "./rebuild-hermes-swap.ts"; import { REBUILD_HERMES_STATE } from "./rebuild-hermes-state-fixture.ts"; import { buildRebuildHermesTimingSummary, describeRunnerClass } from "./rebuild-hermes-timing.ts"; @@ -141,71 +137,6 @@ const LIVE_TIMEOUT_MS = 70 * 60_000; // generous diagnostic tail without letting a stuck child exhaust the hosted // runner by growing the fixture's in-memory stdout/stderr buffers forever. const LONG_COMMAND_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024; -const HERMES_REBUILD_SWAP_FILE = "/mnt/nemoclaw-hermes-rebuild.swap"; - -async function ensureHermesRebuildSwap(host: HostCliClient): Promise { - const githubActions = process.env.GITHUB_ACTIONS === "true"; - if (!githubActions) return; - - const probeOptions = { - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }; - const current = await host.command( - "swapon", - ["--show", "--bytes", "--noheadings", "--output", "SIZE"], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-before", - }, - ); - expectExitZero(current, "inspect active swap before Hermes rebuild"); - if ( - !needsHermesRebuildSwap({ - activeSwapBytes: parseActiveSwapBytes(current.stdout), - githubActions, - }) - ) { - return; - } - - const provision = await host.command( - "sudo", - [ - "bash", - "-c", - `set -euo pipefail -swap_file="$1" -swap_size_bytes="$2" -swapoff "$swap_file" 2>/dev/null || true -rm -f "$swap_file" -fallocate -l "$swap_size_bytes" "$swap_file" -chmod 0600 "$swap_file" -mkswap "$swap_file" -swapon "$swap_file"`, - "hermes-rebuild-swap", - HERMES_REBUILD_SWAP_FILE, - String(HERMES_REBUILD_SWAP_BYTES), - ], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-provision", - timeoutMs: 2 * 60_000, - }, - ); - expectExitZero(provision, "provision swap for Hermes rebuild"); - - const verified = await host.command( - "swapon", - ["--show", "--bytes", "--noheadings", "--output", "SIZE"], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-after", - }, - ); - expectExitZero(verified, "inspect active swap after Hermes rebuild provisioning"); - expect(parseActiveSwapBytes(verified.stdout)).toBeGreaterThanOrEqual(HERMES_REBUILD_SWAP_BYTES); -} function inspectKanbanTaskArgs(sandboxName: string): string[] { const script = [ @@ -685,7 +616,7 @@ test(STALE_BASE_REBUILD "rebuild-Hermes must invoke the checked-out CLI through NEMOCLAW_CLI_BIN", ).toBe(CLI_ENTRYPOINT); await ensureRebuildHermesHostTools(host); - await ensureHermesRebuildSwap(host); + await prepareHermesRebuildSwap(host, cleanup); const dockerInfo = await host.command("docker", ["info"], { artifactName: "prereq-docker-info", diff --git a/test/e2e/support/hermes-rebuild-swap.test.ts b/test/e2e/support/hermes-rebuild-swap.test.ts index ea4984ad43b..e50741eb82c 100644 --- a/test/e2e/support/hermes-rebuild-swap.test.ts +++ b/test/e2e/support/hermes-rebuild-swap.test.ts @@ -47,10 +47,41 @@ describe("Hermes rebuild swap", () => { path.resolve(import.meta.dirname, "../live/rebuild-hermes.test.ts"), "utf8", ); - const ensureSwap = source.indexOf("await ensureHermesRebuildSwap(host);"); + const ensureSwap = source.indexOf("await prepareHermesRebuildSwap(host, cleanup);"); const dockerProbe = source.indexOf('host.command("docker", ["info"]'); expect(ensureSwap).toBeGreaterThan(-1); expect(dockerProbe).toBeGreaterThan(ensureSwap); }); + + it("removes only the swap path created by the Hermes rebuild test", () => { + const source = fs.readFileSync( + path.resolve(import.meta.dirname, "../live/rebuild-hermes-swap.ts"), + "utf8", + ); + const cleanupStart = source.indexOf("async function cleanupHermesRebuildSwap"); + const cleanupEnd = source.indexOf("export async function prepareHermesRebuildSwap", cleanupStart); + const cleanupSource = source.slice(cleanupStart, cleanupEnd); + + expect(cleanupSource).toContain('swapoff "$swap_file"'); + expect(cleanupSource).toContain('rm -f -- "$swap_file"'); + expect(cleanupSource).toContain('assertExitZero(result, "remove Hermes rebuild swap")'); + expect(cleanupSource).not.toContain("/swapfile"); + }); + + it("registers cleanup before it verifies created swap", () => { + const source = fs.readFileSync( + path.resolve(import.meta.dirname, "../live/rebuild-hermes-swap.ts"), + "utf8", + ); + const createSwap = source.indexOf("await createHermesRebuildSwap(host)"); + const registerCleanup = source.indexOf( + 'cleanup.trackDisposable("remove Hermes rebuild swap"', + ); + const verifySwap = source.indexOf("await verifyHermesRebuildSwap(host)"); + + expect(createSwap).toBeGreaterThan(-1); + expect(registerCleanup).toBeGreaterThan(createSwap); + expect(verifySwap).toBeGreaterThan(registerCleanup); + }); }); diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index aa81a87231b..26a9c050f55 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -957,13 +957,14 @@ fi expect(releaseIdentity.run).toContain("git describe --tags --match 'v*' \"$GITHUB_SHA\""); expect(releaseIdentity.run).toContain("managed image release identity does not match"); expect(guard.run).toContain('scripts/check-production-build-args.sh "${build_args[@]}"'); + expect(guard.run).toContain('--build-arg "TARGETARCH=${{ matrix.arch }}"'); expect(build.uses).toBe("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"); expect(build.with).toMatchObject({ context: ".", file: "${{ matrix.dockerfile }}", platforms: "${{ matrix.platform }}", "build-args": - "BASE_IMAGE=${{ steps.base.outputs.ref }}\nNEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1\nNEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root\n", + "BASE_IMAGE=${{ steps.base.outputs.ref }}\nTARGETARCH=${{ matrix.arch }}\nNEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1\nNEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root\n", provenance: "mode=max", sbom: true, }); From facce3cec5aabb2fe4e569a9cbaa0ff75ace72e8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:41:40 -0700 Subject: [PATCH 19/31] fix(rebuild): preserve gateway-held channels Signed-off-by: Prekshi Vyas --- .../handlers/sandbox-messaging.test.ts | 69 ++++++++++++++++++- .../machine/handlers/sandbox-messaging.ts | 37 +++++++++- src/lib/onboard/machine/handlers/sandbox.ts | 1 + 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 5cf50806638..721040e2f71 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -361,7 +361,9 @@ function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { authoritative: false, plan: null, })), - providerMatchesGatewayCredential: vi.fn(() => false), + providerMatchesGatewayCredential: vi.fn( + (_name: string, _type: string, _credentialEnv: string) => false, + ), }; } @@ -741,6 +743,71 @@ describe("reconcileSandboxMessaging plan authority", () => { expect(result).toEqual({ plan: registryPlan, selectedChannels: ["whatsapp"] }); }); + it("preserves a gateway-held Hermes channel during an authoritative rebuild", async () => { + const registryPlan = discordPlan( + hashCredential("historical-discord-token") ?? "", + "hermes", + ); + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ + authoritative: true, + plan: registryPlan, + }); + deps.providerMatchesGatewayCredential.mockImplementation( + (name, type, credentialEnv) => + name === "alpha-discord-bridge" && + type === "discord-hermes-static-v1" && + credentialEnv === "DISCORD_BOT_TOKEN", + ); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: true, + session: completedCheckpointSession(registryPlan), + sandboxName: "alpha", + agent: { name: "hermes" }, + preserveGatewayHeldRegistrySelection: true, + deps, + }); + + expect(deps.providerMatchesGatewayCredential).toHaveBeenCalledWith( + "alpha-discord-bridge", + "discord-hermes-static-v1", + "DISCORD_BOT_TOKEN", + ); + expect(deps.note).not.toHaveBeenCalledWith( + expect.stringContaining("No host inputs configure discord"), + ); + expect(result).toEqual({ plan: registryPlan, selectedChannels: ["discord"] }); + }); + + it("does not preserve an authoritative rebuild channel with a mismatched gateway binding", async () => { + const registryPlan = discordPlan( + hashCredential("historical-discord-token") ?? "", + "hermes", + ); + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ + authoritative: true, + plan: registryPlan, + }); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: true, + session: completedCheckpointSession(registryPlan), + sandboxName: "alpha", + agent: { name: "hermes" }, + preserveGatewayHeldRegistrySelection: true, + deps, + }); + + expect(result).toEqual({ + plan: withChannelDisabled(registryPlan, "discord"), + selectedChannels: [], + }); + }); + it("uses the staged plan before a matching session plan during resume for a pending target", async () => { const sessionPlan = telegramPlan(hashCredential("123456:session-token") ?? ""); const stagedPlan = slackPlan(hashCredential("staged-slack-token") ?? ""); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index d179382d723..38d8b009ea5 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -78,6 +78,8 @@ export interface ReconcileSandboxMessagingOptions { readonly registryAuthoritySnapshot?: RegistryMessagingAuthority; readonly credentialValidationPlan?: SandboxMessagingPlan | null; readonly forceCredentialValidation?: boolean; + /** Authoritative rebuilds may preserve recorded channels backed by exact gateway bindings. */ + readonly preserveGatewayHeldRegistrySelection?: boolean; readonly deps: SandboxMessagingDeps; } @@ -227,7 +229,9 @@ function selectionFromReusablePlan( function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, - deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, + deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv"> & + Partial, "providerMatchesGatewayCredential">>, + preserveGatewayHeldRegistrySelection = false, ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host // input. Rebuild the host-backed selection so policy reconciliation can @@ -240,6 +244,27 @@ function filterUnconfiguredHostChannelsFromSelection( agent as Parameters[2], ), ); + if (preserveGatewayHeldRegistrySelection && selection.plan) { + const agentName = (agent as MessagingAgentLike | null)?.name; + for (const channelId of unconfiguredChannels) { + const bindings = selection.plan.credentialBindings.filter( + (binding) => binding.channelId === channelId, + ); + if ( + bindings.length > 0 && + bindings.every((binding) => + deps.providerMatchesGatewayCredential?.( + binding.providerName, + staticMessagingProviderTypeForChannel(binding.channelId, agentName) ?? + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + binding.providerEnvKey, + ), + ) + ) { + unconfiguredChannels.delete(channelId); + } + } + } if (unconfiguredChannels.size === 0) return selection; deps.note( ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, @@ -389,7 +414,12 @@ function selectionFromRecordedChannels( 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); + selection = filterUnconfiguredHostChannelsFromSelection( + selection, + options.agent, + options.deps, + options.preserveGatewayHeldRegistrySelection, + ); if (selection.selectedChannels.length > 0) { options.deps.note( ` [non-interactive] Reusing messaging channel configuration: ${selection.selectedChannels.join(", ")}`, @@ -426,6 +456,7 @@ async function selectionFromRegistryPlan( selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), options.agent, options.deps, + options.preserveGatewayHeldRegistrySelection, ); } const activeChannels = filterChannelNamesForCurrentAgent( @@ -454,6 +485,7 @@ async function selectionFromRegistryPlan( selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), options.agent, options.deps, + options.preserveGatewayHeldRegistrySelection, ); } options.deps.note( @@ -660,6 +692,7 @@ async function selectionFromRegistryAuthority( selection, options.agent, options.deps, + options.preserveGatewayHeldRegistrySelection, ); } if (authority.plan) return selectionFromRegistryPlan(authority.plan, options); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 7039dd650b4..787a84980e4 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -2146,6 +2146,7 @@ class SandboxStateFlow< registryAuthoritySnapshot: registryMessagingAuthority, credentialValidationPlan: messagingCredentialChanged ? messagingCredentialBaseline : null, forceCredentialValidation: messagingCredentialChanged, + preserveGatewayHeldRegistrySelection: this.options.authoritativeResumeConfig === true, deps: this.deps, }); const messagingProviderBindings = requiredMessagingProviderBindings( From ce35626b029001b2c18549150115e9ecb22ab804 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:41:40 -0700 Subject: [PATCH 20/31] fix(e2e): bind Jetson to managed publication Signed-off-by: Prekshi Vyas --- .github/workflows/e2e.yaml | 4 +- src/lib/onboard/managed-image-catalog.test.ts | 44 +++++++++++++++---- src/lib/onboard/managed-image/catalog.ts | 20 +++++---- test/e2e/live/jetson-nvmap-gpu.test.ts | 5 +++ .../support/jetson-workflow-boundary.test.ts | 13 ++++++ tools/e2e/workflow-boundary.mts | 6 +-- 6 files changed, 70 insertions(+), 22 deletions(-) diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f40d3a3489e..874954581ee 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5328,8 +5328,8 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh jetson-nvmap-gpu: - needs: generate-matrix - if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }} + needs: [base-image-publication, generate-matrix] + if: ${{ always() && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }} concurrency: group: jetson-nvmap-gpu-dispatch cancel-in-progress: false diff --git a/src/lib/onboard/managed-image-catalog.test.ts b/src/lib/onboard/managed-image-catalog.test.ts index ca86f2241da..e0da0b7905f 100644 --- a/src/lib/onboard/managed-image-catalog.test.ts +++ b/src/lib/onboard/managed-image-catalog.test.ts @@ -472,23 +472,49 @@ describe("managed image GHCR catalog", () => { ).rejects.toThrow(/source revision does not match the expected revision/); }); - it("rejects a qualification revision from a different immutable release", async () => { + it("discovers the immutable release from a qualification revision", async () => { + const publishedRelease = "v0.0.96"; const fixture = catalogFixture({ openclaw: { rootReference: REVISION, - labels: { "org.opencontainers.image.version": "v0.0.96" }, + labels: { "org.opencontainers.image.version": publishedRelease }, + }, + hermes: { labels: { "org.opencontainers.image.version": publishedRelease } }, + "langchain-deepagents-code": { + labels: { "org.opencontainers.image.version": publishedRelease }, }, }); - await expect( - resolveManagedImageCatalogFromGhcr({ - release: RELEASE, - revision: REVISION, - fetchImpl: fixture.fetchImpl, - }), - ).rejects.toThrow(/image release does not match the expected release/); + const catalog = await resolveManagedImageCatalogFromGhcr({ + release: RELEASE, + revision: REVISION, + fetchImpl: fixture.fetchImpl, + }); + + expect( + SHIPPED_MANAGED_IMAGE_AGENTS.map( + (agent) => (catalog[agent] as { source: { release: string } }).source.release, + ), + ).toEqual(SHIPPED_MANAGED_IMAGE_AGENTS.map(() => publishedRelease)); }); + it.each(["", "0.0.97", "latest"])( + "rejects malformed image release label %j", + async (release) => { + const fixture = registryFixture("openclaw", { + labels: { "org.opencontainers.image.version": release }, + }); + + await expect( + resolveManagedImageContractFromGhcr({ + agent: "openclaw", + release: RELEASE, + fetchImpl: fixture.fetchImpl, + }), + ).rejects.toThrow(/image release is not a supported release version/); + }, + ); + it("fails closed when a dependent cohort alias is torn or absent", async () => { const fixture = catalogFixture({ hermes: { missingRoot: true } }); diff --git a/src/lib/onboard/managed-image/catalog.ts b/src/lib/onboard/managed-image/catalog.ts index d5513a8b091..12d10cd7387 100644 --- a/src/lib/onboard/managed-image/catalog.ts +++ b/src/lib/onboard/managed-image/catalog.ts @@ -445,6 +445,7 @@ function validateImageLabels( expectedRelease?: string, ): { readonly cohort: ManagedImagePublicationCohort; + readonly release: string; readonly revision: string; } { if (imageConfig.os !== "linux" || imageConfig.architecture !== platformArchitecture(platform)) { @@ -474,14 +475,16 @@ function validateImageLabels( if (typeof cohort !== "string" || !COHORT_PATTERN.test(cohort)) { return invalid(`'${agent}' image publication cohort is not a supported identity`); } - if ( - expectedRelease !== undefined && - labels["org.opencontainers.image.version"] !== expectedRelease - ) { + const release = labels["org.opencontainers.image.version"]; + if (typeof release !== "string" || !RELEASE_PATTERN.test(release)) { + return invalid(`'${agent}' image release is not a supported release version`); + } + if (expectedRelease !== undefined && release !== expectedRelease) { return invalid(`'${agent}' image release does not match the expected release`); } return { cohort: cohort as ManagedImagePublicationCohort, + release, revision, }; } @@ -544,7 +547,7 @@ async function resolveManagedImageContractAtReferenceFromGhcr(options: { source: { repository: MANAGED_IMAGE_SOURCE_REPOSITORY, revision: identity.revision, - release, + release: identity.release, cohort: identity.cohort, }, startupProfileContractVersion: MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, @@ -579,6 +582,7 @@ export async function resolveManagedImageContractFromGhcr(options: { release, platform, fetchImpl, + expectedRelease: release, }), ); } @@ -606,7 +610,7 @@ export async function resolveManagedImageCatalogFromGhcr(options: { platform, fetchImpl, ...(revision === undefined ? {} : { expectedRevision: revision }), - ...(revision === undefined ? {} : { expectedRelease: release }), + ...(revision === undefined ? { expectedRelease: release } : {}), }); const cohortReference = `cohort-${openclaw.source.cohort}`; const dependentResults = await Promise.allSettled( @@ -617,11 +621,11 @@ export async function resolveManagedImageCatalogFromGhcr(options: { await resolveManagedImageContractAtReferenceFromGhcr({ agent, reference: cohortReference, - release, + release: openclaw.source.release, platform, fetchImpl, expectedCohort: openclaw.source.cohort, - ...(revision === undefined ? {} : { expectedRelease: release }), + expectedRelease: openclaw.source.release, expectedRevision: openclaw.source.revision, }), ] as const, diff --git a/test/e2e/live/jetson-nvmap-gpu.test.ts b/test/e2e/live/jetson-nvmap-gpu.test.ts index ef5fe4edea3..d9d22fadf83 100644 --- a/test/e2e/live/jetson-nvmap-gpu.test.ts +++ b/test/e2e/live/jetson-nvmap-gpu.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; +import { getBuildIdentity } from "../../../src/lib/core/version"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { cleanupWhenCommandAvailable, @@ -21,14 +22,18 @@ const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-jetson-nvmap"; const INFERENCE_API_KEY = "jetson-nvmap-e2e-key"; const INFERENCE_MODEL = "jetson-nvmap-e2e"; const TIMEOUT_MS = 50 * 60_000; +const MANAGED_IMAGE_SOURCE_REVISION = getBuildIdentity({ rootDir: REPO_ROOT }).sourceRevision; function env(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { ...buildAvailabilityProbeEnv(), + E2E_MANAGED_IMAGE_REVISION: MANAGED_IMAGE_SOURCE_REVISION, + GITHUB_ACTIONS: "true", HOME: process.env.HOME, NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NEMOCLAW_JETSON_WORKSPACE: process.env.NEMOCLAW_JETSON_WORKSPACE, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_E2E_EXPECTED_SHA: MANAGED_IMAGE_SOURCE_REVISION, NEMOCLAW_RECREATE_SANDBOX: "1", NEMOCLAW_SANDBOX_GPU: "0", NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, diff --git a/test/e2e/support/jetson-workflow-boundary.test.ts b/test/e2e/support/jetson-workflow-boundary.test.ts index 3f5e656a0c0..cedacab0c63 100644 --- a/test/e2e/support/jetson-workflow-boundary.test.ts +++ b/test/e2e/support/jetson-workflow-boundary.test.ts @@ -18,6 +18,19 @@ function validateWorkflowMutation( } describe("Jetson nvmap GPU E2E workflow boundary", () => { + it("waits for the exact managed-image publication before Jetson dispatch", () => { + const errors = validateWorkflowMutation((workflow) => { + const job = (workflow.jobs as Record)["jetson-nvmap-gpu"] as { + needs?: unknown; + }; + job.needs = "generate-matrix"; + }); + + expect(errors).toContain( + "jetson-nvmap-gpu job must depend on managed publication and generate-matrix", + ); + }); + it("keeps manual Jetson dispatch disabled by default (#8142)", () => { const inputErrors = validateWorkflowMutation((workflow) => { const triggers = (workflow.on ?? workflow[true as unknown as string]) as { diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index b3c3c401a9f..880f4b6c109 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -1741,11 +1741,11 @@ function validateAllowJetsonDispatchInput(errors: string[], dispatchInputs: Work function validateJetsonControllerBoundary(errors: string[], jobs: WorkflowRecord): void { const job = asRecord(jobs["jetson-nvmap-gpu"]); - if (job.needs !== "generate-matrix") { - errors.push("jetson-nvmap-gpu job must depend on generate-matrix"); + if (!isDeepStrictEqual(job.needs, ["base-image-publication", "generate-matrix"])) { + errors.push("jetson-nvmap-gpu job must depend on managed publication and generate-matrix"); } const trustedPushOrManualSelector = - "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }}"; + "${{ always() && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }}"; if (job.if !== trustedPushOrManualSelector) { errors.push( "jetson-nvmap-gpu job must run on trusted main pushes and require opt-in for same-repository manual selections", From c26ca332a109548958235f67cb730f770a180763 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:51:15 -0700 Subject: [PATCH 21/31] refactor(channels): isolate gateway binding check --- .../machine/handlers/sandbox-messaging.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 38d8b009ea5..7f32533632f 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -226,6 +226,29 @@ function selectionFromReusablePlan( }; } +function hasExactGatewayCredentialBindings( + plan: SandboxMessagingPlan, + channelId: string, + agentName: string | undefined, + providerMatchesGatewayCredential: + | SandboxMessagingDeps["providerMatchesGatewayCredential"] + | undefined, +): boolean { + if (!providerMatchesGatewayCredential) return false; + const bindings = plan.credentialBindings.filter((binding) => binding.channelId === channelId); + return ( + bindings.length > 0 && + bindings.every((binding) => + providerMatchesGatewayCredential( + binding.providerName, + staticMessagingProviderTypeForChannel(binding.channelId, agentName) ?? + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + binding.providerEnvKey, + ), + ) + ); +} + function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, @@ -247,18 +270,12 @@ function filterUnconfiguredHostChannelsFromSelection( if (preserveGatewayHeldRegistrySelection && selection.plan) { const agentName = (agent as MessagingAgentLike | null)?.name; for (const channelId of unconfiguredChannels) { - const bindings = selection.plan.credentialBindings.filter( - (binding) => binding.channelId === channelId, - ); if ( - bindings.length > 0 && - bindings.every((binding) => - deps.providerMatchesGatewayCredential?.( - binding.providerName, - staticMessagingProviderTypeForChannel(binding.channelId, agentName) ?? - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - binding.providerEnvKey, - ), + hasExactGatewayCredentialBindings( + selection.plan, + channelId, + agentName, + deps.providerMatchesGatewayCredential, ) ) { unconfiguredChannels.delete(channelId); From 5a1fb04ab6cf8a7922dbb0485ac94242d8a662cb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:54:21 -0700 Subject: [PATCH 22/31] refactor(channels): preserve architecture budgets --- .../provider-attachments.test.ts} | 4 ++-- .../provider-attachments.ts} | 19 +++++++++++-------- .../sandbox/policy-channel-dependencies.ts | 6 +++--- src/lib/policy/index.ts | 4 +--- src/lib/state/registry-messaging.ts | 7 +++++++ src/lib/state/registry.ts | 1 + 6 files changed, 25 insertions(+), 16 deletions(-) rename src/lib/actions/sandbox/{messaging-provider-attachments.test.ts => messaging/provider-attachments.test.ts} (98%) rename src/lib/actions/sandbox/{messaging-provider-attachments.ts => messaging/provider-attachments.ts} (92%) diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.test.ts b/src/lib/actions/sandbox/messaging/provider-attachments.test.ts similarity index 98% rename from src/lib/actions/sandbox/messaging-provider-attachments.test.ts rename to src/lib/actions/sandbox/messaging/provider-attachments.test.ts index 74511069f4f..c62288ac110 100644 --- a/src/lib/actions/sandbox/messaging-provider-attachments.test.ts +++ b/src/lib/actions/sandbox/messaging/provider-attachments.test.ts @@ -2,12 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import type { SandboxMessagingPlan } from "../../messaging"; +import type { SandboxMessagingPlan } from "../../../messaging"; import { parseMessagingProviderAttachmentNames, restoreChannelMessagingProviderAttachments, rollbackMessagingProviderAttachments, -} from "./messaging-provider-attachments"; +} from "./provider-attachments"; type OpenShellRunner = NonNullable< Parameters[3] diff --git a/src/lib/actions/sandbox/messaging-provider-attachments.ts b/src/lib/actions/sandbox/messaging/provider-attachments.ts similarity index 92% rename from src/lib/actions/sandbox/messaging-provider-attachments.ts rename to src/lib/actions/sandbox/messaging/provider-attachments.ts index f6fdda768ae..d325b5a73cb 100644 --- a/src/lib/actions/sandbox/messaging-provider-attachments.ts +++ b/src/lib/actions/sandbox/messaging/provider-attachments.ts @@ -1,17 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { stripAnsi } from "../../adapters/openshell/client"; -import { runOpenshell } from "../../adapters/openshell/runtime"; -import type { SandboxMessagingPlan } from "../../messaging"; +import type { SandboxMessagingPlan } from "../../../messaging"; import { matchesGatewayCredentialOnlyProviderBinding, readGatewayProviderMetadata, -} from "../../onboard/gateway-provider-metadata"; -import { staticMessagingProviderTypeForChannel } from "../../onboard/messaging-bridge-provider"; +} from "../../../onboard/gateway-provider-metadata"; +import { staticMessagingProviderTypeForChannel } from "../../../onboard/messaging-bridge-provider"; -type OpenShellRunner = typeof runOpenshell; +type OpenShellRunner = typeof import("../../../adapters/openshell/runtime").runOpenshell; type OpenShellResult = ReturnType; +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(value = ""): string { + return String(value).replace(ANSI_RE, ""); +} function commandOutput(result: OpenShellResult): string { const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; @@ -91,7 +94,7 @@ function assertMessagingProviderBinding( export function rollbackMessagingProviderAttachments( sandboxName: string, providerNames: readonly string[], - run: OpenShellRunner = runOpenshell, + run: OpenShellRunner, ): string[] { const failures: string[] = []; for (const providerName of [...providerNames].reverse()) { @@ -114,7 +117,7 @@ export function restoreChannelMessagingProviderAttachments( sandboxName: string, plan: SandboxMessagingPlan, channelId: string, - run: OpenShellRunner = runOpenshell, + run: OpenShellRunner, ): string[] { const bindings = channelCredentialBindings(plan, channelId); if (bindings.length === 0) return []; diff --git a/src/lib/actions/sandbox/policy-channel-dependencies.ts b/src/lib/actions/sandbox/policy-channel-dependencies.ts index bb054a9934e..263ed70a831 100644 --- a/src/lib/actions/sandbox/policy-channel-dependencies.ts +++ b/src/lib/actions/sandbox/policy-channel-dependencies.ts @@ -6,7 +6,7 @@ import type { SandboxMessagingPlan } from "../../messaging"; import { restoreChannelMessagingProviderAttachments, rollbackMessagingProviderAttachments, -} from "./messaging-provider-attachments"; +} from "./messaging/provider-attachments"; type MessagingProviderTokenDefinition = { name: string; @@ -59,13 +59,13 @@ export const policyChannelDependencies = { plan: SandboxMessagingPlan, channelId: string, ): string[] { - return restoreChannelMessagingProviderAttachments(sandboxName, plan, channelId); + return restoreChannelMessagingProviderAttachments(sandboxName, plan, channelId, runOpenshell); }, rollbackMessagingProviderAttachments( sandboxName: string, providerNames: readonly string[], ): string[] { - return rollbackMessagingProviderAttachments(sandboxName, providerNames); + return rollbackMessagingProviderAttachments(sandboxName, providerNames, runOpenshell); }, isMessagingProviderBindingConflict( error: unknown, diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index f592f81f46a..9f67200a8a7 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -26,7 +26,6 @@ import { loadMessagingChannelPolicyPreset, materializeMessagingPolicySandboxName, } from "../messaging/channels"; -import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation"; import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; @@ -34,7 +33,6 @@ import { ROOT, run, runCapture } from "../runner"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import { redact } from "../security/redact"; import * as registry from "../state/registry"; -import { getMessagingPlanFromEntry } from "../state/registry-messaging"; import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion"; import { digestBaselineEntry, @@ -2929,7 +2927,7 @@ function applyPermissivePolicy(sandboxName: string): void { sandbox?.agent === "hermes" ? filterInactiveMessagingChannelPolicies( policyDocument, - getActiveChannelIdsFromPlan(getMessagingPlanFromEntry(sandbox)), + registry.getActiveMessagingChannelsFromEntry(sandbox), "hermes", ).content : policyDocument; diff --git a/src/lib/state/registry-messaging.ts b/src/lib/state/registry-messaging.ts index 279ffb4a8c5..e3f3f411cce 100644 --- a/src/lib/state/registry-messaging.ts +++ b/src/lib/state/registry-messaging.ts @@ -5,6 +5,7 @@ import { hydrateDerivedSandboxMessagingPlanFields } from "../messaging/hydration import type { SandboxMessagingPlan } from "../messaging/manifest"; import { compactSandboxMessagingPlanForPersistence } from "../messaging/persistence"; import { + getActiveChannelIdsFromPlan, getConfiguredChannelIdsFromPlan, getDisabledChannelIdsFromPlan, parseSandboxMessagingPlan, @@ -68,6 +69,12 @@ export function getConfiguredMessagingChannelsFromEntry( return getConfiguredChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); } +export function getActiveMessagingChannelsFromEntry( + entry: EntryWithMessaging | null | undefined, +): string[] { + return getActiveChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); +} + export function getDisabledMessagingChannelsFromEntry( entry: EntryWithMessaging | null | undefined, ): string[] { diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 233ccafcd81..611db5726b5 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -109,6 +109,7 @@ export type { } from "./registry/types"; export type { McpBridgeEntry, SandboxMcpState } from "./registry-mcp"; export { + getActiveMessagingChannelsFromEntry, getConfiguredMessagingChannelsFromEntry, getDisabledMessagingChannelsFromEntry, getHydratedMessagingPlanFromEntry, From 750fe60477b55662298777400d7d4c8e4ec95823 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 14:58:01 -0700 Subject: [PATCH 23/31] refactor(e2e): isolate Hermes swap lifecycle --- test/e2e/live/rebuild-hermes-bootstrap.ts | 75 ++++++++++++++++ test/e2e/live/rebuild-hermes.test.ts | 92 +------------------- test/e2e/support/hermes-rebuild-swap.test.ts | 13 +-- 3 files changed, 85 insertions(+), 95 deletions(-) diff --git a/test/e2e/live/rebuild-hermes-bootstrap.ts b/test/e2e/live/rebuild-hermes-bootstrap.ts index cbb483c9497..2fad2d85da4 100644 --- a/test/e2e/live/rebuild-hermes-bootstrap.ts +++ b/test/e2e/live/rebuild-hermes-bootstrap.ts @@ -14,8 +14,16 @@ import { import type { SandboxBaseImageResolutionMetadata } from "../../../src/lib/sandbox-base-image/types"; import type { ArtifactSink } from "../fixtures/artifacts.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; import { assertCleanupSucceededOrAbsent } from "../fixtures/cleanup-resources.ts"; import { assertExitZero, type HostCliClient, resultText } from "../fixtures/clients/index.ts"; +import { + HERMES_REBUILD_SWAP_BYTES, + HERMES_REBUILD_SWAP_FILE, + hermesRebuildSwapCleanupArgs, + needsHermesRebuildSwap, + parseActiveSwapBytes, +} from "../fixtures/hermes-rebuild-swap.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import type { ShellProbeOutputEvent, ShellProbeResult } from "../fixtures/shell-probe.ts"; import { requireRebuildHermesCurrentBaseIdentity } from "./rebuild-hermes-base-identity.ts"; @@ -154,6 +162,73 @@ export async function createRebuildHermesDiscordProvider( assertExitZero(provider, "create Hermes Discord provider"); } +export async function ensureRebuildHermesSwap( + host: HostCliClient, + cleanup: Pick, +): Promise { + const githubActions = process.env.GITHUB_ACTIONS === "true"; + if (!githubActions) return; + const probeOptions = { env: buildAvailabilityProbeEnv(), timeoutMs: 30_000 }; + const current = await host.command( + "swapon", + ["--show", "--bytes", "--noheadings", "--output", "SIZE"], + { ...probeOptions, artifactName: "prereq-hermes-rebuild-swap-before" }, + ); + assertExitZero(current, "inspect active swap before Hermes rebuild"); + if ( + !needsHermesRebuildSwap({ + activeSwapBytes: parseActiveSwapBytes(current.stdout), + githubActions, + }) + ) { + return; + } + + const provision = await host.command( + "sudo", + [ + "bash", + "-c", + `set -euo pipefail +swap_file="$1" +swap_size_bytes="$2" +swapoff "$swap_file" 2>/dev/null || true +rm -f "$swap_file" +fallocate -l "$swap_size_bytes" "$swap_file" +chmod 0600 "$swap_file" +mkswap "$swap_file" +swapon "$swap_file"`, + "hermes-rebuild-swap", + HERMES_REBUILD_SWAP_FILE, + String(HERMES_REBUILD_SWAP_BYTES), + ], + { + ...probeOptions, + artifactName: "prereq-hermes-rebuild-swap-provision", + timeoutMs: 2 * 60_000, + }, + ); + assertExitZero(provision, "provision swap for Hermes rebuild"); + cleanup.trackDisposable("remove Hermes rebuild swap", async () => { + const removed = await host.command("sudo", hermesRebuildSwapCleanupArgs(), { + artifactName: "cleanup-hermes-rebuild-swap", + env: buildAvailabilityProbeEnv(), + timeoutMs: 2 * 60_000, + }); + assertExitZero(removed, "remove Hermes rebuild swap"); + }); + + const verified = await host.command( + "swapon", + ["--show", "--bytes", "--noheadings", "--output", "SIZE"], + { ...probeOptions, artifactName: "prereq-hermes-rebuild-swap-after" }, + ); + assertExitZero(verified, "inspect active swap after Hermes rebuild provisioning"); + if (parseActiveSwapBytes(verified.stdout) < HERMES_REBUILD_SWAP_BYTES) { + throw new Error("Hermes rebuild swap provisioning did not meet the required capacity"); + } +} + export function buildRebuildHermesCurrentBaseScript(): string { return [ '"use strict";', diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 88e02c914cc..94c075b79ea 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -20,13 +20,6 @@ import { snapshotFile, writeJsonFile, } from "../fixtures/file-state.ts"; -import { - HERMES_REBUILD_SWAP_BYTES, - HERMES_REBUILD_SWAP_FILE, - hermesRebuildSwapCleanupArgs, - needsHermesRebuildSwap, - parseActiveSwapBytes, -} from "../fixtures/hermes-rebuild-swap.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import { listCredentialLeakPaths } from "../fixtures/phases/state-validation.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -42,6 +35,7 @@ import { cleanupRebuildHermesForward as cleanupHermesForward, cleanupRebuildHermesTrackedForwards, createRebuildHermesDiscordProvider, + ensureRebuildHermesSwap, requireRebuildHermesDashboardPort, requireRebuildHermesHostedInferenceRoute, requireRebuildHermesOpenshellBin, @@ -143,77 +137,6 @@ const LIVE_TIMEOUT_MS = 70 * 60_000; // generous diagnostic tail without letting a stuck child exhaust the hosted // runner by growing the fixture's in-memory stdout/stderr buffers forever. const LONG_COMMAND_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024; -async function ensureHermesRebuildSwap(host: HostCliClient): Promise { - const githubActions = process.env.GITHUB_ACTIONS === "true"; - if (!githubActions) return false; - - const probeOptions = { - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }; - const current = await host.command( - "swapon", - ["--show", "--bytes", "--noheadings", "--output", "SIZE"], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-before", - }, - ); - expectExitZero(current, "inspect active swap before Hermes rebuild"); - if ( - !needsHermesRebuildSwap({ - activeSwapBytes: parseActiveSwapBytes(current.stdout), - githubActions, - }) - ) { - return false; - } - - const provision = await host.command( - "sudo", - [ - "bash", - "-c", - `set -euo pipefail -swap_file="$1" -swap_size_bytes="$2" -swapoff "$swap_file" 2>/dev/null || true -rm -f "$swap_file" -fallocate -l "$swap_size_bytes" "$swap_file" -chmod 0600 "$swap_file" -mkswap "$swap_file" -swapon "$swap_file"`, - "hermes-rebuild-swap", - HERMES_REBUILD_SWAP_FILE, - String(HERMES_REBUILD_SWAP_BYTES), - ], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-provision", - timeoutMs: 2 * 60_000, - }, - ); - expectExitZero(provision, "provision swap for Hermes rebuild"); - - return true; -} - -async function verifyHermesRebuildSwap(host: HostCliClient): Promise { - const probeOptions = { - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }; - const verified = await host.command( - "swapon", - ["--show", "--bytes", "--noheadings", "--output", "SIZE"], - { - ...probeOptions, - artifactName: "prereq-hermes-rebuild-swap-after", - }, - ); - expectExitZero(verified, "inspect active swap after Hermes rebuild provisioning"); - expect(parseActiveSwapBytes(verified.stdout)).toBeGreaterThanOrEqual(HERMES_REBUILD_SWAP_BYTES); -} function inspectKanbanTaskArgs(sandboxName: string): string[] { const script = [ @@ -693,18 +616,7 @@ test(STALE_BASE_REBUILD "rebuild-Hermes must invoke the checked-out CLI through NEMOCLAW_CLI_BIN", ).toBe(CLI_ENTRYPOINT); await ensureRebuildHermesHostTools(host); - const createdRebuildSwap = await ensureHermesRebuildSwap(host); - if (createdRebuildSwap) { - cleanup.trackDisposable("remove Hermes rebuild swap", async () => { - const removed = await host.command("sudo", hermesRebuildSwapCleanupArgs(), { - artifactName: "cleanup-hermes-rebuild-swap", - env: buildAvailabilityProbeEnv(), - timeoutMs: 2 * 60_000, - }); - expectExitZero(removed, "remove Hermes rebuild swap"); - }); - await verifyHermesRebuildSwap(host); - } + await ensureRebuildHermesSwap(host, cleanup); const dockerInfo = await host.command("docker", ["info"], { artifactName: "prereq-docker-info", diff --git a/test/e2e/support/hermes-rebuild-swap.test.ts b/test/e2e/support/hermes-rebuild-swap.test.ts index 6e59ac52219..1486d0880ae 100644 --- a/test/e2e/support/hermes-rebuild-swap.test.ts +++ b/test/e2e/support/hermes-rebuild-swap.test.ts @@ -49,7 +49,7 @@ describe("Hermes rebuild swap", () => { path.resolve(import.meta.dirname, "../live/rebuild-hermes.test.ts"), "utf8", ); - const ensureSwap = source.indexOf("await ensureHermesRebuildSwap(host);"); + const ensureSwap = source.indexOf("await ensureRebuildHermesSwap(host, cleanup);"); const dockerProbe = source.indexOf('host.command("docker", ["info"]'); expect(ensureSwap).toBeGreaterThan(-1); @@ -68,17 +68,20 @@ describe("Hermes rebuild swap", () => { it("registers observable cleanup before verifying created swap", () => { const source = fs.readFileSync( - path.resolve(import.meta.dirname, "../live/rebuild-hermes.test.ts"), + path.resolve(import.meta.dirname, "../live/rebuild-hermes-bootstrap.ts"), "utf8", ); - const ensureSwap = source.indexOf("const createdRebuildSwap = await ensureHermesRebuildSwap(host);"); + const ensureSwap = source.indexOf("export async function ensureRebuildHermesSwap("); const registerCleanup = source.indexOf( 'cleanup.trackDisposable("remove Hermes rebuild swap"', ensureSwap, ); - const verifySwap = source.indexOf("await verifyHermesRebuildSwap(host);", registerCleanup); + const verifySwap = source.indexOf( + 'artifactName: "prereq-hermes-rebuild-swap-after"', + registerCleanup, + ); const observableFailure = source.indexOf( - 'expectExitZero(removed, "remove Hermes rebuild swap");', + 'assertExitZero(removed, "remove Hermes rebuild swap");', registerCleanup, ); From 4f5ec8667dc554883addcb94f4e327d3c58ef0aa Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 15:04:35 -0700 Subject: [PATCH 24/31] fix(e2e): close latest review and image failures Signed-off-by: Prekshi Vyas --- .../validate-progressive-tool-disclosure.py | 21 ++++++ .../messaging-provider/attachments.test.ts | 33 +++++++++- .../sandbox/messaging-provider/attachments.ts | 2 + src/lib/shields/index.ts | 16 ++++- src/lib/shields/permissive-runtime.ts | 65 +++++++++---------- test/langchain-deepagents-code-image.test.ts | 33 +++++----- test/permissive-runtime.test.ts | 41 ++++++++++-- 7 files changed, 153 insertions(+), 58 deletions(-) diff --git a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py index 60d7029a447..35546efb8c8 100644 --- a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py +++ b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py @@ -889,6 +889,18 @@ def direct_probe(value: str) -> str: executions.append(value) return "direct-proof" + # Match the exact metadata shape emitted by the pinned MCP wrapper. Without + # coherent read-only hints, the headless MCP guard correctly rejects this + # fixture before the direct executor can prove the disclosure mode. + direct_probe.metadata = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + "_deepagents_code_mcp": True, + "_deepagents_code_mcp_server": "direct-runtime-validator", + } + info = MCPServerInfo( name="direct-runtime-validator", transport="http", @@ -1027,6 +1039,15 @@ def isolated_probe() -> str: """Return an isolated probe capability.""" return "isolated-proof" + isolated_probe.metadata = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + "_deepagents_code_mcp": True, + "_deepagents_code_mcp_server": "runtime-validator", + } + model = ScriptedModel(scenario="subagent") info = MCPServerInfo( name="runtime-validator", diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.test.ts b/src/lib/actions/sandbox/messaging-provider/attachments.test.ts index 0c8439dfc5c..52635870051 100644 --- a/src/lib/actions/sandbox/messaging-provider/attachments.test.ts +++ b/src/lib/actions/sandbox/messaging-provider/attachments.test.ts @@ -120,7 +120,11 @@ describe("messaging provider attachment lifecycle", () => { }); it("does not mutate an attachment that already exists", () => { - const fixture = queuedRunner([result(EXACT_PROVIDER), result(ATTACHED_PROVIDER)]); + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result(ATTACHED_PROVIDER), + result(EXACT_PROVIDER), + ]); expect( restoreChannelMessagingProviderAttachments( @@ -131,7 +135,32 @@ describe("messaging provider attachment lifecycle", () => { fixture.run, ), ).toEqual([]); - expect(fixture.spy).toHaveBeenCalledTimes(2); + expect(fixture.spy.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], + ["sandbox", "provider", "-g", "nemoclaw-9090", "list", "alpha"], + ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], + ]); + }); + + it("rejects identity drift for an attachment that already exists", () => { + const fixture = queuedRunner([ + result(EXACT_PROVIDER), + result(ATTACHED_PROVIDER), + result(EXACT_PROVIDER.replace("provider-alpha-discord", "provider-replacement")), + ]); + + expect(() => + restoreChannelMessagingProviderAttachments( + "alpha", + hermesDiscordPlan(), + "discord", + "nemoclaw-9090", + fixture.run, + ), + ).toThrow("changed across the attachment boundary"); + const commands = fixture.spy.mock.calls.map(([args]) => (args as string[]).join(" ")); + expect(commands.some((command) => command.includes(" attach "))).toBe(false); + expect(commands.some((command) => command.includes(" detach "))).toBe(false); }); it("does not inspect attachments for a channel without credential bindings", () => { diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.ts b/src/lib/actions/sandbox/messaging-provider/attachments.ts index 3d7ea8bfc59..df61e3c4a1f 100644 --- a/src/lib/actions/sandbox/messaging-provider/attachments.ts +++ b/src/lib/actions/sandbox/messaging-provider/attachments.ts @@ -215,6 +215,8 @@ export function restoreChannelMessagingProviderAttachments( `OpenShell did not confirm provider '${binding.providerName}' was attached to '${sandboxName}'.`, ); } + } + for (const receipt of receipts.values()) { assertProviderIdentityUnchanged(receipt, run); } return newlyAttached; diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 83d7964270a..cf56ac0b035 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -64,6 +64,7 @@ const { const { assertLegacyMcpPolicyRestoreSafe, buildDeadlineRuntimeManagedMcpPolicy, + buildRegisteredRuntimePermissivePolicy, buildRuntimeManagedMcpPolicy, buildRuntimePermissivePolicy, hasManagedMcpPolicyClaims, @@ -5092,12 +5093,21 @@ function shieldsDownWithoutHostLock( // policyYaml is the pre-parsed body we already captured for the // snapshot above — reuse it instead of re-fetching. Exact generated MCP // entries are overlaid without copying any unrelated live egress. - policyFile = buildRuntimePermissivePolicy(basePath, { + const permissiveDeps = { livePolicyYaml: policyYaml, managedMcpPolicies, readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), - ...(target.agentName === "hermes" ? { sandboxName } : {}), - }); + }; + if (target.agentName === "hermes") { + const { sandbox } = resolveRegisteredSandboxAgentAuthority(sandboxName); + policyFile = buildRegisteredRuntimePermissivePolicy(basePath, { + ...permissiveDeps, + sandboxEntry: sandbox, + sandboxName, + }); + } else { + policyFile = buildRuntimePermissivePolicy(basePath, permissiveDeps); + } policyFileIsTemp = policyFile !== basePath; } else if (fs.existsSync(policyName)) { const basePath = path.resolve(policyName); diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index b883ee8c1aa..b8fa9cc7f1a 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -19,14 +19,12 @@ import type { import { filterInactiveMessagingChannelPolicies, materializeMessagingPolicySandboxName, - messagingChannelsPresentInPolicy, } from "../messaging/channels/policy"; import { cleanupTempDir, secureTempFile } from "../onboard/temp-files"; +import { getActiveMessagingChannelsFromEntry } from "../state/registry-messaging"; +import type { SandboxEntry } from "../state/registry/types"; -export { - assertLegacyMcpPolicyRestoreSafe, - isManagedMcpPolicyKey, -} from "./mcp-policy-transition"; +export { assertLegacyMcpPolicyRestoreSafe, isManagedMcpPolicyKey } from "./mcp-policy-transition"; import { composeDeadlineManagedMcpPolicies, @@ -100,6 +98,31 @@ export interface PermissiveRuntimeDeps { // binding. Supplying the target name makes composition fail closed unless // every placeholder can be materialized before the policy is staged. sandboxName?: string; + // Persisted manifest state is the enablement authority. Live policy can be + // stale during a transition and must never reactivate a disabled channel. + activeMessagingChannels?: readonly string[]; +} + +export interface RegisteredPermissiveRuntimeDeps extends Omit< + PermissiveRuntimeDeps, + "activeMessagingChannels" | "sandboxName" +> { + sandboxEntry: SandboxEntry; + sandboxName: string; +} + +export function buildRegisteredRuntimePermissivePolicy( + basePermissivePath: string, + deps: RegisteredPermissiveRuntimeDeps, +): string { + if (deps.sandboxEntry.name !== deps.sandboxName || deps.sandboxEntry.agent !== "hermes") { + throw new Error("Cannot compose Hermes Shields-down policy without exact registry authority"); + } + const { sandboxEntry, ...runtimeDeps } = deps; + return buildRuntimePermissivePolicy(basePermissivePath, { + ...runtimeDeps, + activeMessagingChannels: getActiveMessagingChannelsFromEntry(sandboxEntry), + }); } export function buildRuntimePermissivePolicy( @@ -110,11 +133,9 @@ export function buildRuntimePermissivePolicy( const liveRw = readStringList(live, "read_write"); const liveRo = readStringList(live, "read_only"); const managedMcpPolicies = deps.managedMcpPolicies ?? []; - const discordProviderName = deps.sandboxName - ? `${deps.sandboxName}-discord-bridge` - : null; + const activeMessagingChannels = deps.activeMessagingChannels ?? []; const preserveDiscordBinding = - discordProviderName !== null && policyUsesCredentialProvider(live, discordProviderName); + deps.sandboxName !== undefined && activeMessagingChannels.includes("discord"); // No live startup-sealed or filesystem state to carry forward — keep the // static path so the caller's apply path is unchanged unless exact managed @@ -152,7 +173,7 @@ export function buildRuntimePermissivePolicy( } baseYaml = filterInactiveMessagingChannelPolicies( materialized, - messagingChannelsPresentInPolicy(deps.livePolicyYaml, "hermes"), + activeMessagingChannels, "hermes", ).content; } @@ -362,30 +383,6 @@ function safeYamlObject(text: string): Record | null { return null; } -function policyUsesCredentialProvider( - policy: Record | null, - providerName: string, -): boolean { - const networkPolicies = policy?.network_policies; - if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { - return false; - } - for (const networkPolicy of Object.values(networkPolicies)) { - if (!networkPolicy || typeof networkPolicy !== "object" || Array.isArray(networkPolicy)) { - continue; - } - const endpoints = (networkPolicy as Record).endpoints; - if (!Array.isArray(endpoints)) continue; - for (const endpoint of endpoints) { - if (!endpoint || typeof endpoint !== "object" || Array.isArray(endpoint)) continue; - const binding = (endpoint as Record).credential_binding; - if (!binding || typeof binding !== "object" || Array.isArray(binding)) continue; - if ((binding as Record).provider === providerName) return true; - } - } - return false; -} - function readStringList( root: Record | null, key: "read_only" | "read_write", diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 24967aa5978..5fc76299b60 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -99,10 +99,7 @@ function pythonStringMap(source: string, constantName: string): Record, -): void { +function expectVersionsMatchLock(requirementsLock: string, versions: Record): void { expect(versions, "deepagents-code must be present in the version map").toHaveProperty( "deepagents-code", ); @@ -123,16 +120,19 @@ const TARGETED_ADVISORY_VERSIONS = [ ] as const; describe("targeted dependency advisory review", () => { - it.each(TARGETED_ADVISORY_VERSIONS)("documents the reviewed %s %s pin", (distribution, version) => { - const normalizedDistribution = distribution.replaceAll("-", "[-_]"); - const normalizedVersion = version.replaceAll(".", "\\."); - expect(readAgentFile("dependency-review.md")).toMatch( - new RegExp( - `(?:^|[^A-Za-z0-9_-])${normalizedDistribution}\\s+${normalizedVersion}(?=[^0-9.]|$)`, - "im", - ), - ); - }); + it.each(TARGETED_ADVISORY_VERSIONS)( + "documents the reviewed %s %s pin", + (distribution, version) => { + const normalizedDistribution = distribution.replaceAll("-", "[-_]"); + const normalizedVersion = version.replaceAll(".", "\\."); + expect(readAgentFile("dependency-review.md")).toMatch( + new RegExp( + `(?:^|[^A-Za-z0-9_-])${normalizedDistribution}\\s+${normalizedVersion}(?=[^0-9.]|$)`, + "im", + ), + ); + }, + ); }); function writeMinimalWheel(directory: string): string { @@ -1173,6 +1173,7 @@ describe("LangChain Deep Agents Code image contracts", () => { it("keeps image validator versions aligned with the reviewed lockfile", () => { const requirementsLock = readAgentFile("requirements.lock"); + const progressiveValidator = readAgentFile("validate-progressive-tool-disclosure.py"); const pluginMetadata = readAgentFile("profile-plugin/pyproject.toml"); const pluginVersion = pluginMetadata.match(/^version = "([^"]+)"$/m)?.[1]; expect(pluginVersion).toBe("0.1.0"); @@ -1185,8 +1186,10 @@ describe("LangChain Deep Agents Code image contracts", () => { expectVersionsMatchLock(requirementsLock, profileValidatorVersions); expectVersionsMatchLock( requirementsLock, - pythonStringMap(readAgentFile("validate-progressive-tool-disclosure.py"), "PINNED_VERSIONS"), + pythonStringMap(progressiveValidator, "PINNED_VERSIONS"), ); + expect(progressiveValidator).toContain('"_deepagents_code_mcp": True'); + expect(progressiveValidator).toContain('"readOnlyHint": True'); const observabilityValidator = readAgentFile("validate-observability.py"); const observabilityVersion = observabilityValidator.match( diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index fa5f1be7a23..8600eebfb95 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -8,9 +8,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import { + buildRegisteredRuntimePermissivePolicy, buildRuntimePermissivePolicy, type ExactManagedMcpPolicy, } from "../src/lib/shields/permissive-runtime.js"; +import type { SandboxEntry } from "../src/lib/state/registry/types.js"; +import { makeMessagingPlan } from "./helpers/messaging-plan-fixtures.js"; const BASE_PERMISSIVE = YAML.stringify({ filesystem_policy: { @@ -58,6 +61,22 @@ const HERMES_DISCORD_PERMISSIVE = YAML.stringify({ const tempFilesToClean: string[] = []; +function hermesRegistryEntry(disabledChannels: readonly "discord"[] = []): SandboxEntry { + return { + name: "hermes-box", + agent: "hermes", + messaging: { + schemaVersion: 1, + plan: makeMessagingPlan({ + agent: "hermes", + channels: ["discord"], + disabledChannels, + sandboxName: "hermes-box", + }), + }, + }; +} + function trackTempForCleanup(out: string, basePath: string): void { // Defensive: if the helper degrades to the static base path we must // never try to `rm -rf` its parent dir — that would target the @@ -86,7 +105,7 @@ afterEach(() => { describe("buildRuntimePermissivePolicy (#3942)", () => { it("keeps the Hermes Discord provider binding in Shields down", () => { let stagedPolicy = ""; - const out = buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { + const out = buildRegisteredRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { livePolicyYaml: YAML.stringify({ network_policies: { discord: { @@ -100,6 +119,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }, }), readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, + sandboxEntry: hermesRegistryEntry(), sandboxName: "hermes-box", writeTempPolicy: (yaml) => { stagedPolicy = yaml; @@ -132,11 +152,23 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(stagedPolicy).not.toContain("{sandboxName}"); }); - it("removes inactive Hermes Discord bindings before Shields down", () => { + it("removes stale live Hermes Discord bindings when persisted state disables Discord", () => { let stagedPolicy = ""; - const out = buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { - livePolicyYaml: BASE_PERMISSIVE, + const out = buildRegisteredRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { + livePolicyYaml: YAML.stringify({ + network_policies: { + discord: { + endpoints: [ + { + host: "discord.com", + credential_binding: { provider: "hermes-box-discord-bridge" }, + }, + ], + }, + }, + }), readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, + sandboxEntry: hermesRegistryEntry(["discord"]), sandboxName: "hermes-box", writeTempPolicy: (yaml) => { stagedPolicy = yaml; @@ -166,6 +198,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }, }), readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, + activeMessagingChannels: ["discord"], sandboxName: "bad:provider", writeTempPolicy, }), From f142c0e56fad4694d24fa18787a9e9a15107ad1b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 15:53:18 -0700 Subject: [PATCH 25/31] docs(messaging): document provider restoration order Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/manage-messaging-channels.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manage-sandboxes/manage-messaging-channels.mdx b/docs/manage-sandboxes/manage-messaging-channels.mdx index 626b49deee1..fba0bf5f59f 100644 --- a/docs/manage-sandboxes/manage-messaging-channels.mdx +++ b/docs/manage-sandboxes/manage-messaging-channels.mdx @@ -113,8 +113,8 @@ The next rebuild reuses the bridge provider without requiring the service-accoun Hermes Google Chat does not use the dedicated webhook endpoint or `$$nemoclaw tunnel` commands. -When `channels start` re-enables a channel, NemoClaw reapplies the matching built-in policy preset before rebuild. -If policy restoration fails, the command keeps the channel disabled and exits without rebuilding into a partially active state. +When `channels start` re-enables a channel, NemoClaw first restores its required OpenShell credential-provider attachments, then reapplies the matching built-in policy preset before rebuild. +If attachment or policy restoration fails, the command keeps the channel disabled and exits without rebuilding into a partially active state. ## Avoid Cross-Sandbox Conflicts From af28738b98d79b82f2fdc44fc5a3186474ab620f Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 16:14:43 -0700 Subject: [PATCH 26/31] fix(e2e): build generic GPU workload from PR source Signed-off-by: Prekshi Vyas --- test/e2e/live/llama-cpp-generic-gpu.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/e2e/live/llama-cpp-generic-gpu.test.ts b/test/e2e/live/llama-cpp-generic-gpu.test.ts index 3ef410b755e..a55070d5d54 100644 --- a/test/e2e/live/llama-cpp-generic-gpu.test.ts +++ b/test/e2e/live/llama-cpp-generic-gpu.test.ts @@ -42,6 +42,9 @@ function env(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const selected: NodeJS.ProcessEnv = { ...buildAvailabilityProbeEnv(process.env), NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + // PR images are published by digest only. Build the workload from this + // exact checkout instead of resolving the unreleased package-version tag. + NEMOCLAW_FROM_DOCKERFILE: path.join(REPO_ROOT, "Dockerfile"), NEMOCLAW_LLAMACPP_RECIPE: RECIPE_ID, NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_PROVIDER: "install-llama-cpp", From 7351a53847261afb5b0d095340d12b6b6c203a88 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 16:55:10 -0700 Subject: [PATCH 27/31] fix(e2e): close qualification review findings Signed-off-by: Prekshi Vyas --- .github/workflows/pr-self-hosted.yaml | 92 ++++++++++- .../patch-managed-deepagents-code.py | 4 +- .../progressive_tool_disclosure.py | 23 ++- .../validate-progressive-tool-disclosure.py | 28 +++- .../manage-messaging-channels.mdx | 3 +- .../messaging-provider/attachments.test.ts | 10 -- .../sandbox/messaging-provider/attachments.ts | 2 - .../onboard/gateway-provider-metadata.test.ts | 23 ++- src/lib/onboard/gateway-provider-metadata.ts | 37 ++--- test/e2e/RETRY_INVENTORY.md | 1 + test/e2e/live/llama-cpp-generic-gpu.test.ts | 3 - test/e2e/live/rebuild-hermes-swap.ts | 13 +- .../support/base-image-publication.test.ts | 21 +++ test/e2e/support/hermes-rebuild-swap.test.ts | 144 ++++++++++++++---- .../pr-managed-image-publication.test.ts | 72 ++++++++- .../pr-self-hosted-llama-selector.test.ts | 89 ++++++++++- test/langchain-deepagents-code-image.test.ts | 55 ++++++- tools/e2e/base-image-publication.mts | 4 +- tools/e2e/pr-managed-image-publication.mts | 100 ++++++++++-- 19 files changed, 623 insertions(+), 101 deletions(-) diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index 63ad377faae..821bd497ed2 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -32,6 +32,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: + base_sha: ${{ steps.changed.outputs.base_sha }} + candidate_repository: ${{ steps.changed.outputs.candidate_repository }} + pr_number: ${{ steps.changed.outputs.pr_number }} selected: ${{ steps.changed.outputs.selected }} steps: - id: changed @@ -47,6 +50,12 @@ jobs: } pr_number="${BASH_REMATCH[1]}" pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number")" + base_sha="$(jq -er '.base.sha | select(test("^[a-f0-9]{40}$"))' <<<"$pr_json")" + candidate_repository="$(jq -er '.head.repo.full_name | strings | select(length > 0)' <<<"$pr_json")" + [[ "$candidate_repository" == "$GITHUB_REPOSITORY" ]] || { + echo "::error::Copied PR branch must come from the workflow repository" >&2 + exit 1 + } head_sha="$(jq -er '.head.sha | select(test("^[a-f0-9]{40}$"))' <<<"$pr_json")" [[ "$head_sha" == "$GITHUB_SHA" ]] || { echo "::error::Copied PR branch SHA does not match the current PR head" >&2 @@ -79,14 +88,80 @@ jobs: else selected=false fi + printf 'base_sha=%s\n' "$base_sha" >>"$GITHUB_OUTPUT" + printf 'candidate_repository=%s\n' "$candidate_repository" >>"$GITHUB_OUTPUT" + printf 'pr_number=%s\n' "$pr_number" >>"$GITHUB_OUTPUT" printf 'selected=%s\n' "$selected" >>"$GITHUB_OUTPUT" + resolve-llama-cpp-managed-images: + name: Resolve exact PR managed images for llama.cpp GPU + needs: select-llama-cpp-generic-gpu + if: ${{ needs.select-llama-cpp-generic-gpu.outputs.selected == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 70 + outputs: + catalog_written: ${{ steps.catalog.outputs.catalog_written }} + permissions: + actions: read + contents: read + pull-requests: read + steps: + - name: Checkout exact PR head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.19.0 + cache: npm + + - name: Install exact catalog resolver dependencies + run: npm ci --ignore-scripts + + - id: catalog + name: Wait for exact PR managed-image catalog + env: + BASE_SHA: ${{ needs.select-llama-cpp-generic-gpu.outputs.base_sha }} + CANDIDATE_REPOSITORY: ${{ needs.select-llama-cpp-generic-gpu.outputs.candidate_repository }} + CANDIDATE_SHA: ${{ github.sha }} + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.select-llama-cpp-generic-gpu.outputs.pr_number }} + shell: bash + run: | + set -euo pipefail + catalog_path="${RUNNER_TEMP}/pr-managed-image-catalog.json" + node --experimental-strip-types --no-warnings \ + tools/e2e/pr-managed-image-publication.mts wait \ + "$catalog_path" + if [[ -e "$catalog_path" ]]; then + printf 'catalog_written=true\n' >>"$GITHUB_OUTPUT" + else + printf 'catalog_written=false\n' >>"$GITHUB_OUTPUT" + fi + + - name: Upload exact PR managed-image catalog + if: ${{ steps.catalog.outputs.catalog_written == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: llama-cpp-pr-managed-catalog-${{ github.sha }} + path: ${{ runner.temp }}/pr-managed-image-catalog.json + if-no-files-found: error + retention-days: 1 + llama-cpp-generic-gpu: name: llama.cpp on generic NVIDIA GPU - needs: select-llama-cpp-generic-gpu + needs: + - select-llama-cpp-generic-gpu + - resolve-llama-cpp-managed-images if: ${{ needs.select-llama-cpp-generic-gpu.outputs.selected == 'true' }} runs-on: linux-amd64-gpu-rtxpro6000-latest-1 timeout-minutes: 120 + permissions: + actions: read + contents: read env: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/llama-cpp-generic-gpu E2E_JOB: "1" @@ -109,6 +184,21 @@ jobs: persist-credentials: false ref: ${{ github.sha }} + - name: Download exact PR managed-image catalog + if: ${{ needs.resolve-llama-cpp-managed-images.outputs.catalog_written == 'true' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: llama-cpp-pr-managed-catalog-${{ github.sha }} + path: ${{ runner.temp }}/pr-managed-image-catalog + + - name: Bind exact PR managed-image catalog + if: ${{ needs.resolve-llama-cpp-managed-images.outputs.catalog_written == 'true' }} + shell: bash + run: >- + printf 'NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG=%s\n' + "${RUNNER_TEMP}/pr-managed-image-catalog/pr-managed-image-catalog.json" + >>"$GITHUB_ENV" + - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index c6857596227..1a6cd37b390 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -721,7 +721,9 @@ def create_cli_agent(model, assistant_id, *args, **kwargs): ) assert_unique_callable_tool_names( - kwargs.get("tools"), kwargs.get("mcp_server_info") + kwargs.get("tools"), + kwargs.get("mcp_server_info"), + kwargs.get("mcp_tools"), ) # Deep Agents Code 0.1.55 passes the exact loaded MCP tool objects # separately from the status-oriented server metadata. The metadata can be diff --git a/agents/langchain-deepagents-code/progressive_tool_disclosure.py b/agents/langchain-deepagents-code/progressive_tool_disclosure.py index 57799bb38ce..334c0a5fc73 100644 --- a/agents/langchain-deepagents-code/progressive_tool_disclosure.py +++ b/agents/langchain-deepagents-code/progressive_tool_disclosure.py @@ -280,6 +280,7 @@ def _tool_description(tool: BaseTool | dict[str, Any]) -> str: def assert_unique_callable_tool_names( tools: Sequence[object] | None, mcp_server_info: Sequence[object] | None, + mcp_tools: Sequence[object] | None = None, ) -> None: """Reject ambiguous or non-managed registrations before graph creation. @@ -287,8 +288,9 @@ def assert_unique_callable_tool_names( registry keyed by resolved callable name. Its model schema selection and executor lookup do not share the same duplicate-name rule, so accepting two implementations can bind one schema and execute another. Keep the executor - registry and MCP metadata as separate views: one loaded MCP tool normally - appears once in each, while duplicates within either view are ambiguous. + registry, loaded MCP tools, and MCP metadata as separate views: one loaded + MCP tool normally appears in each view, while duplicates within one view are + ambiguous. """ collisions: set[str] = set() registered_owners: dict[str, list[str]] = {} @@ -334,6 +336,23 @@ def assert_unique_callable_tool_names( f"({', '.join(owners)})" ) + loaded_mcp_owners: dict[str, list[str]] = {} + for index, tool in enumerate(mcp_tools or ()): + name = _tool_name(tool) + if name is None: + continue + owner = f"loaded MCP tool[{index}]" + loaded_mcp_owners.setdefault(name, []).append(owner) + if name in CORE_TOOL_NAMES: + collisions.add(f"{owner} is a non-managed owner of reserved name {name!r}") + + for name, owners in loaded_mcp_owners.items(): + if len(owners) > 1: + collisions.add( + f"resolved callable name {name!r} has multiple loaded MCP implementations " + f"({', '.join(owners)})" + ) + if collisions: detail = "; ".join(sorted(collisions)) raise RuntimeError( diff --git a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py index 35546efb8c8..3b7af00d647 100644 --- a/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py +++ b/agents/langchain-deepagents-code/validate-progressive-tool-disclosure.py @@ -770,6 +770,7 @@ def probe(value: str = "") -> str: "progressive", [regular_a, regular_b], [], + [], ), "regular_mcp": ( "progressive", @@ -786,6 +787,7 @@ def probe(value: str = "") -> str: ), ) ], + [], ), "cross_mcp": ( "progressive", @@ -803,11 +805,13 @@ def probe(value: str = "") -> str: ) for server in ("alpha", "alpha_beta") ], + [], ), "reserved_progressive": ( "progressive", [reserved_regular], [], + [], ), "reserved_mcp": ( "progressive", @@ -824,16 +828,34 @@ def probe(value: str = "") -> str: ), ) ], + [], ), "duplicate_direct": ( "direct", [regular_a, regular_b], [], + [], ), "reserved_direct": ( "direct", [collision_tool("execute", "reserved-direct")], [], + [], + ), + "duplicate_loaded_mcp": ( + "direct", + [], + [], + [ + collision_tool("loaded_duplicate", "loaded-a"), + collision_tool("loaded_duplicate", "loaded-b"), + ], + ), + "reserved_loaded_mcp": ( + "direct", + [], + [], + [collision_tool("execute", "reserved-loaded")], ), } original_cli_factory = agent_module._nemoclaw_original_create_cli_agent @@ -848,7 +870,7 @@ def forbidden_original(*args: Any, **kwargs: Any) -> None: previous = os.environ.get("NEMOCLAW_TOOL_DISCLOSURE") try: errors: dict[str, str] = {} - for label, (mode, tools, info) in collision_cases.items(): + for label, (mode, tools, info, mcp_tools) in collision_cases.items(): os.environ["NEMOCLAW_TOOL_DISCLOSURE"] = mode try: create_cli_agent( @@ -856,6 +878,7 @@ def forbidden_original(*args: Any, **kwargs: Any) -> None: assistant_id="callable-namespace-validator", tools=tools, mcp_server_info=info, + mcp_tools=mcp_tools, ) except RuntimeError as exc: errors[label] = str(exc) @@ -878,6 +901,9 @@ def forbidden_original(*args: Any, **kwargs: Any) -> None: assert "reserved name 'search_tools'" in errors["reserved_mcp"] assert "multiple registered implementations" in errors["duplicate_direct"] assert "reserved name 'execute'" in errors["reserved_direct"] + assert "multiple loaded MCP implementations" in errors["duplicate_loaded_mcp"] + assert "loaded MCP tool[0]" in errors["reserved_loaded_mcp"] + assert "reserved name 'execute'" in errors["reserved_loaded_mcp"] def _validate_direct_mode_execution() -> None: diff --git a/docs/manage-sandboxes/manage-messaging-channels.mdx b/docs/manage-sandboxes/manage-messaging-channels.mdx index fba0bf5f59f..e82592fa89d 100644 --- a/docs/manage-sandboxes/manage-messaging-channels.mdx +++ b/docs/manage-sandboxes/manage-messaging-channels.mdx @@ -114,7 +114,8 @@ Hermes Google Chat does not use the dedicated webhook endpoint or `$$nemoclaw tu When `channels start` re-enables a channel, NemoClaw first restores its required OpenShell credential-provider attachments, then reapplies the matching built-in policy preset before rebuild. -If attachment or policy restoration fails, the command keeps the channel disabled and exits without rebuilding into a partially active state. +If attachment or policy restoration fails, NemoClaw attempts to restore the disabled plan and exits without rebuilding. +If that persistence operation also fails, follow the recovery guidance and verify or restore the disabled plan before continuing. ## Avoid Cross-Sandbox Conflicts diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.test.ts b/src/lib/actions/sandbox/messaging-provider/attachments.test.ts index 52635870051..1d7b23b5644 100644 --- a/src/lib/actions/sandbox/messaging-provider/attachments.test.ts +++ b/src/lib/actions/sandbox/messaging-provider/attachments.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it, vi } from "vitest"; import type { SandboxMessagingPlan } from "../../../messaging"; import { - parseMessagingProviderAttachmentNames, restoreChannelMessagingProviderAttachments, rollbackMessagingProviderAttachments, type MessagingProviderAttachmentReceipt, @@ -81,15 +80,6 @@ const ATTACHED_PROVIDER = [ ].join("\n"); describe("messaging provider attachment lifecycle", () => { - it("parses empty and populated OpenShell attachment lists", () => { - expect( - parseMessagingProviderAttachmentNames("No providers attached to sandbox alpha."), - ).toEqual([]); - expect(parseMessagingProviderAttachmentNames(ATTACHED_PROVIDER)).toEqual([ - "alpha-discord-bridge", - ]); - }); - it("restores an exact Hermes Discord provider before policy application", () => { const fixture = queuedRunner([ result(EXACT_PROVIDER), diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.ts b/src/lib/actions/sandbox/messaging-provider/attachments.ts index df61e3c4a1f..ce78e29f769 100644 --- a/src/lib/actions/sandbox/messaging-provider/attachments.ts +++ b/src/lib/actions/sandbox/messaging-provider/attachments.ts @@ -40,8 +40,6 @@ function commandOutput(result: OpenShellResult): string { .trim(); } -export { parseProviderAttachmentNames as parseMessagingProviderAttachmentNames } from "../../../adapters/openshell/provider-attachment-table"; - function gatewayScopedArgs(args: string[], gatewayName: string): string[] { return [...args.slice(0, 2), "-g", gatewayName, ...args.slice(2)]; } diff --git a/src/lib/onboard/gateway-provider-metadata.test.ts b/src/lib/onboard/gateway-provider-metadata.test.ts index 4f97b3ea7a4..2dbba5358f6 100644 --- a/src/lib/onboard/gateway-provider-metadata.test.ts +++ b/src/lib/onboard/gateway-provider-metadata.test.ts @@ -144,7 +144,10 @@ describe("gateway provider metadata", () => { it("parses and reads the exact gateway-scoped provider mutation identity", () => { const runOpenshell = vi.fn(() => ({ status: 0, stdout: COMPLETE_OUTPUT })); const expected = { - ...parseGatewayProviderMetadata(COMPLETE_OUTPUT), + name: "compatible-endpoint", + type: "openai", + credentialKeys: ["COMPATIBLE_API_KEY"], + configKeys: ["OPENAI_BASE_URL", "EXTRA_FLAG"], id: "2ca3b7c7-eff4-4399-af5a-13c4984d7343", resourceVersion: 1, }; @@ -163,6 +166,24 @@ describe("gateway provider metadata", () => { ); }); + it.each([ + ["duplicate ID", `${COMPLETE_OUTPUT}\nId: second-id`], + [ + "non-decimal resource version", + COMPLETE_OUTPUT.replace("Resource version:\u001b[0m 1", "Resource version:\u001b[0m 0x10"), + ], + ["unsafe ID", COMPLETE_OUTPUT.replace("2ca3b7c7-eff4-4399-af5a-13c4984d7343", "unsafe/id")], + [ + "out-of-range resource version", + COMPLETE_OUTPUT.replace( + "Resource version:\u001b[0m 1", + "Resource version:\u001b[0m 9007199254740993", + ), + ], + ])("rejects a provider identity with %s", (_label, output) => { + expect(parseGatewayProviderIdentity(output)).toBeNull(); + }); + it.each([ [ "OSC injection inside the provider name", diff --git a/src/lib/onboard/gateway-provider-metadata.ts b/src/lib/onboard/gateway-provider-metadata.ts index 909dbae74d6..dc40793de6d 100644 --- a/src/lib/onboard/gateway-provider-metadata.ts +++ b/src/lib/onboard/gateway-provider-metadata.ts @@ -268,12 +268,12 @@ export function inspectGatewayCredentialOnlyProviderBinding( : { kind: "collision" }; } -/** Read one exact provider identity without reading or exporting credential values. */ -export function readGatewayProviderMetadata( +function readGatewayProvider( name: string, runOpenshell: GatewayProviderRunner, - gatewayName?: string | null, -): GatewayProviderMetadata | null { + gatewayName: string | null | undefined, + parse: (output: string) => T | null, +): T | null { if (!isSafeIdentifier(name, MAX_PROVIDER_NAME_LENGTH)) return null; const args = ["provider", "get"]; @@ -287,8 +287,17 @@ export function readGatewayProviderMetadata( if (result.status !== 0) return null; const output = `${commandStreamText(result.stdout)}\n${commandStreamText(result.stderr)}`; - const metadata = parseGatewayProviderMetadata(output); - return metadata?.name === name ? metadata : null; + const provider = parse(output); + return provider?.name === name ? provider : null; +} + +/** Read one exact provider identity without reading or exporting credential values. */ +export function readGatewayProviderMetadata( + name: string, + runOpenshell: GatewayProviderRunner, + gatewayName?: string | null, +): GatewayProviderMetadata | null { + return readGatewayProvider(name, runOpenshell, gatewayName, parseGatewayProviderMetadata); } /** Read one gateway-scoped provider identity for a mutation precondition. */ @@ -297,19 +306,5 @@ export function readGatewayProviderIdentity( runOpenshell: GatewayProviderRunner, gatewayName?: string | null, ): GatewayProviderIdentity | null { - if (!isSafeIdentifier(name, MAX_PROVIDER_NAME_LENGTH)) return null; - - const args = ["provider", "get"]; - if (gatewayName) args.push("-g", gatewayName); - args.push(name); - const result = runOpenshell(args, { - ignoreError: true, - suppressOutput: true, - stdio: ["ignore", "pipe", "pipe"], - }); - if (result.status !== 0) return null; - - const output = `${commandStreamText(result.stdout)}\n${commandStreamText(result.stderr)}`; - const identity = parseGatewayProviderIdentity(output); - return identity?.name === name ? identity : null; + return readGatewayProvider(name, runOpenshell, gatewayName, parseGatewayProviderIdentity); } diff --git a/test/e2e/RETRY_INVENTORY.md b/test/e2e/RETRY_INVENTORY.md index e4dbbef8410..923db9ff71c 100644 --- a/test/e2e/RETRY_INVENTORY.md +++ b/test/e2e/RETRY_INVENTORY.md @@ -24,6 +24,7 @@ Exhaustion remains failed. | `trusted-controller-collaborator-permission-read` | Collaborator-permission reads for manual PR dispatch and Launchable E2E dispatch; `.github/workflows/e2e.yaml` | Curl exit 5, 6, 7, 16, 18, 28, 35, 52, 55, 56, 92, 95, or 96; HTTP 408, 429, or 5xx | 3 attempts; linear 1s then 2s | Read-only GitHub API request | GitHub API | Transient API read versus terminal authentication, authorization, actor, or response failure | Operation name, attempt number, and sanitized failure class or HTTP status; no response body, header, or token | Eligible bounded read; HTTP 401, 403, 404, and 422, malformed responses, actor failures, and insufficient roles remain terminal; no cached permission or workflow rerun | | `pr-exact-openclaw-mcp-repetition` | Exact managed-image OpenClaw MCP discovery and lifecycle acceptance; `.github/workflows/managed-images.yaml`, `test/e2e/live/mcp-bridge.test.ts` | Either independent matrix execution fails | 2 required executions on fresh runners; 0 workflow or test retries | Each execution creates and cleans up its own sandbox against the same exact candidate publication cohort | NemoClaw | Each execution passes or fails independently; both must pass | Existing redacted MCP diagnostics, request ledger, cleanup evidence, and fixture-credential scan for each matrix pass | Fixed acceptance repetition required by #8746; not a retry, and one pass never masks the other; trusted-private DNS-rebinding remains in full E2E where the supervisor resolver is authoritative | | `github-exact-artifact-content-read` | Bound base-image or PR managed-image contract artifact; `tools/e2e/exact-artifact-download.mts`, `tools/e2e/pr-managed-image-publication.mts` | Transport failure, HTTP 408, HTTP 429, or HTTP 5xx while reading one pre-bound artifact ID | 3 attempts; Retry-After or linear delay capped at 10s | Read-only request against one immutable artifact ID, name, size, digest, producer run, attempt, and producer commit | GitHub artifact service | `passed-first-attempt`, `passed-after-retry`, `exhausted` for transient exhaustion, or `failed-no-retry` for terminal HTTP; identity, size, digest, archive, and contract failures throw without an aggregate outcome or `failureClass` | Content-read attempts log only the sanitized operation, attempt, HTTP status or transport class, and outcome; thrown validation failures expose only their bounded error message, never headers, body, token, signed URL, or artifact content | Standalone bounded content read; it does not use `retry-policy.ts` or `RetryEvidence`, and all identity, integrity, archive, and contract failures remain terminal | +| `pr-managed-image-publication-readiness` | Exact PR managed-image workflow status; `tools/e2e/pr-managed-image-publication.mts`, `.github/workflows/pr-self-hosted.yaml` | The exact candidate run is absent, queued, in progress, or waiting; every completed failure and identity mismatch is terminal | 121 observations; fixed 30s delay (at most 60m) | Read-only GitHub workflow-status observation before the first artifact read | GitHub Actions | Exact successful publication, terminal failure, or bounded exhaustion | The resolver reports only the bounded outcome or sanitized terminal error; no response body, header, token, or artifact content | Bounded readiness polling on a GitHub-hosted runner avoids reserving the GPU while the exact publication finishes; it never reruns a workflow or accepts another commit | | `inference-set-route-convergence` | Sandbox inference probe after one OpenShell route selection; `src/lib/actions/inference-set-provider.ts`, `src/lib/actions/inference-set.ts` | HTTP 400 or 404 only when the selected API family changes; authentication, authorization, unsafe or malformed input, every other HTTP status, transport failure, and probe failure are terminal | Initial 6s route-cache wait after a provider/model change; then up to 3 probes with 2s and 4s retry delays | Each retry repeats only the read-only sandbox inference probe after one route mutation | OpenShell route cache | Converged, terminal failure, or exhausted rollback | Retry progress records only HTTP status, attempt number, and delay; the final command error stays redacted, and focused tests assert the exact attempt count and rollback | The initial wait covers one full 5s OpenShell 0.0.106 cache-refresh interval even when the stale route returns a valid 2xx; exhaustion restores the prior route, removes the uncommitted provider, and remains failed | | `inference-switch-ts` | Verified inference route update; `test/e2e/fixtures/inference-switch-retry.ts` | Timeout, reset, DNS/connectivity/connect error, request transport error, or exact 502/503/504 status; authentication, authorization, policy, malformed-input, and invalid-request signals take precedence | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Shared `RetryEvidence` classifications | Every attempt classification and aggregate outcome; command artifacts remain separate and redacted | Uses `runBoundedRetry`; deterministic verification mismatches stop; no `--no-verify` exhaustion bypass | | `inference-switch-shell` | Verified shell inference route update; `test/e2e/lib/inference-switch-retry.sh` | Same bounded transient and terminal-precedence signatures as the TypeScript helper | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Exit status remains failed on exhaustion | Existing command output and retry progress | Bounded compatibility helper; no `--no-verify` exhaustion bypass | diff --git a/test/e2e/live/llama-cpp-generic-gpu.test.ts b/test/e2e/live/llama-cpp-generic-gpu.test.ts index a55070d5d54..3ef410b755e 100644 --- a/test/e2e/live/llama-cpp-generic-gpu.test.ts +++ b/test/e2e/live/llama-cpp-generic-gpu.test.ts @@ -42,9 +42,6 @@ function env(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const selected: NodeJS.ProcessEnv = { ...buildAvailabilityProbeEnv(process.env), NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", - // PR images are published by digest only. Build the workload from this - // exact checkout instead of resolving the unreleased package-version tag. - NEMOCLAW_FROM_DOCKERFILE: path.join(REPO_ROOT, "Dockerfile"), NEMOCLAW_LLAMACPP_RECIPE: RECIPE_ID, NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_PROVIDER: "install-llama-cpp", diff --git a/test/e2e/live/rebuild-hermes-swap.ts b/test/e2e/live/rebuild-hermes-swap.ts index 2e70225502f..b026220e7fa 100644 --- a/test/e2e/live/rebuild-hermes-swap.ts +++ b/test/e2e/live/rebuild-hermes-swap.ts @@ -51,10 +51,21 @@ if test -e "$swap_file"; then printf 'refusing to replace existing swap path: %s\n' "$swap_file" >&2 exit 1 fi +cleanup_failed_provision() { + status=$? + trap - EXIT + if ((status != 0)); then + swapoff "$swap_file" >/dev/null 2>&1 || true + rm -f -- "$swap_file" || true + fi + exit "$status" +} +trap cleanup_failed_provision EXIT fallocate -l "$swap_size_bytes" "$swap_file" chmod 0600 "$swap_file" mkswap "$swap_file" -swapon "$swap_file"`, +swapon "$swap_file" +trap - EXIT`, "hermes-rebuild-swap", HERMES_REBUILD_SWAP_FILE, String(HERMES_REBUILD_SWAP_BYTES), diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index 4ee20b8b78b..a1ed7f41ef4 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -767,6 +767,27 @@ describe("base-image publication evidence", () => { expect(currentTime).toBe(10); }); + it("returns the completed detailed run when the workflow list is stale", async () => { + const listedRun = workflowRun({ status: "in_progress", conclusion: null }); + const completedRun = workflowRun(); + const responses = [ + workflowMetadata(), + runsPayload([listedRun]), + { total_count: 3, jobs: successfulJobs() }, + completedRun, + ]; + + await expect( + waitForBaseImagePublication({ + history: history(), + request: async () => responses.shift(), + requireWorkflowSuccess: true, + waitMs: 100, + pollMs: 10, + }), + ).resolves.toEqual(selectedRun()); + }); + it("rejects failed managed-image publication before E2E consumers start", async () => { const failedRun = workflowRun({ conclusion: "failure" }); const responses = [ diff --git a/test/e2e/support/hermes-rebuild-swap.test.ts b/test/e2e/support/hermes-rebuild-swap.test.ts index e50741eb82c..16d28ded3d8 100644 --- a/test/e2e/support/hermes-rebuild-swap.test.ts +++ b/test/e2e/support/hermes-rebuild-swap.test.ts @@ -1,16 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HostCliClient } from "../fixtures/clients/index.ts"; import { HERMES_REBUILD_SWAP_BYTES, needsHermesRebuildSwap, parseActiveSwapBytes, } from "../fixtures/hermes-rebuild-swap.ts"; +import { prepareHermesRebuildSwap } from "../live/rebuild-hermes-swap.ts"; + +function result(exitCode = 0, stdout = "", stderr = "") { + return { exitCode, signal: null, stderr, stdout }; +} describe("Hermes rebuild swap", () => { + afterEach(() => vi.unstubAllEnvs()); + it("adds active swap sizes reported by swapon", () => { expect(parseActiveSwapBytes("17179869184\n17179869184\n")).toBe(HERMES_REBUILD_SWAP_BYTES); }); @@ -42,46 +52,114 @@ describe("Hermes rebuild swap", () => { expect(needsHermesRebuildSwap({ activeSwapBytes: 0, githubActions: false })).toBe(false); }); - it("checks the fallback before the live Docker fixture starts", () => { - const source = fs.readFileSync( - path.resolve(import.meta.dirname, "../live/rebuild-hermes.test.ts"), - "utf8", + it("registers cleanup before verifying and removes the created swap", async () => { + vi.stubEnv("GITHUB_ACTIONS", "true"); + let cleanupAction: (() => Promise | void) | undefined; + const trackDisposable = vi.fn((name: string, action: () => Promise | void) => { + expect(name).toBe("remove Hermes rebuild swap"); + cleanupAction = action; + }); + const command = vi + .fn<(_commandName: string, _args?: string[]) => Promise>>() + .mockResolvedValueOnce(result(0, "0\n")) + .mockResolvedValueOnce(result()) + .mockImplementationOnce(async () => { + expect(trackDisposable).toHaveBeenCalledOnce(); + return result(0, `${String(HERMES_REBUILD_SWAP_BYTES)}\n`); + }) + .mockResolvedValueOnce(result()); + + await prepareHermesRebuildSwap( + { command } as unknown as HostCliClient, + { trackDisposable }, ); - const ensureSwap = source.indexOf("await prepareHermesRebuildSwap(host, cleanup);"); - const dockerProbe = source.indexOf('host.command("docker", ["info"]'); - expect(ensureSwap).toBeGreaterThan(-1); - expect(dockerProbe).toBeGreaterThan(ensureSwap); + expect(command.mock.calls.map(([commandName]) => commandName)).toEqual([ + "swapon", + "sudo", + "swapon", + ]); + expect(cleanupAction).toEqual(expect.any(Function)); + await cleanupAction?.(); + expect(command.mock.calls.map(([commandName]) => commandName)).toEqual([ + "swapon", + "sudo", + "swapon", + "sudo", + ]); }); - it("removes only the swap path created by the Hermes rebuild test", () => { - const source = fs.readFileSync( - path.resolve(import.meta.dirname, "../live/rebuild-hermes-swap.ts"), - "utf8", + it("propagates cleanup failure", async () => { + vi.stubEnv("GITHUB_ACTIONS", "true"); + const responses = [ + result(0, "0\n"), + result(), + result(0, `${String(HERMES_REBUILD_SWAP_BYTES)}\n`), + result(1, "", "swap remains active"), + ]; + let cleanupAction: (() => Promise | void) | undefined; + const command = vi.fn( + async (_commandName: string, _args: string[] = []) => responses.shift() ?? result(1), ); - const cleanupStart = source.indexOf("async function cleanupHermesRebuildSwap"); - const cleanupEnd = source.indexOf("export async function prepareHermesRebuildSwap", cleanupStart); - const cleanupSource = source.slice(cleanupStart, cleanupEnd); - - expect(cleanupSource).toContain('swapoff "$swap_file"'); - expect(cleanupSource).toContain('rm -f -- "$swap_file"'); - expect(cleanupSource).toContain('assertExitZero(result, "remove Hermes rebuild swap")'); - expect(cleanupSource).not.toContain("/swapfile"); - }); - it("registers cleanup before it verifies created swap", () => { - const source = fs.readFileSync( - path.resolve(import.meta.dirname, "../live/rebuild-hermes-swap.ts"), - "utf8", + await prepareHermesRebuildSwap( + { command } as unknown as HostCliClient, + { + trackDisposable: (_name, action) => { + cleanupAction = action; + }, + }, ); - const createSwap = source.indexOf("await createHermesRebuildSwap(host)"); - const registerCleanup = source.indexOf( - 'cleanup.trackDisposable("remove Hermes rebuild swap"', + + await expect(cleanupAction?.()).rejects.toThrow("remove Hermes rebuild swap failed"); + }); + + it("removes a new swap path when provisioning fails after allocation", async () => { + vi.stubEnv("GITHUB_ACTIONS", "true"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-swap-test-")); + const binDirectory = path.join(directory, "bin"); + const swapPath = path.join(directory, "rebuild.swap"); + fs.mkdirSync(binDirectory); + fs.writeFileSync( + path.join(binDirectory, "fallocate"), + '#!/usr/bin/env bash\nset -euo pipefail\n: > "$3"\n', + { mode: 0o755 }, ); - const verifySwap = source.indexOf("await verifyHermesRebuildSwap(host)"); + fs.writeFileSync(path.join(binDirectory, "mkswap"), "#!/usr/bin/env bash\nexit 42\n", { + mode: 0o755, + }); + fs.writeFileSync(path.join(binDirectory, "swapoff"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + const trackDisposable = vi.fn(); + const command = vi + .fn() + .mockResolvedValueOnce(result(0, "0\n")) + .mockImplementationOnce(async (_commandName: string, args: string[]) => { + const execution = spawnSync( + "bash", + ["-c", args[2], args[3], swapPath, args[5]], + { + encoding: "utf8", + env: { ...process.env, PATH: `${binDirectory}:${process.env.PATH ?? ""}` }, + }, + ); + expect(execution.status).toBe(42); + expect(fs.existsSync(swapPath)).toBe(false); + return result(execution.status ?? 1, execution.stdout, execution.stderr); + }); - expect(createSwap).toBeGreaterThan(-1); - expect(registerCleanup).toBeGreaterThan(createSwap); - expect(verifySwap).toBeGreaterThan(registerCleanup); + try { + await expect( + prepareHermesRebuildSwap( + { command } as unknown as HostCliClient, + { trackDisposable }, + ), + ).rejects.toThrow("provision swap for Hermes rebuild failed"); + expect(trackDisposable).not.toHaveBeenCalled(); + expect(fs.existsSync(swapPath)).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } }); }); diff --git a/test/e2e/support/pr-managed-image-publication.test.ts b/test/e2e/support/pr-managed-image-publication.test.ts index 18eb4bc35d9..21fb7cf8cd1 100644 --- a/test/e2e/support/pr-managed-image-publication.test.ts +++ b/test/e2e/support/pr-managed-image-publication.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, @@ -20,9 +20,11 @@ import { import { assembleManagedImageCatalog, main, + ManagedImagePublicationPendingError, managedImagePublicationRequired, parseManagedImagePullRequestPaths, selectManagedImagePublicationRun, + waitForPrManagedImageCatalog, } from "../../../tools/e2e/pr-managed-image-publication.mts"; const CANDIDATE_SHA = "a".repeat(40); @@ -120,11 +122,15 @@ on: }); it.each([ - ["pending", { status: "in_progress", conclusion: null }, "must complete successfully"], + ["queued", { status: "queued", conclusion: null }, "still running"], + ["in progress", { status: "in_progress", conclusion: null }, "still running"], + ["waiting", { status: "waiting", conclusion: null }, "still running"], + ["pending", { status: "pending", conclusion: null }, "still running"], + ["requested", { status: "requested", conclusion: null }, "still running"], ["failed", { conclusion: "failure" }, "must complete successfully"], ["different commit", { head_sha: "b".repeat(40) }, "commit must be"], ["different PR", { pull_requests: [{ number: 9464 }] }, "PR number"], - ])("rejects a %s publication run", (_label, overrides, message) => { + ])("classifies a %s publication run", (_label, overrides, message) => { expect(() => selectManagedImagePublicationRun(run(overrides), { headSha: CANDIDATE_SHA, @@ -134,6 +140,66 @@ on: ).toThrow(message); }); + it("waits when GitHub has not created the exact publication run", () => { + expect(() => + selectManagedImagePublicationRun( + { total_count: 0, workflow_runs: [] }, + { + headSha: CANDIDATE_SHA, + prNumber: PR_NUMBER, + workflowId: WORKFLOW_ID, + }, + ), + ).toThrow(ManagedImagePublicationPendingError); + }); + + it("waits only while the exact publication is pending", async () => { + const resolve = vi + .fn() + .mockRejectedValueOnce(new ManagedImagePublicationPendingError("still running")) + .mockResolvedValueOnce("written" as const); + const sleep = vi.fn(async () => undefined); + + await expect( + waitForPrManagedImageCatalog( + { + baseSha: "b".repeat(40), + candidateRepository: "NVIDIA/NemoClaw", + candidateSha: CANDIDATE_SHA, + outputPath: "/tmp/catalog.json", + prNumber: PR_NUMBER, + token: "token", + workflowSource: "trusted workflow", + }, + { attempts: 2, delayMs: 1, resolve, sleep }, + ), + ).resolves.toBe("written"); + expect(resolve).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledExactlyOnceWith(1); + }); + + it("does not retry a terminal publication failure", async () => { + const resolve = vi.fn().mockRejectedValue(new Error("publication failed")); + const sleep = vi.fn(async () => undefined); + + await expect( + waitForPrManagedImageCatalog( + { + baseSha: "b".repeat(40), + candidateRepository: "NVIDIA/NemoClaw", + candidateSha: CANDIDATE_SHA, + outputPath: "/tmp/catalog.json", + prNumber: PR_NUMBER, + token: "token", + workflowSource: "trusted workflow", + }, + { attempts: 2, delayMs: 1, resolve, sleep }, + ), + ).rejects.toThrow("publication failed"); + expect(resolve).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); + }); + it("assembles one exact all-agent catalog", () => { const contracts = SHIPPED_MANAGED_IMAGE_AGENTS.map(contract); diff --git a/test/e2e/support/pr-self-hosted-llama-selector.test.ts b/test/e2e/support/pr-self-hosted-llama-selector.test.ts index 23e4d1b557d..64d72f75624 100644 --- a/test/e2e/support/pr-self-hosted-llama-selector.test.ts +++ b/test/e2e/support/pr-self-hosted-llama-selector.test.ts @@ -9,14 +9,36 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; +type WorkflowStep = { + env?: Record; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + type Workflow = { - jobs: Record }>; + jobs: Record< + string, + { + env?: Record; + needs?: string | string[]; + outputs?: Record; + permissions?: Record; + steps?: WorkflowStep[]; + [key: string]: unknown; + } + >; }; const WORKFLOW_PATH = ".github/workflows/pr-self-hosted.yaml"; const CANDIDATE_SHA = "a".repeat(40); -function selectGenericGpuLane(changedFiles: readonly string[], copiedSha = CANDIDATE_SHA) { +function selectGenericGpuLane( + changedFiles: readonly string[], + copiedSha = CANDIDATE_SHA, + candidateRepository = "NVIDIA/NemoClaw", +) { const workflow = YAML.parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow; const script = workflow.jobs["select-llama-cpp-generic-gpu"]?.steps?.find( (step) => step.name === "Select llama.cpp generic GPU E2E from PR files", @@ -57,12 +79,21 @@ fi GITHUB_SHA: copiedSha, PATH: `${binDirectory}:${process.env.PATH ?? ""}`, PR_FILES_JSON: JSON.stringify([changedFiles.map((filename) => ({ filename }))]), - PR_JSON: JSON.stringify({ number: 8748, head: { sha: CANDIDATE_SHA } }), + PR_JSON: JSON.stringify({ + number: 8748, + base: { sha: "c".repeat(40) }, + head: { + repo: { full_name: candidateRepository }, + sha: CANDIDATE_SHA, + }, + }), }, }, ); expect(result.status, result.stderr).toBe(0); - return readFileSync(outputPath, "utf8").trim(); + return readFileSync(outputPath, "utf8") + .split("\n") + .find((line) => line.startsWith("selected=")); } finally { rmSync(directory, { force: true, recursive: true }); } @@ -89,4 +120,54 @@ describe("generic NVIDIA GPU PR selection", () => { "Copied PR branch SHA does not match the current PR head", ); }); + + it("rejects a copied branch from a fork repository", () => { + expect(() => + selectGenericGpuLane(["scripts/install.sh"], CANDIDATE_SHA, "example/NemoClaw"), + ).toThrow("Copied PR branch must come from the workflow repository"); + }); + + it("binds the GPU lane to the exact PR managed-image catalog", () => { + const workflow = YAML.parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow; + const selector = workflow.jobs["select-llama-cpp-generic-gpu"]; + expect(selector?.outputs).toMatchObject({ + base_sha: "${{ steps.changed.outputs.base_sha }}", + candidate_repository: "${{ steps.changed.outputs.candidate_repository }}", + pr_number: "${{ steps.changed.outputs.pr_number }}", + }); + + const resolver = workflow.jobs["resolve-llama-cpp-managed-images"]; + expect(resolver?.needs).toBe("select-llama-cpp-generic-gpu"); + expect(resolver?.permissions).toEqual({ + actions: "read", + contents: "read", + "pull-requests": "read", + }); + const wait = resolver?.steps?.find( + (step) => step.name === "Wait for exact PR managed-image catalog", + ); + expect(wait?.env).toMatchObject({ + BASE_SHA: "${{ needs.select-llama-cpp-generic-gpu.outputs.base_sha }}", + CANDIDATE_REPOSITORY: + "${{ needs.select-llama-cpp-generic-gpu.outputs.candidate_repository }}", + CANDIDATE_SHA: "${{ github.sha }}", + PR_NUMBER: "${{ needs.select-llama-cpp-generic-gpu.outputs.pr_number }}", + }); + expect(wait?.run).toContain("pr-managed-image-publication.mts wait"); + + const gpu = workflow.jobs["llama-cpp-generic-gpu"]; + expect(gpu?.needs).toEqual([ + "select-llama-cpp-generic-gpu", + "resolve-llama-cpp-managed-images", + ]); + const download = gpu?.steps?.find( + (step) => step.name === "Download exact PR managed-image catalog", + ); + expect(download?.with).toEqual({ + name: "llama-cpp-pr-managed-catalog-${{ github.sha }}", + path: "${{ runner.temp }}/pr-managed-image-catalog", + }); + const bind = gpu?.steps?.find((step) => step.name === "Bind exact PR managed-image catalog"); + expect(bind?.run).toContain("NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG"); + }); }); diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 5fc76299b60..1ef62aad395 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -1188,8 +1188,6 @@ describe("LangChain Deep Agents Code image contracts", () => { requirementsLock, pythonStringMap(progressiveValidator, "PINNED_VERSIONS"), ); - expect(progressiveValidator).toContain('"_deepagents_code_mcp": True'); - expect(progressiveValidator).toContain('"readOnlyHint": True'); const observabilityValidator = readAgentFile("validate-observability.py"); const observabilityVersion = observabilityValidator.match( @@ -1216,6 +1214,59 @@ describe("LangChain Deep Agents Code image contracts", () => { expectVersionsMatchLock(requirementsLock, e2eVersions); }); + it("assigns the read-only MCP contract to each loaded validator tool", () => { + const validatorPath = path.join( + repoRoot, + "agents", + "langchain-deepagents-code", + "validate-progressive-tool-disclosure.py", + ); + const metadata = JSON.parse( + execFileSync( + "python3", + [ + "-c", + `import ast +import json +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +values = [] +for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(target, ast.Attribute) and target.attr == "metadata" for target in node.targets): + continue + value = ast.literal_eval(node.value) + if isinstance(value, dict) and value.get("_deepagents_code_mcp") is True: + values.append(value) +print(json.dumps(values, sort_keys=True))`, + validatorPath, + ], + { encoding: "utf8" }, + ), + ) as Array>; + + expect(metadata).toEqual([ + { + _deepagents_code_mcp: true, + _deepagents_code_mcp_server: "direct-runtime-validator", + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + { + _deepagents_code_mcp: true, + _deepagents_code_mcp_server: "runtime-validator", + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + ]); + }); + it.each([ ["aiohttp", "3.14.3"], ["cryptography", "50.0.0"], diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 595a8d105ec..2c5bff93fda 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -640,6 +640,7 @@ export async function waitForBaseImagePublication( ); } let publisherState: "pending" | "ready"; + let validatedRun = selection.run; try { const jobs = await collectPaginated(options.request, jobsPath, "jobs"); publisherState = validatePublisherJobs(jobs, selection.run); @@ -648,6 +649,7 @@ export async function waitForBaseImagePublication( await options.request(`/repos/${REPOSITORY}/actions/runs/${selection.run.id}`), selection.run, ); + validatedRun = boundRun; if (options.requireWorkflowSuccess === true) { if (boundRun.status !== "completed") { publisherState = "pending"; @@ -667,7 +669,7 @@ export async function waitForBaseImagePublication( `timed out validating base-image publication for ${selection.run.headSha}; ${selection.run.url}`, ); } - return selection.run; + return validatedRun; } } diff --git a/tools/e2e/pr-managed-image-publication.mts b/tools/e2e/pr-managed-image-publication.mts index 0c7e0b4e09f..47797db5804 100644 --- a/tools/e2e/pr-managed-image-publication.mts +++ b/tools/e2e/pr-managed-image-publication.mts @@ -38,6 +38,23 @@ export interface ManagedImagePublicationRun { readonly headSha: string; } +export class ManagedImagePublicationPendingError extends Error { + constructor(message: string) { + super(message); + this.name = "ManagedImagePublicationPendingError"; + } +} + +export interface ResolvePrManagedImageCatalogInput { + readonly baseSha: string; + readonly candidateRepository: string; + readonly candidateSha: string; + readonly outputPath: string; + readonly prNumber: number; + readonly token: string; + readonly workflowSource: string; +} + function record(value: unknown, label: string): JsonRecord { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be a JSON object`); @@ -138,7 +155,15 @@ export function selectManagedImagePublicationRun( positiveInteger(expected.prNumber, "PR number"); positiveInteger(expected.workflowId, "managed-image workflow id"); const response = record(payload, "managed-image workflow runs"); - if (response.total_count !== 1 || !Array.isArray(response.workflow_runs)) { + if (!Array.isArray(response.workflow_runs)) { + throw new Error("exact managed-image workflow run is missing or ambiguous"); + } + if (response.total_count === 0 && response.workflow_runs.length === 0) { + throw new ManagedImagePublicationPendingError( + "exact managed-image workflow run is not available yet", + ); + } + if (response.total_count !== 1) { throw new Error("exact managed-image workflow run is missing or ambiguous"); } if (response.workflow_runs.length !== 1) { @@ -171,6 +196,18 @@ export function selectManagedImagePublicationRun( ) { throw new Error("managed-image workflow run does not match the PR number"); } + if ( + (run.status === "queued" || + run.status === "in_progress" || + run.status === "waiting" || + run.status === "pending" || + run.status === "requested") && + (run.conclusion === null || run.conclusion === undefined) + ) { + throw new ManagedImagePublicationPendingError( + `managed-image workflow for candidate ${expected.headSha} is still running`, + ); + } if (run.status !== "completed" || run.conclusion !== "success") { throw new Error( `managed-image workflow for candidate ${expected.headSha} must complete successfully before live E2E`, @@ -312,15 +349,7 @@ function validatePr( /** Resolve and download the exact all-agent catalog before candidate code executes. */ export async function resolvePrManagedImageCatalog( - input: { - readonly baseSha: string; - readonly candidateRepository: string; - readonly candidateSha: string; - readonly outputPath: string; - readonly prNumber: number; - readonly token: string; - readonly workflowSource: string; - }, + input: ResolvePrManagedImageCatalogInput, request: (path: string) => Promise = (apiPath) => githubRequest(apiPath, input.token), ): Promise<"not-required" | "written"> { if (input.candidateRepository !== REPOSITORY) return "not-required"; @@ -385,6 +414,42 @@ export async function resolvePrManagedImageCatalog( } } +/** Wait only for the exact candidate publication to appear and finish successfully. */ +export async function waitForPrManagedImageCatalog( + input: ResolvePrManagedImageCatalogInput, + options: { + readonly attempts?: number; + readonly delayMs?: number; + readonly resolve?: typeof resolvePrManagedImageCatalog; + readonly sleep?: (delayMs: number) => Promise; + } = {}, +): Promise<"not-required" | "written"> { + const attempts = options.attempts ?? 121; + const delayMs = options.delayMs ?? 30_000; + if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 121) { + throw new Error("managed-image publication wait attempts are invalid"); + } + if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > 30_000) { + throw new Error("managed-image publication wait delay is invalid"); + } + const resolve = options.resolve ?? resolvePrManagedImageCatalog; + const sleep = options.sleep ?? ((delay) => new Promise((done) => setTimeout(done, delay))); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await resolve(input); + } catch (error) { + if (!(error instanceof ManagedImagePublicationPendingError)) throw error; + if (attempt === attempts) { + throw new Error("exact managed-image workflow did not complete within the bounded wait", { + cause: error, + }); + } + await sleep(delayMs); + } + } + throw new Error("exact managed-image workflow wait exhausted unexpectedly"); +} + function requiredInteger(value: string | undefined, label: string): number { if (!value || !/^[1-9][0-9]*$/u.test(value)) throw new Error(`${label} is required`); return positiveInteger(Number(value), label); @@ -399,18 +464,25 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro console.log("pr-managed-image-catalog outcome=assembled"); return; } - if (argv.length !== 1) throw new Error("expected one managed-image catalog output path"); + const wait = argv[0] === "wait"; + const outputPath = wait ? argv[1] : argv[0]; + if ((wait && argv.length !== 2) || (!wait && argv.length !== 1) || !outputPath) { + throw new Error("expected one managed-image catalog output path"); + } const candidateSha = env.CANDIDATE_SHA ?? ""; if (!candidateSha) return; - const result = await resolvePrManagedImageCatalog({ + const input = { baseSha: env.BASE_SHA ?? "", candidateRepository: env.CANDIDATE_REPOSITORY ?? "", candidateSha, - outputPath: argv[0], + outputPath, prNumber: requiredInteger(env.PR_NUMBER, "PR_NUMBER"), token: env.GITHUB_TOKEN ?? "", workflowSource: fs.readFileSync(WORKFLOW_PATH, "utf8"), - }); + }; + const result = wait + ? await waitForPrManagedImageCatalog(input) + : await resolvePrManagedImageCatalog(input); console.log(`pr-managed-image-catalog outcome=${result}`); } From 669629143468c1ec34db82394c88f586ac6a4b05 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 17:53:35 -0700 Subject: [PATCH 28/31] Revert "fix(onboard): produce scoped pairing before observation" This reverts commit 4103ae0e1cc9b80114ac8cd25f2e0dbf343d3559. --- src/lib/actions/sandbox/auto-pair-warmup.ts | 22 ++------- .../onboard/machine/finalization-deps.test.ts | 45 +++++++++---------- src/lib/onboard/machine/finalization-deps.ts | 37 +++++++-------- 3 files changed, 39 insertions(+), 65 deletions(-) diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index aa088c6cbb8..490548b91db 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -182,11 +182,7 @@ NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ exit 0 `; -function runSandboxWarmupScript( - sandboxName: string, - script: string, - gatewayName?: string, -): void { +function runSandboxWarmupScript(sandboxName: string, script: string): void { // Lazy require: `adapters/openshell/resolve` pulls in `runner`, whose // load-time `require("./platform")` cannot be resolved by the Vitest TS // loader. Importing it here keeps this module unit-testable in-process. @@ -201,17 +197,7 @@ function runSandboxWarmupScript( if (!openshellBinary) return; spawnSync( openshellBinary, - [ - "sandbox", - "exec", - "--name", - sandboxName, - ...(gatewayName ? ["-g", gatewayName] : []), - "--", - "sh", - "-c", - script, - ], + ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], { cwd: ROOT, env: process.env, @@ -230,8 +216,8 @@ function runSandboxWarmupScript( * missing openclaw, gateway unreachable) are swallowed. The finalization * settlement gate decides readiness from a later canonical observation. */ -export function runSandboxScopeWarmupRun(sandboxName: string, gatewayName: string): void { - runSandboxWarmupScript(sandboxName, WARMUP_SCRIPT, gatewayName); +export function runSandboxScopeWarmupRun(sandboxName: string): void { + runSandboxWarmupScript(sandboxName, WARMUP_SCRIPT); } /** diff --git a/src/lib/onboard/machine/finalization-deps.test.ts b/src/lib/onboard/machine/finalization-deps.test.ts index f54799a4a24..457e38f5a31 100644 --- a/src/lib/onboard/machine/finalization-deps.test.ts +++ b/src/lib/onboard/machine/finalization-deps.test.ts @@ -72,7 +72,7 @@ describe("ordinary OpenClaw pairing settlement", () => { vi.restoreAllMocks(); }); - it("accepts one already-settled canonical CLI device after one idempotent producer (#10014)", async () => { + it("accepts one already-settled canonical CLI device without pairing writes (#9844)", async () => { const scope = ordinaryPairingDeps(); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ @@ -85,32 +85,27 @@ describe("ordinary OpenClaw pairing settlement", () => { "2026.7.1", "/sandbox/.openclaw", ); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); - it("runs the canonical request probe before waiting for fresh pairing (#10014)", async () => { - const observePairing = vi.fn(() => PAIRING_ONLY); - observePairing.mockImplementationOnce(() => { - throw new Error("not published"); - }); + it("waits for canonical pairing before one warm-up and approval pass (#9844)", async () => { const scope = ordinaryPairingDeps({ - observePairing, - runWarmup: vi.fn(() => { - scope.calls.push("warmup"); - }), - runApproval: vi.fn(() => { - scope.calls.push("approval"); - vi.mocked(scope.deps.observePairing).mockReturnValue(SETTLED); - }), + observePairing: vi + .fn() + .mockImplementationOnce(() => { + throw new Error("not published"); + }) + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValue(SETTLED), }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ kind: "settled", }); - expect(scope.calls).toEqual(["warmup", "sleep", "approval"]); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.calls).toEqual(["sleep", "warmup", "approval"]); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); }); @@ -154,8 +149,8 @@ describe("ordinary OpenClaw pairing settlement", () => { expect(events).toEqual([ "sandbox-lock:start", "gateway-lock:start", - "warmup", "observe:baseline", + "warmup", "approval", "observe:final", "gateway-lock:end", @@ -304,7 +299,7 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); expect(scope.deps.runApproval).not.toHaveBeenCalled(); - expect(scope.deps.observePairing).not.toHaveBeenCalled(); + expect(scope.deps.observePairing).toHaveBeenCalledOnce(); }); it("does not observe replacement state when the runtime changes during approval (#9844)", async () => { @@ -383,7 +378,7 @@ describe("ordinary OpenClaw pairing settlement", () => { kind: "settled", }); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.observePairing).toHaveBeenCalledTimes(3); expect(now).toBe( @@ -415,11 +410,11 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); expect(scope.deps.sleep).not.toHaveBeenCalled(); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); - it("performs one request-producer write when a canonical CLI pairing never appears (#10014)", async () => { + it("performs no writes when a canonical CLI pairing never appears (#9844)", async () => { const scope = ordinaryPairingDeps({ observePairing: vi.fn(() => { throw new Error("not published"); @@ -431,7 +426,7 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); @@ -461,7 +456,7 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.observePairing).not.toHaveBeenCalled(); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); @@ -522,7 +517,7 @@ describe("ordinary OpenClaw pairing settlement", () => { await expect(finalizationHandlerDeps.settleOrdinaryOpenClawPairing("alpha")).resolves.toEqual({ kind: "settled", }); - expect(runSandboxScopeWarmupRun).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(runSandboxScopeWarmupRun).toHaveBeenCalledExactlyOnceWith("alpha"); expect(runConnectAutoPairApprovalPass).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); }); diff --git a/src/lib/onboard/machine/finalization-deps.ts b/src/lib/onboard/machine/finalization-deps.ts index 0e87d1bdc04..e842ae3370f 100644 --- a/src/lib/onboard/machine/finalization-deps.ts +++ b/src/lib/onboard/machine/finalization-deps.ts @@ -66,7 +66,7 @@ interface OrdinaryOpenClawPairingSettlementDeps { version: string, stateDirectory: string, ): OpenClawPairingSettlementObservation; - runWarmup(name: string, gatewayName: string): Promise | void; + runWarmup(name: string): Promise | void; runApproval(name: string, gatewayName: string): Promise | void; withSandboxLock: SandboxLifecycleLock; withGatewayLock: GatewayRouteLock; @@ -157,10 +157,8 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { finalizationHandlerRuntime .loadPairingQualification() .observeOrdinaryOpenClawPairingSettlement(...args), - runWarmup: (name, gatewayName) => - finalizationHandlerRuntime - .loadAutoPairWarmup() - .runSandboxScopeWarmupRun(name, gatewayName), + runWarmup: (name) => + finalizationHandlerRuntime.loadAutoPairWarmup().runSandboxScopeWarmupRun(name), runApproval: (name, gatewayName) => finalizationHandlerRuntime .loadAutoPairApproval() @@ -179,8 +177,8 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { } /** - * Run one bounded request producer, then wait for one canonical CLI pairing. - * When the device has only its pairing scope, approve the write scope once. + * Wait for the startup watcher to publish one canonical CLI pairing. When the + * device has only its pairing scope, request and approve the write scope once. * A final read verifies the exact device and no pending request for that device. */ export async function settleOrdinaryOpenClawPairing( @@ -203,20 +201,6 @@ export async function settleOrdinaryOpenClawPairing( return { kind: "incomplete", reason: "runtime-identity-invalid" }; } const settlementDeadline = deps.now() + OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; - - // Fresh non-interactive onboarding can reach finalization before the - // startup watcher publishes its first CLI pairing request. Provoke - // that request once with the direct, device-authenticated - // sessions.create probe before observation. Approval and the final - // exact-device observation remain the only completion authority. - try { - await deps.runWarmup(name, target.gatewayName); - } catch { - // The bounded observation below remains fail closed. - } - if (!samePairingTarget(target, deps.getTarget(name))) { - return { kind: "incomplete", reason: "runtime-identity-invalid" }; - } const pairingAppearanceDeadline = Math.min( settlementDeadline, deps.now() + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS, @@ -243,7 +227,16 @@ export async function settleOrdinaryOpenClawPairing( return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; } - if (deps.now() >= settlementDeadline) { + let warmupFailed = false; + try { + await deps.runWarmup(name); + } catch { + warmupFailed = true; + } + if (!samePairingTarget(target, deps.getTarget(name))) { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + if (warmupFailed || deps.now() >= settlementDeadline) { return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; } From 6d0d2ec63d98cd7db5600d6f65c6becf773f9c78 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 17:53:58 -0700 Subject: [PATCH 29/31] Revert "fix(images): bind managed builds to target architecture" This reverts commit 874d6e27ce89597fc1528682cdd614d06697758c. --- .github/workflows/managed-images.yaml | 3 --- test/managed-image-publication-workflow.test.ts | 4 +--- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index a5c6bf1b376..9b78f4faf64 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -1886,7 +1886,6 @@ jobs: - name: Validate production build args env: - ARCH: ${{ matrix.arch }} BASE_IMAGE: ${{ steps.base.outputs.ref }} DOCKERFILE: ${{ matrix.dockerfile }} run: | @@ -1896,7 +1895,6 @@ jobs: --build-arg "BASE_IMAGE=${BASE_IMAGE}" --build-arg "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1" --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" - --build-arg "TARGETARCH=${ARCH}" ) scripts/check-production-build-args.sh "${build_args[@]}" @@ -1926,7 +1924,6 @@ jobs: BASE_IMAGE=${{ steps.base.outputs.ref }} NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root - TARGETARCH=${{ matrix.arch }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }} cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.artifact_platform }},mode=max provenance: mode=max diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 5d0eafb47e4..aa81a87231b 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -957,15 +957,13 @@ fi expect(releaseIdentity.run).toContain("git describe --tags --match 'v*' \"$GITHUB_SHA\""); expect(releaseIdentity.run).toContain("managed image release identity does not match"); expect(guard.run).toContain('scripts/check-production-build-args.sh "${build_args[@]}"'); - expect(guard.env?.ARCH).toBe("${{ matrix.arch }}"); - expect(guard.run).toContain('--build-arg "TARGETARCH=${ARCH}"'); expect(build.uses).toBe("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"); expect(build.with).toMatchObject({ context: ".", file: "${{ matrix.dockerfile }}", platforms: "${{ matrix.platform }}", "build-args": - "BASE_IMAGE=${{ steps.base.outputs.ref }}\nNEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1\nNEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root\nTARGETARCH=${{ matrix.arch }}\n", + "BASE_IMAGE=${{ steps.base.outputs.ref }}\nNEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1\nNEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root\n", provenance: "mode=max", sbom: true, }); From 681182deac1f4bcb785c8afed53ff6cce401edb1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 18:38:46 -0700 Subject: [PATCH 30/31] refactor(e2e): narrow main remediation ownership Signed-off-by: Prekshi Vyas --- .github/workflows/pr-self-hosted.yaml | 92 +----- .../launch-readiness-ordinary-pairing.test.ts | 1 + .../sandbox/mcp-bridge-provider-inspection.ts | 21 +- .../messaging-provider/attachments.test.ts | 296 ------------------ .../sandbox/messaging-provider/attachments.ts | 227 -------------- .../sandbox/policy-channel-dependencies.ts | 26 -- src/lib/adapters/openshell/ansi.ts | 8 - src/lib/adapters/openshell/client.ts | 7 +- .../provider-attachment-table.test.ts | 31 -- .../openshell/provider-attachment-table.ts | 23 -- src/lib/messaging/channels/policy.ts | 62 +--- .../onboard/gateway-provider-metadata.test.ts | 45 --- src/lib/onboard/gateway-provider-metadata.ts | 73 +---- src/lib/onboard/initial-policy.ts | 30 +- .../handlers/sandbox-messaging.test.ts | 69 +--- .../machine/handlers/sandbox-messaging.ts | 54 +--- src/lib/onboard/machine/handlers/sandbox.ts | 1 - src/lib/policy/index.ts | 15 +- src/lib/shields/index.ts | 16 +- src/lib/shields/permissive-runtime.ts | 81 ++--- src/lib/state/registry-messaging.ts | 7 - src/lib/state/registry.ts | 1 - test/e2e/live/rebuild-hermes-bootstrap.ts | 50 --- test/e2e/live/rebuild-hermes.test.ts | 32 +- .../pr-managed-image-publication.test.ts | 72 +---- .../pr-self-hosted-llama-selector.test.ts | 89 +----- .../support/rebuild-hermes-bootstrap.test.ts | 36 --- test/permissive-runtime.test.ts | 44 +-- test/policies-permissive-policy.test.ts | 58 ++-- tools/e2e/pr-managed-image-publication.mts | 100 +----- 30 files changed, 178 insertions(+), 1489 deletions(-) delete mode 100644 src/lib/actions/sandbox/messaging-provider/attachments.test.ts delete mode 100644 src/lib/actions/sandbox/messaging-provider/attachments.ts delete mode 100644 src/lib/adapters/openshell/ansi.ts delete mode 100644 src/lib/adapters/openshell/provider-attachment-table.test.ts delete mode 100644 src/lib/adapters/openshell/provider-attachment-table.ts diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index 821bd497ed2..63ad377faae 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -32,9 +32,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - base_sha: ${{ steps.changed.outputs.base_sha }} - candidate_repository: ${{ steps.changed.outputs.candidate_repository }} - pr_number: ${{ steps.changed.outputs.pr_number }} selected: ${{ steps.changed.outputs.selected }} steps: - id: changed @@ -50,12 +47,6 @@ jobs: } pr_number="${BASH_REMATCH[1]}" pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number")" - base_sha="$(jq -er '.base.sha | select(test("^[a-f0-9]{40}$"))' <<<"$pr_json")" - candidate_repository="$(jq -er '.head.repo.full_name | strings | select(length > 0)' <<<"$pr_json")" - [[ "$candidate_repository" == "$GITHUB_REPOSITORY" ]] || { - echo "::error::Copied PR branch must come from the workflow repository" >&2 - exit 1 - } head_sha="$(jq -er '.head.sha | select(test("^[a-f0-9]{40}$"))' <<<"$pr_json")" [[ "$head_sha" == "$GITHUB_SHA" ]] || { echo "::error::Copied PR branch SHA does not match the current PR head" >&2 @@ -88,80 +79,14 @@ jobs: else selected=false fi - printf 'base_sha=%s\n' "$base_sha" >>"$GITHUB_OUTPUT" - printf 'candidate_repository=%s\n' "$candidate_repository" >>"$GITHUB_OUTPUT" - printf 'pr_number=%s\n' "$pr_number" >>"$GITHUB_OUTPUT" printf 'selected=%s\n' "$selected" >>"$GITHUB_OUTPUT" - resolve-llama-cpp-managed-images: - name: Resolve exact PR managed images for llama.cpp GPU - needs: select-llama-cpp-generic-gpu - if: ${{ needs.select-llama-cpp-generic-gpu.outputs.selected == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 70 - outputs: - catalog_written: ${{ steps.catalog.outputs.catalog_written }} - permissions: - actions: read - contents: read - pull-requests: read - steps: - - name: Checkout exact PR head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.sha }} - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22.19.0 - cache: npm - - - name: Install exact catalog resolver dependencies - run: npm ci --ignore-scripts - - - id: catalog - name: Wait for exact PR managed-image catalog - env: - BASE_SHA: ${{ needs.select-llama-cpp-generic-gpu.outputs.base_sha }} - CANDIDATE_REPOSITORY: ${{ needs.select-llama-cpp-generic-gpu.outputs.candidate_repository }} - CANDIDATE_SHA: ${{ github.sha }} - GITHUB_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ needs.select-llama-cpp-generic-gpu.outputs.pr_number }} - shell: bash - run: | - set -euo pipefail - catalog_path="${RUNNER_TEMP}/pr-managed-image-catalog.json" - node --experimental-strip-types --no-warnings \ - tools/e2e/pr-managed-image-publication.mts wait \ - "$catalog_path" - if [[ -e "$catalog_path" ]]; then - printf 'catalog_written=true\n' >>"$GITHUB_OUTPUT" - else - printf 'catalog_written=false\n' >>"$GITHUB_OUTPUT" - fi - - - name: Upload exact PR managed-image catalog - if: ${{ steps.catalog.outputs.catalog_written == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: llama-cpp-pr-managed-catalog-${{ github.sha }} - path: ${{ runner.temp }}/pr-managed-image-catalog.json - if-no-files-found: error - retention-days: 1 - llama-cpp-generic-gpu: name: llama.cpp on generic NVIDIA GPU - needs: - - select-llama-cpp-generic-gpu - - resolve-llama-cpp-managed-images + needs: select-llama-cpp-generic-gpu if: ${{ needs.select-llama-cpp-generic-gpu.outputs.selected == 'true' }} runs-on: linux-amd64-gpu-rtxpro6000-latest-1 timeout-minutes: 120 - permissions: - actions: read - contents: read env: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/llama-cpp-generic-gpu E2E_JOB: "1" @@ -184,21 +109,6 @@ jobs: persist-credentials: false ref: ${{ github.sha }} - - name: Download exact PR managed-image catalog - if: ${{ needs.resolve-llama-cpp-managed-images.outputs.catalog_written == 'true' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: llama-cpp-pr-managed-catalog-${{ github.sha }} - path: ${{ runner.temp }}/pr-managed-image-catalog - - - name: Bind exact PR managed-image catalog - if: ${{ needs.resolve-llama-cpp-managed-images.outputs.catalog_written == 'true' }} - shell: bash - run: >- - printf 'NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG=%s\n' - "${RUNNER_TEMP}/pr-managed-image-catalog/pr-managed-image-catalog.json" - >>"$GITHUB_ENV" - - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 diff --git a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts index ea9faae4207..f57fecfa009 100644 --- a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts +++ b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts @@ -97,6 +97,7 @@ describe("ordinary OpenClaw pairing target", () => { expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toBeNull(); }); + it.each([ ["missing agent identity", { agent: undefined }], ["pending route reservation", { pendingRouteReservation: true }], diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index c0150c7b2e1..0cbe4c3f04f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { stripAnsi } from "../../adapters/openshell/client"; -import { parseProviderAttachmentNames } from "../../adapters/openshell/provider-attachment-table"; import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; import { listExtraProviders, type McpBridgeEntry } from "../../state/registry"; @@ -106,7 +105,23 @@ export function inspectMcpProvider(providerName: string | undefined): McpProvide }; } -export { parseProviderAttachmentNames as parseMcpProviderAttachmentNames } from "../../adapters/openshell/provider-attachment-table"; +export function parseMcpProviderAttachmentNames(output: string): string[] { + const clean = stripAnsi(output).replace(/\r/g, "").trim(); + if (/^No providers attached to sandbox\b/m.test(clean)) return []; + const lines = clean + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const headerIndex = lines.findIndex((line) => + /^NAME\s+TYPE\s+CREDENTIAL_KEYS\s+CONFIG_KEYS$/.test(line), + ); + if (headerIndex < 0) throw new Error("missing provider attachment table header"); + return lines.slice(headerIndex + 1).map((line) => { + const match = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$/); + if (!match?.[1]) throw new Error("invalid provider attachment table row"); + return match[1]; + }); +} export function inspectMcpProviderAttachments( sandboxName: string, @@ -122,7 +137,7 @@ export function inspectMcpProviderAttachments( try { const clean = stripAnsi(output).replace(/\r/g, "").trim(); if (/^No providers attached to sandbox\b/m.test(clean)) return { attachments: [] }; - const names = parseProviderAttachmentNames(clean); + const names = parseMcpProviderAttachmentNames(clean); const attachments = names.map((name) => { const provider = inspectMcpProvider(name); if ( diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.test.ts b/src/lib/actions/sandbox/messaging-provider/attachments.test.ts deleted file mode 100644 index 1d7b23b5644..00000000000 --- a/src/lib/actions/sandbox/messaging-provider/attachments.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; -import type { SandboxMessagingPlan } from "../../../messaging"; -import { - restoreChannelMessagingProviderAttachments, - rollbackMessagingProviderAttachments, - type MessagingProviderAttachmentReceipt, -} from "./attachments"; - -type OpenShellRunner = NonNullable< - Parameters[4] ->; - -function result(stdout = "", status = 0, stderr = "") { - return { - pid: 0, - output: [null, stdout, stderr], - stdout, - stderr, - status, - signal: null, - }; -} - -function queuedRunner(results: ReturnType[]) { - const run = vi.fn((..._args: unknown[]) => results.shift() ?? result()); - return { run: run as unknown as OpenShellRunner, spy: run }; -} - -function hermesDiscordPlan(): SandboxMessagingPlan { - return { - schemaVersion: 1, - sandboxName: "alpha", - agent: "hermes", - workflow: "onboard", - channels: [], - disabledChannels: [], - credentialBindings: [ - { - channelId: "discord", - credentialId: "botToken", - sourceInput: "botToken", - providerName: "alpha-discord-bridge", - providerEnvKey: "DISCORD_BOT_TOKEN", - placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", - credentialAvailable: true, - }, - ], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; -} - -const EXACT_PROVIDER = [ - "Id: provider-alpha-discord", - "Name: alpha-discord-bridge", - "Type: discord-hermes-static-v1", - "Resource version: 7", - "Credential keys: DISCORD_BOT_TOKEN", - "Config keys: ", -].join("\n"); - -const RECEIPT: MessagingProviderAttachmentReceipt = { - credentialKey: "DISCORD_BOT_TOKEN", - gatewayName: "nemoclaw-9090", - providerId: "provider-alpha-discord", - providerName: "alpha-discord-bridge", - providerType: "discord-hermes-static-v1", - resourceVersion: 7, -}; - -const ATTACHED_PROVIDER = [ - "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS", - "alpha-discord-bridge discord-hermes-static-v1 1 0", -].join("\n"); - -describe("messaging provider attachment lifecycle", () => { - it("restores an exact Hermes Discord provider before policy application", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER), - result("No providers attached to sandbox alpha."), - result(EXACT_PROVIDER), - result("Attached provider alpha-discord-bridge"), - result(ATTACHED_PROVIDER), - result(EXACT_PROVIDER), - ]); - - expect( - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - "nemoclaw-9090", - fixture.run, - ), - ).toEqual([RECEIPT]); - expect(fixture.spy.mock.calls.map(([args]) => args)).toEqual([ - ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], - ["sandbox", "provider", "-g", "nemoclaw-9090", "list", "alpha"], - ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], - ["sandbox", "provider", "-g", "nemoclaw-9090", "attach", "alpha", "alpha-discord-bridge"], - ["sandbox", "provider", "-g", "nemoclaw-9090", "list", "alpha"], - ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], - ]); - }); - - it("does not mutate an attachment that already exists", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER), - result(ATTACHED_PROVIDER), - result(EXACT_PROVIDER), - ]); - - expect( - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - "nemoclaw-9090", - fixture.run, - ), - ).toEqual([]); - expect(fixture.spy.mock.calls.map(([args]) => args)).toEqual([ - ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], - ["sandbox", "provider", "-g", "nemoclaw-9090", "list", "alpha"], - ["provider", "get", "-g", "nemoclaw-9090", "alpha-discord-bridge"], - ]); - }); - - it("rejects identity drift for an attachment that already exists", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER), - result(ATTACHED_PROVIDER), - result(EXACT_PROVIDER.replace("provider-alpha-discord", "provider-replacement")), - ]); - - expect(() => - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - "nemoclaw-9090", - fixture.run, - ), - ).toThrow("changed across the attachment boundary"); - const commands = fixture.spy.mock.calls.map(([args]) => (args as string[]).join(" ")); - expect(commands.some((command) => command.includes(" attach "))).toBe(false); - expect(commands.some((command) => command.includes(" detach "))).toBe(false); - }); - - it("does not inspect attachments for a channel without credential bindings", () => { - const fixture = queuedRunner([]); - - expect( - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "whatsapp", - "nemoclaw-9090", - fixture.run, - ), - ).toEqual([]); - expect(fixture.spy).not.toHaveBeenCalled(); - }); - - it("rejects a same-name provider with the wrong Hermes binding", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER.replace("discord-hermes-static-v1", "generic")), - ]); - - expect(() => - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - "nemoclaw-9090", - fixture.run, - ), - ).toThrow(/does not match the required 'discord-hermes-static-v1'/u); - expect(fixture.spy).toHaveBeenCalledTimes(1); - }); - - it("reports rollback failures without hiding successful absent detaches", () => { - const teamsReceipt = { - ...RECEIPT, - providerId: "provider-alpha-teams", - providerName: "alpha-teams-bridge", - }; - const fixture = queuedRunner([ - result( - EXACT_PROVIDER.replace("provider-alpha-discord", "provider-alpha-teams").replace( - "alpha-discord-bridge", - "alpha-teams-bridge", - ), - ), - result("provider not attached", 1), - result(EXACT_PROVIDER), - result("gateway unavailable", 1), - ]); - - expect( - rollbackMessagingProviderAttachments("alpha", [RECEIPT, teamsReceipt], fixture.run), - ).toEqual(["alpha-discord-bridge: gateway unavailable"]); - }); - - it("detaches a provisional attachment when confirmation fails", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER), - result("No providers attached to sandbox alpha."), - result(EXACT_PROVIDER), - result("Attached provider alpha-discord-bridge"), - result("gateway unavailable", 1), - result(EXACT_PROVIDER), - result("Detached provider alpha-discord-bridge"), - ]); - - expect(() => - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - "nemoclaw-9090", - fixture.run, - ), - ).toThrow("gateway unavailable"); - expect(fixture.spy.mock.calls.at(-1)?.[0]).toEqual([ - "sandbox", - "provider", - "-g", - "nemoclaw-9090", - "detach", - "alpha", - "alpha-discord-bridge", - ]); - }); - - it("detaches a provisional attachment when confirmation omits it", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER), - result("No providers attached to sandbox alpha."), - result(EXACT_PROVIDER), - result("Attached provider alpha-discord-bridge"), - result("No providers attached to sandbox alpha."), - result(EXACT_PROVIDER), - result("Detached provider alpha-discord-bridge"), - ]); - - expect(() => - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - "nemoclaw-9090", - fixture.run, - ), - ).toThrow("did not confirm provider 'alpha-discord-bridge'"); - expect(fixture.spy.mock.calls.at(-1)?.[0]).toContain("detach"); - }); - - it("does not attach or detach a provider replaced after the metadata precheck", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER), - result("No providers attached to sandbox alpha."), - result(EXACT_PROVIDER.replace("provider-alpha-discord", "provider-replacement")), - ]); - - expect(() => - restoreChannelMessagingProviderAttachments( - "alpha", - hermesDiscordPlan(), - "discord", - "nemoclaw-9090", - fixture.run, - ), - ).toThrow("changed across the attachment boundary"); - const commands = fixture.spy.mock.calls.map(([args]) => (args as string[]).join(" ")); - expect(commands.some((command) => command.includes(" attach "))).toBe(false); - expect(commands.some((command) => command.includes(" detach "))).toBe(false); - }); - - it("refuses to detach a replacement provider during rollback", () => { - const fixture = queuedRunner([ - result(EXACT_PROVIDER.replace("provider-alpha-discord", "provider-replacement")), - ]); - - expect(rollbackMessagingProviderAttachments("alpha", [RECEIPT], fixture.run)).toEqual([ - "alpha-discord-bridge: provider identity changed; refusing detach", - ]); - expect(fixture.spy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/lib/actions/sandbox/messaging-provider/attachments.ts b/src/lib/actions/sandbox/messaging-provider/attachments.ts deleted file mode 100644 index ce78e29f769..00000000000 --- a/src/lib/actions/sandbox/messaging-provider/attachments.ts +++ /dev/null @@ -1,227 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * OpenShell attachment commands accept a provider name but no caller-supplied - * provider ID. The sandbox lifecycle lock serializes NemoClaw mutations. Exact - * gateway, provider ID, resource version, and credential-shape checks reject - * identity drift before attachment and compensation. Remove these checks when - * OpenShell exposes an immutable-ID attachment precondition. - */ - -import { stripAnsi } from "../../../adapters/openshell/ansi"; -import { parseProviderAttachmentNames } from "../../../adapters/openshell/provider-attachment-table"; -import type { SandboxMessagingPlan } from "../../../messaging"; -import { - matchesGatewayCredentialOnlyProviderBinding, - readGatewayProviderIdentity, - type GatewayProviderIdentity, - type GatewayProviderRunner, -} from "../../../onboard/gateway-provider-metadata"; -import { staticMessagingProviderTypeForChannel } from "../../../onboard/messaging-bridge-provider"; - -type OpenShellRunner = GatewayProviderRunner; -type OpenShellResult = ReturnType; - -export type MessagingProviderAttachmentReceipt = { - readonly credentialKey: string; - readonly gatewayName: string; - readonly providerId: string; - readonly providerName: string; - readonly providerType: string; - readonly resourceVersion: number; -}; - -function commandOutput(result: OpenShellResult): string { - const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; - const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; - return stripAnsi(`${stdout ?? ""}\n${stderr ?? ""}`) - .replace(/\r/g, "") - .trim(); -} - -function gatewayScopedArgs(args: string[], gatewayName: string): string[] { - return [...args.slice(0, 2), "-g", gatewayName, ...args.slice(2)]; -} - -function listMessagingProviderAttachments( - sandboxName: string, - gatewayName: string, - run: OpenShellRunner, -): Set { - const result = run(gatewayScopedArgs(["sandbox", "provider", "list", sandboxName], gatewayName), { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - const output = commandOutput(result); - if (result.status !== 0) { - throw new Error(output || `Could not inspect providers attached to '${sandboxName}'.`); - } - try { - return new Set(parseProviderAttachmentNames(output)); - } catch (error) { - throw new Error( - `OpenShell returned invalid provider attachment metadata for '${sandboxName}': ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -function channelCredentialBindings(plan: SandboxMessagingPlan, channelId: string) { - return [ - ...new Map( - plan.credentialBindings - .filter((binding) => binding.channelId === channelId) - .map((binding) => [binding.providerName, binding]), - ).values(), - ]; -} - -function providerIdentityMatchesReceipt( - identity: GatewayProviderIdentity | null, - receipt: MessagingProviderAttachmentReceipt, -): boolean { - return ( - identity?.id === receipt.providerId && - identity.resourceVersion === receipt.resourceVersion && - matchesGatewayCredentialOnlyProviderBinding(identity, { - name: receipt.providerName, - type: receipt.providerType, - credentialKey: receipt.credentialKey, - }) - ); -} - -function readMessagingProviderReceipt( - plan: SandboxMessagingPlan, - binding: SandboxMessagingPlan["credentialBindings"][number], - gatewayName: string, - run: OpenShellRunner, -): MessagingProviderAttachmentReceipt { - const identity = readGatewayProviderIdentity(binding.providerName, run, gatewayName); - const exactType = staticMessagingProviderTypeForChannel(binding.channelId, plan.agent); - const expectedType = exactType ?? identity?.type ?? "generic"; - if ( - !identity || - !matchesGatewayCredentialOnlyProviderBinding(identity, { - name: binding.providerName, - type: expectedType, - credentialKey: binding.providerEnvKey, - }) - ) { - throw new Error( - `Existing provider '${binding.providerName}' does not match the required '${expectedType}' credential binding.`, - ); - } - return { - credentialKey: binding.providerEnvKey, - gatewayName, - providerId: identity.id, - providerName: binding.providerName, - providerType: expectedType, - resourceVersion: identity.resourceVersion, - }; -} - -function assertProviderIdentityUnchanged( - receipt: MessagingProviderAttachmentReceipt, - run: OpenShellRunner, -): void { - const identity = readGatewayProviderIdentity(receipt.providerName, run, receipt.gatewayName); - if (!providerIdentityMatchesReceipt(identity, receipt)) { - throw new Error( - `Provider '${receipt.providerName}' changed across the attachment boundary. Refusing to mutate it.`, - ); - } -} - -export function rollbackMessagingProviderAttachments( - sandboxName: string, - receipts: readonly MessagingProviderAttachmentReceipt[], - run: OpenShellRunner, -): string[] { - const failures: string[] = []; - for (const receipt of [...receipts].reverse()) { - const identity = readGatewayProviderIdentity(receipt.providerName, run, receipt.gatewayName); - if (!providerIdentityMatchesReceipt(identity, receipt)) { - failures.push(`${receipt.providerName}: provider identity changed; refusing detach`); - continue; - } - const result = run( - gatewayScopedArgs( - ["sandbox", "provider", "detach", sandboxName, receipt.providerName], - receipt.gatewayName, - ), - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - const output = commandOutput(result); - if ( - result.status !== 0 && - !/\bNotFound\b|not found|not attached|already detached/i.test(output) - ) { - failures.push(`${receipt.providerName}: ${output || `detach exited ${result.status}`}`); - } - } - return failures; -} - -export function restoreChannelMessagingProviderAttachments( - sandboxName: string, - plan: SandboxMessagingPlan, - channelId: string, - gatewayName: string, - run: OpenShellRunner, -): MessagingProviderAttachmentReceipt[] { - const bindings = channelCredentialBindings(plan, channelId); - if (bindings.length === 0) return []; - const receipts = new Map( - bindings.map((binding) => [ - binding.providerName, - readMessagingProviderReceipt(plan, binding, gatewayName, run), - ]), - ); - - const attachedBefore = listMessagingProviderAttachments(sandboxName, gatewayName, run); - const newlyAttached: MessagingProviderAttachmentReceipt[] = []; - try { - for (const binding of bindings) { - if (attachedBefore.has(binding.providerName)) continue; - const receipt = receipts.get(binding.providerName); - if (!receipt) throw new Error(`Provider '${binding.providerName}' has no identity receipt.`); - assertProviderIdentityUnchanged(receipt, run); - const result = run( - gatewayScopedArgs( - ["sandbox", "provider", "attach", sandboxName, binding.providerName], - gatewayName, - ), - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - if (result.status !== 0) { - throw new Error( - commandOutput(result) || `Failed to attach provider '${binding.providerName}'.`, - ); - } - newlyAttached.push(receipt); - const attachedAfter = listMessagingProviderAttachments(sandboxName, gatewayName, run); - if (!attachedAfter.has(binding.providerName)) { - throw new Error( - `OpenShell did not confirm provider '${binding.providerName}' was attached to '${sandboxName}'.`, - ); - } - } - for (const receipt of receipts.values()) { - assertProviderIdentityUnchanged(receipt, run); - } - return newlyAttached; - } catch (error) { - const rollbackFailures = rollbackMessagingProviderAttachments(sandboxName, newlyAttached, run); - const detail = - rollbackFailures.length > 0 ? ` Rollback failed: ${rollbackFailures.join("; ")}` : ""; - throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`); - } -} diff --git a/src/lib/actions/sandbox/policy-channel-dependencies.ts b/src/lib/actions/sandbox/policy-channel-dependencies.ts index 1a965537c2c..757ee909f6b 100644 --- a/src/lib/actions/sandbox/policy-channel-dependencies.ts +++ b/src/lib/actions/sandbox/policy-channel-dependencies.ts @@ -2,12 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { runOpenshell } from "../../adapters/openshell/runtime"; -import type { SandboxMessagingPlan } from "../../messaging"; -import { - restoreChannelMessagingProviderAttachments, - rollbackMessagingProviderAttachments, - type MessagingProviderAttachmentReceipt, -} from "./messaging-provider/attachments"; type MessagingProviderTokenDefinition = { name: string; @@ -55,26 +49,6 @@ type GooglechatWebhookProxy = Pick< * onboarding and rebuild modules at policy-channel import time. */ export const policyChannelDependencies = { - restoreChannelMessagingProviderAttachments( - sandboxName: string, - plan: SandboxMessagingPlan, - channelId: string, - gatewayName: string, - ): MessagingProviderAttachmentReceipt[] { - return restoreChannelMessagingProviderAttachments( - sandboxName, - plan, - channelId, - gatewayName, - runOpenshell, - ); - }, - rollbackMessagingProviderAttachments( - sandboxName: string, - receipts: readonly MessagingProviderAttachmentReceipt[], - ): string[] { - return rollbackMessagingProviderAttachments(sandboxName, receipts, runOpenshell); - }, isMessagingProviderBindingConflict( error: unknown, ): error is Error & { readonly mutatedProviderNames: readonly string[] } { diff --git a/src/lib/adapters/openshell/ansi.ts b/src/lib/adapters/openshell/ansi.ts deleted file mode 100644 index a7eccaf7344..00000000000 --- a/src/lib/adapters/openshell/ansi.ts +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -const ANSI_RE = /\x1b\[[0-9;]*m/g; - -export function stripAnsi(value = ""): string { - return String(value).replace(ANSI_RE, ""); -} diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 335ed6bd246..012546d0422 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -14,7 +14,6 @@ import { redirectInheritedChildStdoutToStderr } from "../../cli/stdout-guard"; import { buildSubprocessEnv } from "../../subprocess-env"; export { openshellSandboxSshHost, resolveOpenshellSandboxSshHost } from "./sandbox-ssh-host"; -export { stripAnsi } from "./ansi"; export type OpenshellSpawnSync = ( command: string, @@ -83,6 +82,12 @@ export interface CaptureOpenshellResult { signal?: NodeJS.Signals | null; } +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +export function stripAnsi(value = ""): string { + return String(value).replace(ANSI_RE, ""); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/lib/adapters/openshell/provider-attachment-table.test.ts b/src/lib/adapters/openshell/provider-attachment-table.test.ts deleted file mode 100644 index dd4f442c11f..00000000000 --- a/src/lib/adapters/openshell/provider-attachment-table.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { parseProviderAttachmentNames } from "./provider-attachment-table"; - -describe("OpenShell provider attachment table", () => { - it("parses empty, populated, and ANSI-decorated attachment output", () => { - expect(parseProviderAttachmentNames("No providers attached to sandbox alpha.")).toEqual([]); - expect( - parseProviderAttachmentNames( - "\u001b[1mNAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\u001b[0m\nalpha-token generic 1 0\n", - ), - ).toEqual(["alpha-token"]); - }); - - it("rejects output without the attachment table header", () => { - expect(() => parseProviderAttachmentNames("alpha-token generic 1 0\n")).toThrow( - "missing provider attachment table header", - ); - }); - - it("rejects malformed attachment table rows", () => { - expect(() => - parseProviderAttachmentNames( - "NAME TYPE CREDENTIAL_KEYS CONFIG_KEYS\nalpha-token generic one zero\n", - ), - ).toThrow("invalid provider attachment table row"); - }); -}); diff --git a/src/lib/adapters/openshell/provider-attachment-table.ts b/src/lib/adapters/openshell/provider-attachment-table.ts deleted file mode 100644 index 3b84ce153f0..00000000000 --- a/src/lib/adapters/openshell/provider-attachment-table.ts +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { stripAnsi } from "./ansi"; - -/** Parse the provider names from `openshell sandbox provider list`. */ -export function parseProviderAttachmentNames(output: string): string[] { - const clean = stripAnsi(output).replace(/\r/g, "").trim(); - if (/^No providers attached to sandbox\b/m.test(clean)) return []; - const lines = clean - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - const headerIndex = lines.findIndex((line) => - /^NAME\s+TYPE\s+CREDENTIAL_KEYS\s+CONFIG_KEYS$/.test(line), - ); - if (headerIndex < 0) throw new Error("missing provider attachment table header"); - return lines.slice(headerIndex + 1).map((line) => { - const match = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$/); - if (!match?.[1]) throw new Error("invalid provider attachment table row"); - return match[1]; - }); -} diff --git a/src/lib/messaging/channels/policy.ts b/src/lib/messaging/channels/policy.ts index da4cee8cc42..8865086011f 100644 --- a/src/lib/messaging/channels/policy.ts +++ b/src/lib/messaging/channels/policy.ts @@ -8,10 +8,7 @@ import YAML from "yaml"; import { isValidName } from "../../sandbox-name-contract"; import { ROOT } from "../../state/paths"; import type { MessagingAgentId } from "../manifest"; -import { - getMessagingPolicyKeysByChannel, - listMessagingPolicyPresetMetadata, -} from "./metadata"; +import { listMessagingPolicyPresetMetadata } from "./metadata"; type PolicyPresetLocator = { readonly channelId: string; @@ -70,63 +67,6 @@ export function materializeMessagingPolicySandboxName( return content.replaceAll("{sandboxName}", sandboxName); } -export function filterInactiveMessagingChannelPolicies( - content: string, - activeChannels: readonly string[], - agent: MessagingAgentId, -): { content: string; changed: boolean } { - let parsed: unknown; - try { - parsed = YAML.parse(content); - } catch { - return { content, changed: false }; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return { content, changed: false }; - } - const policy = parsed as Record; - const networkPolicies = policy.network_policies; - if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { - return { content, changed: false }; - } - - const active = new Set(activeChannels); - const entries = networkPolicies as Record; - let changed = false; - for (const [channel, policyKeys] of Object.entries( - getMessagingPolicyKeysByChannel({ agent }), - )) { - if (active.has(channel)) continue; - for (const key of policyKeys) { - if (!Object.hasOwn(entries, key)) continue; - delete entries[key]; - changed = true; - } - } - return { content: changed ? YAML.stringify(policy) : content, changed }; -} - -export function messagingChannelsPresentInPolicy( - content: string, - agent: MessagingAgentId, -): string[] { - let parsed: unknown; - try { - parsed = YAML.parse(content); - } catch { - return []; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return []; - const networkPolicies = (parsed as Record).network_policies; - if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { - return []; - } - const keys = new Set(Object.keys(networkPolicies)); - return Object.entries(getMessagingPolicyKeysByChannel({ agent })) - .filter(([, policyKeys]) => policyKeys.some((key) => keys.has(key))) - .map(([channel]) => channel); -} - function normalizeAgent( agent: MessagingAgentId | string | null | undefined, ): MessagingAgentId | null { diff --git a/src/lib/onboard/gateway-provider-metadata.test.ts b/src/lib/onboard/gateway-provider-metadata.test.ts index 2dbba5358f6..020a7873d46 100644 --- a/src/lib/onboard/gateway-provider-metadata.test.ts +++ b/src/lib/onboard/gateway-provider-metadata.test.ts @@ -7,9 +7,7 @@ import { inspectGatewayCredentialOnlyProviderBinding, matchesGatewayCredentialOnlyProviderBinding, matchesGatewayProviderBinding, - parseGatewayProviderIdentity, parseGatewayProviderMetadata, - readGatewayProviderIdentity, readGatewayProviderMetadata, } from "./gateway-provider-metadata"; @@ -141,49 +139,6 @@ describe("gateway provider metadata", () => { }); }); - it("parses and reads the exact gateway-scoped provider mutation identity", () => { - const runOpenshell = vi.fn(() => ({ status: 0, stdout: COMPLETE_OUTPUT })); - const expected = { - name: "compatible-endpoint", - type: "openai", - credentialKeys: ["COMPATIBLE_API_KEY"], - configKeys: ["OPENAI_BASE_URL", "EXTRA_FLAG"], - id: "2ca3b7c7-eff4-4399-af5a-13c4984d7343", - resourceVersion: 1, - }; - - expect(parseGatewayProviderIdentity(COMPLETE_OUTPUT)).toEqual(expected); - expect( - readGatewayProviderIdentity("compatible-endpoint", runOpenshell, "nemoclaw-9090"), - ).toEqual(expected); - expect(runOpenshell).toHaveBeenCalledWith( - ["provider", "get", "-g", "nemoclaw-9090", "compatible-endpoint"], - { - ignoreError: true, - suppressOutput: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - }); - - it.each([ - ["duplicate ID", `${COMPLETE_OUTPUT}\nId: second-id`], - [ - "non-decimal resource version", - COMPLETE_OUTPUT.replace("Resource version:\u001b[0m 1", "Resource version:\u001b[0m 0x10"), - ], - ["unsafe ID", COMPLETE_OUTPUT.replace("2ca3b7c7-eff4-4399-af5a-13c4984d7343", "unsafe/id")], - [ - "out-of-range resource version", - COMPLETE_OUTPUT.replace( - "Resource version:\u001b[0m 1", - "Resource version:\u001b[0m 9007199254740993", - ), - ], - ])("rejects a provider identity with %s", (_label, output) => { - expect(parseGatewayProviderIdentity(output)).toBeNull(); - }); - it.each([ [ "OSC injection inside the provider name", diff --git a/src/lib/onboard/gateway-provider-metadata.ts b/src/lib/onboard/gateway-provider-metadata.ts index dc40793de6d..f5a7f20df92 100644 --- a/src/lib/onboard/gateway-provider-metadata.ts +++ b/src/lib/onboard/gateway-provider-metadata.ts @@ -8,7 +8,6 @@ const PROVIDER_PROBE_DIAGNOSTIC_LIMIT = 64 * 1024; const PROVIDER_PROBE_TIMEOUT_MS = 5_000; const MAX_PROVIDER_NAME_LENGTH = 128; const MAX_PROVIDER_TYPE_LENGTH = 64; -const MAX_PROVIDER_ID_LENGTH = 128; const MAX_PROVIDER_KEYS = 32; const MAX_PROVIDER_KEY_LENGTH = 128; const SAFE_PROVIDER_IDENTIFIER = /^[A-Za-z0-9._:-]+$/; @@ -25,11 +24,6 @@ export type GatewayProviderMetadata = { configKeys: string[]; }; -export type GatewayProviderIdentity = GatewayProviderMetadata & { - id: string; - resourceVersion: number; -}; - export type GatewayProviderBinding = { name: string; type: string; @@ -83,12 +77,12 @@ type GatewayProviderCommandResult = { signal?: unknown; }; -export type GatewayProviderRunner = ( +type GatewayProviderRunner = ( args: string[], options: { ignoreError: true; maxBuffer?: number; - suppressOutput?: true; + suppressOutput: true; stdio: ["ignore", "pipe", "pipe"]; timeout?: number; }, @@ -103,7 +97,6 @@ export type GatewayCredentialOnlyProviderInspection = type ProviderField = "Name" | "Type" | "Credential keys" | "Config keys"; const PROVIDER_FIELD_PATTERN = /^\s*(Name|Type|Credential keys|Config keys):\s*(.*?)\s*$/i; -const PROVIDER_IDENTITY_FIELD_PATTERN = /^\s*(Id|Resource version):\s*(.*?)\s*$/i; const CANONICAL_PROVIDER_FIELDS = new Map([ ["name", "Name"], ["type", "Type"], @@ -202,38 +195,6 @@ export function parseGatewayProviderMetadata(output: string): GatewayProviderMet return { name, type, credentialKeys, configKeys }; } -/** Parse the immutable ID and resource version with the provider binding shape. */ -export function parseGatewayProviderIdentity(output: string): GatewayProviderIdentity | null { - const metadata = parseGatewayProviderMetadata(output); - if (!metadata) return null; - - const fields = new Map(); - for (const rawLine of output.split(/\r?\n/u)) { - const line = rawLine.replace(ANSI_OSC_PATTERN, "").replace(ANSI_CSI_PATTERN, ""); - const match = line.match(PROVIDER_IDENTITY_FIELD_PATTERN); - if (!match) continue; - if (hasUnsafeRawProviderFieldValue(rawLine)) return null; - const field = match[1].toLowerCase(); - if (fields.has(field)) return null; - fields.set(field, match[2].trim()); - } - - const id = fields.get("id"); - const resourceVersionText = fields.get("resource version"); - if ( - !id || - !isSafeIdentifier(id, MAX_PROVIDER_ID_LENGTH) || - !resourceVersionText || - !/^\d+$/u.test(resourceVersionText) - ) { - return null; - } - const resourceVersion = Number.parseInt(resourceVersionText, 10); - if (!Number.isSafeInteger(resourceVersion) || resourceVersion < 0) return null; - - return { ...metadata, id, resourceVersion }; -} - /** Distinguish an exact credential-only binding from absence and lookup failure. */ export function inspectGatewayCredentialOnlyProviderBinding( expected: GatewayCredentialOnlyProviderBinding, @@ -268,12 +229,12 @@ export function inspectGatewayCredentialOnlyProviderBinding( : { kind: "collision" }; } -function readGatewayProvider( +/** Read one exact provider identity without reading or exporting credential values. */ +export function readGatewayProviderMetadata( name: string, runOpenshell: GatewayProviderRunner, - gatewayName: string | null | undefined, - parse: (output: string) => T | null, -): T | null { + gatewayName?: string | null, +): GatewayProviderMetadata | null { if (!isSafeIdentifier(name, MAX_PROVIDER_NAME_LENGTH)) return null; const args = ["provider", "get"]; @@ -287,24 +248,6 @@ function readGatewayProvider( if (result.status !== 0) return null; const output = `${commandStreamText(result.stdout)}\n${commandStreamText(result.stderr)}`; - const provider = parse(output); - return provider?.name === name ? provider : null; -} - -/** Read one exact provider identity without reading or exporting credential values. */ -export function readGatewayProviderMetadata( - name: string, - runOpenshell: GatewayProviderRunner, - gatewayName?: string | null, -): GatewayProviderMetadata | null { - return readGatewayProvider(name, runOpenshell, gatewayName, parseGatewayProviderMetadata); -} - -/** Read one gateway-scoped provider identity for a mutation precondition. */ -export function readGatewayProviderIdentity( - name: string, - runOpenshell: GatewayProviderRunner, - gatewayName?: string | null, -): GatewayProviderIdentity | null { - return readGatewayProvider(name, runOpenshell, gatewayName, parseGatewayProviderIdentity); + const metadata = parseGatewayProviderMetadata(output); + return metadata?.name === name ? metadata : null; } diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 7c77cf533be..4e05baef0c2 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -7,7 +7,7 @@ import { TextDecoder } from "node:util"; import YAML from "yaml"; import { isObjectRecord } from "../core/json-types"; -import { filterInactiveMessagingChannelPolicies } from "../messaging/channels"; +import { getMessagingPolicyKeysByChannel } from "../messaging/channels"; import * as policies from "../policy"; import { applyBaselineExclusions, @@ -49,6 +49,8 @@ export function discloseInitialSandboxPolicy(policy: InitialSandboxPolicy): void ); } +const HERMES_MESSAGING_POLICY_KEYS = getMessagingPolicyKeysByChannel({ agent: "hermes" }); + const PROC_PATH = "/proc"; const PROC_COMM_READ_WRITE_PATHS = ["/proc/self/comm", "/proc/self/task/*/comm"]; const SYSFS_PATH = "/sys"; @@ -363,11 +365,27 @@ function filterHermesInactiveMessagingPolicies( policyContent: string, activeMessagingChannels: string[], ): { content: string; changed: boolean } { - return filterInactiveMessagingChannelPolicies( - policyContent, - activeMessagingChannels, - "hermes", - ); + const parsed = YAML.parse(policyContent); + if (!isObjectRecord(parsed) || !isObjectRecord(parsed.network_policies)) { + return { content: policyContent, changed: false }; + } + + const active = new Set(activeMessagingChannels); + let changed = false; + for (const [channel, policyKeys] of Object.entries(HERMES_MESSAGING_POLICY_KEYS)) { + if (active.has(channel)) continue; + for (const key of policyKeys) { + if (Object.prototype.hasOwnProperty.call(parsed.network_policies, key)) { + delete parsed.network_policies[key]; + changed = true; + } + } + } + + return { + content: changed ? YAML.stringify(parsed) : policyContent, + changed, + }; } function isHermesPolicyPath(policyPath: string): boolean { diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 721040e2f71..5cf50806638 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -361,9 +361,7 @@ function reconcileDeps(plans: readonly (SandboxMessagingPlan | null)[]) { authoritative: false, plan: null, })), - providerMatchesGatewayCredential: vi.fn( - (_name: string, _type: string, _credentialEnv: string) => false, - ), + providerMatchesGatewayCredential: vi.fn(() => false), }; } @@ -743,71 +741,6 @@ describe("reconcileSandboxMessaging plan authority", () => { expect(result).toEqual({ plan: registryPlan, selectedChannels: ["whatsapp"] }); }); - it("preserves a gateway-held Hermes channel during an authoritative rebuild", async () => { - const registryPlan = discordPlan( - hashCredential("historical-discord-token") ?? "", - "hermes", - ); - const deps = reconcileDeps([]); - deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ - authoritative: true, - plan: registryPlan, - }); - deps.providerMatchesGatewayCredential.mockImplementation( - (name, type, credentialEnv) => - name === "alpha-discord-bridge" && - type === "discord-hermes-static-v1" && - credentialEnv === "DISCORD_BOT_TOKEN", - ); - vi.stubEnv("DISCORD_BOT_TOKEN", ""); - - const result = await reconcileSandboxMessaging({ - resume: true, - session: completedCheckpointSession(registryPlan), - sandboxName: "alpha", - agent: { name: "hermes" }, - preserveGatewayHeldRegistrySelection: true, - deps, - }); - - expect(deps.providerMatchesGatewayCredential).toHaveBeenCalledWith( - "alpha-discord-bridge", - "discord-hermes-static-v1", - "DISCORD_BOT_TOKEN", - ); - expect(deps.note).not.toHaveBeenCalledWith( - expect.stringContaining("No host inputs configure discord"), - ); - expect(result).toEqual({ plan: registryPlan, selectedChannels: ["discord"] }); - }); - - it("does not preserve an authoritative rebuild channel with a mismatched gateway binding", async () => { - const registryPlan = discordPlan( - hashCredential("historical-discord-token") ?? "", - "hermes", - ); - const deps = reconcileDeps([]); - deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ - authoritative: true, - plan: registryPlan, - }); - vi.stubEnv("DISCORD_BOT_TOKEN", ""); - - const result = await reconcileSandboxMessaging({ - resume: true, - session: completedCheckpointSession(registryPlan), - sandboxName: "alpha", - agent: { name: "hermes" }, - preserveGatewayHeldRegistrySelection: true, - deps, - }); - - expect(result).toEqual({ - plan: withChannelDisabled(registryPlan, "discord"), - selectedChannels: [], - }); - }); - it("uses the staged plan before a matching session plan during resume for a pending target", async () => { const sessionPlan = telegramPlan(hashCredential("123456:session-token") ?? ""); const stagedPlan = slackPlan(hashCredential("staged-slack-token") ?? ""); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 7f32533632f..d179382d723 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -78,8 +78,6 @@ export interface ReconcileSandboxMessagingOptions { readonly registryAuthoritySnapshot?: RegistryMessagingAuthority; readonly credentialValidationPlan?: SandboxMessagingPlan | null; readonly forceCredentialValidation?: boolean; - /** Authoritative rebuilds may preserve recorded channels backed by exact gateway bindings. */ - readonly preserveGatewayHeldRegistrySelection?: boolean; readonly deps: SandboxMessagingDeps; } @@ -226,35 +224,10 @@ function selectionFromReusablePlan( }; } -function hasExactGatewayCredentialBindings( - plan: SandboxMessagingPlan, - channelId: string, - agentName: string | undefined, - providerMatchesGatewayCredential: - | SandboxMessagingDeps["providerMatchesGatewayCredential"] - | undefined, -): boolean { - if (!providerMatchesGatewayCredential) return false; - const bindings = plan.credentialBindings.filter((binding) => binding.channelId === channelId); - return ( - bindings.length > 0 && - bindings.every((binding) => - providerMatchesGatewayCredential( - binding.providerName, - staticMessagingProviderTypeForChannel(binding.channelId, agentName) ?? - MESSAGING_CREDENTIAL_PROVIDER_TYPE, - binding.providerEnvKey, - ), - ) - ); -} - function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, - deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv"> & - Partial, "providerMatchesGatewayCredential">>, - preserveGatewayHeldRegistrySelection = false, + deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host // input. Rebuild the host-backed selection so policy reconciliation can @@ -267,21 +240,6 @@ function filterUnconfiguredHostChannelsFromSelection( agent as Parameters[2], ), ); - if (preserveGatewayHeldRegistrySelection && selection.plan) { - const agentName = (agent as MessagingAgentLike | null)?.name; - for (const channelId of unconfiguredChannels) { - if ( - hasExactGatewayCredentialBindings( - selection.plan, - channelId, - agentName, - deps.providerMatchesGatewayCredential, - ) - ) { - unconfiguredChannels.delete(channelId); - } - } - } if (unconfiguredChannels.size === 0) return selection; deps.note( ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, @@ -431,12 +389,7 @@ function selectionFromRecordedChannels( 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, - options.preserveGatewayHeldRegistrySelection, - ); + selection = filterUnconfiguredHostChannelsFromSelection(selection, options.agent, options.deps); if (selection.selectedChannels.length > 0) { options.deps.note( ` [non-interactive] Reusing messaging channel configuration: ${selection.selectedChannels.join(", ")}`, @@ -473,7 +426,6 @@ async function selectionFromRegistryPlan( selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), options.agent, options.deps, - options.preserveGatewayHeldRegistrySelection, ); } const activeChannels = filterChannelNamesForCurrentAgent( @@ -502,7 +454,6 @@ async function selectionFromRegistryPlan( selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), options.agent, options.deps, - options.preserveGatewayHeldRegistrySelection, ); } options.deps.note( @@ -709,7 +660,6 @@ async function selectionFromRegistryAuthority( selection, options.agent, options.deps, - options.preserveGatewayHeldRegistrySelection, ); } if (authority.plan) return selectionFromRegistryPlan(authority.plan, options); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 787a84980e4..7039dd650b4 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -2146,7 +2146,6 @@ class SandboxStateFlow< registryAuthoritySnapshot: registryMessagingAuthority, credentialValidationPlan: messagingCredentialChanged ? messagingCredentialBaseline : null, forceCredentialValidation: messagingCredentialChanged, - preserveGatewayHeldRegistrySelection: this.options.authoritativeResumeConfig === true, deps: this.deps, }); const messagingProviderBindings = requiredMessagingProviderBindings( diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 9f67200a8a7..b028fd0ea9f 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -18,7 +18,6 @@ import { CLI_NAME } from "../cli/branding"; import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, - filterInactiveMessagingChannelPolicies, isMessagingChannelPolicyPreset, listBuiltInMessagingChannelManifests, listMessagingChannelPolicyPresets, @@ -2921,20 +2920,8 @@ function applyPermissivePolicy(sandboxName: string): void { if (!fs.existsSync(policyPath)) { throw new Error(`Permissive policy not found: ${policyPath}`); } - const sandbox = registry.getSandbox(sandboxName); const policyDocument = fs.readFileSync(policyPath, "utf-8"); - const channelFilteredPolicy = - sandbox?.agent === "hermes" - ? filterInactiveMessagingChannelPolicies( - policyDocument, - registry.getActiveMessagingChannelsFromEntry(sandbox), - "hermes", - ).content - : policyDocument; - const materializedPolicy = materializeMessagingPolicySandboxName( - channelFilteredPolicy, - sandboxName, - ); + const materializedPolicy = materializeMessagingPolicySandboxName(policyDocument, sandboxName); if (materializedPolicy === null) { throw new Error("Cannot materialize the permissive policy credential provider binding"); } diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index cf56ac0b035..83d7964270a 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -64,7 +64,6 @@ const { const { assertLegacyMcpPolicyRestoreSafe, buildDeadlineRuntimeManagedMcpPolicy, - buildRegisteredRuntimePermissivePolicy, buildRuntimeManagedMcpPolicy, buildRuntimePermissivePolicy, hasManagedMcpPolicyClaims, @@ -5093,21 +5092,12 @@ function shieldsDownWithoutHostLock( // policyYaml is the pre-parsed body we already captured for the // snapshot above — reuse it instead of re-fetching. Exact generated MCP // entries are overlaid without copying any unrelated live egress. - const permissiveDeps = { + policyFile = buildRuntimePermissivePolicy(basePath, { livePolicyYaml: policyYaml, managedMcpPolicies, readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), - }; - if (target.agentName === "hermes") { - const { sandbox } = resolveRegisteredSandboxAgentAuthority(sandboxName); - policyFile = buildRegisteredRuntimePermissivePolicy(basePath, { - ...permissiveDeps, - sandboxEntry: sandbox, - sandboxName, - }); - } else { - policyFile = buildRuntimePermissivePolicy(basePath, permissiveDeps); - } + ...(target.agentName === "hermes" ? { sandboxName } : {}), + }); policyFileIsTemp = policyFile !== basePath; } else if (fs.existsSync(policyName)) { const basePath = path.resolve(policyName); diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 4394d636ff7..aa3b91bb5b6 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -16,15 +16,13 @@ import type { ExactManagedMcpPolicy, ManagedMcpPolicyOmission, } from "../actions/sandbox/mcp-bridge-policy"; -import { - filterInactiveMessagingChannelPolicies, - materializeMessagingPolicySandboxName, -} from "../messaging/channels/policy"; +import { materializeMessagingPolicySandboxName } from "../messaging/channels/policy"; import { cleanupTempDir, secureTempFile } from "../onboard/temp-files"; -import { getActiveMessagingChannelsFromEntry } from "../state/registry-messaging"; -import type { SandboxEntry } from "../state/registry/types"; -export { assertLegacyMcpPolicyRestoreSafe, isManagedMcpPolicyKey } from "./mcp-policy-transition"; +export { + assertLegacyMcpPolicyRestoreSafe, + isManagedMcpPolicyKey, +} from "./mcp-policy-transition"; import { composeDeadlineManagedMcpPolicies, @@ -98,31 +96,6 @@ export interface PermissiveRuntimeDeps { // binding. Supplying the target name makes composition fail closed unless // every placeholder can be materialized before the policy is staged. sandboxName?: string; - // Persisted manifest state is the enablement authority. Live policy can be - // stale during a transition and must never reactivate a disabled channel. - activeMessagingChannels?: readonly string[]; -} - -export interface RegisteredPermissiveRuntimeDeps extends Omit< - PermissiveRuntimeDeps, - "activeMessagingChannels" | "sandboxName" -> { - sandboxEntry: SandboxEntry; - sandboxName: string; -} - -export function buildRegisteredRuntimePermissivePolicy( - basePermissivePath: string, - deps: RegisteredPermissiveRuntimeDeps, -): string { - if (deps.sandboxEntry.name !== deps.sandboxName || deps.sandboxEntry.agent !== "hermes") { - throw new Error("Cannot compose Hermes Shields-down policy without exact registry authority"); - } - const { sandboxEntry, ...runtimeDeps } = deps; - return buildRuntimePermissivePolicy(basePermissivePath, { - ...runtimeDeps, - activeMessagingChannels: getActiveMessagingChannelsFromEntry(sandboxEntry), - }); } export function buildRuntimePermissivePolicy( @@ -133,7 +106,11 @@ export function buildRuntimePermissivePolicy( const liveRw = readStringList(live, "read_write"); const liveRo = readStringList(live, "read_only"); const managedMcpPolicies = deps.managedMcpPolicies ?? []; - const activeMessagingChannels = deps.activeMessagingChannels ?? []; + const discordProviderName = deps.sandboxName + ? `${deps.sandboxName}-discord-bridge` + : null; + const preserveDiscordBinding = + discordProviderName !== null && policyUsesCredentialProvider(live, discordProviderName); // No live startup-sealed or filesystem state to carry forward — keep the // static path so the caller's apply path is unchanged unless exact managed @@ -164,16 +141,12 @@ export function buildRuntimePermissivePolicy( } return basePermissivePath; } - if (deps.sandboxName !== undefined) { + if (deps.sandboxName !== undefined && preserveDiscordBinding) { const materialized = materializeMessagingPolicySandboxName(baseYaml, deps.sandboxName); if (materialized === null) { throw new Error("Cannot materialize the Shields-down credential provider binding"); } - baseYaml = filterInactiveMessagingChannelPolicies( - materialized, - activeMessagingChannels, - "hermes", - ).content; + baseYaml = materialized; } const base = safeYamlObject(baseYaml); if (!base) { @@ -185,6 +158,12 @@ export function buildRuntimePermissivePolicy( } return basePermissivePath; } + if (deps.sandboxName !== undefined && !preserveDiscordBinding) { + const networkPolicies = base.network_policies; + if (networkPolicies && typeof networkPolicies === "object" && !Array.isArray(networkPolicies)) { + delete (networkPolicies as Record).discord; + } + } const fsPolicy = base.filesystem_policy && typeof base.filesystem_policy === "object" ? (base.filesystem_policy as Record) @@ -375,6 +354,30 @@ function safeYamlObject(text: string): Record | null { return null; } +function policyUsesCredentialProvider( + policy: Record | null, + providerName: string, +): boolean { + const networkPolicies = policy?.network_policies; + if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { + return false; + } + for (const networkPolicy of Object.values(networkPolicies)) { + if (!networkPolicy || typeof networkPolicy !== "object" || Array.isArray(networkPolicy)) { + continue; + } + const endpoints = (networkPolicy as Record).endpoints; + if (!Array.isArray(endpoints)) continue; + for (const endpoint of endpoints) { + if (!endpoint || typeof endpoint !== "object" || Array.isArray(endpoint)) continue; + const binding = (endpoint as Record).credential_binding; + if (!binding || typeof binding !== "object" || Array.isArray(binding)) continue; + if ((binding as Record).provider === providerName) return true; + } + } + return false; +} + function readStringList( root: Record | null, key: "read_only" | "read_write", diff --git a/src/lib/state/registry-messaging.ts b/src/lib/state/registry-messaging.ts index e3f3f411cce..279ffb4a8c5 100644 --- a/src/lib/state/registry-messaging.ts +++ b/src/lib/state/registry-messaging.ts @@ -5,7 +5,6 @@ import { hydrateDerivedSandboxMessagingPlanFields } from "../messaging/hydration import type { SandboxMessagingPlan } from "../messaging/manifest"; import { compactSandboxMessagingPlanForPersistence } from "../messaging/persistence"; import { - getActiveChannelIdsFromPlan, getConfiguredChannelIdsFromPlan, getDisabledChannelIdsFromPlan, parseSandboxMessagingPlan, @@ -69,12 +68,6 @@ export function getConfiguredMessagingChannelsFromEntry( return getConfiguredChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); } -export function getActiveMessagingChannelsFromEntry( - entry: EntryWithMessaging | null | undefined, -): string[] { - return getActiveChannelIdsFromPlan(getMessagingPlanFromEntry(entry)); -} - export function getDisabledMessagingChannelsFromEntry( entry: EntryWithMessaging | null | undefined, ): string[] { diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 611db5726b5..233ccafcd81 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -109,7 +109,6 @@ export type { } from "./registry/types"; export type { McpBridgeEntry, SandboxMcpState } from "./registry-mcp"; export { - getActiveMessagingChannelsFromEntry, getConfiguredMessagingChannelsFromEntry, getDisabledMessagingChannelsFromEntry, getHydratedMessagingPlanFromEntry, diff --git a/test/e2e/live/rebuild-hermes-bootstrap.ts b/test/e2e/live/rebuild-hermes-bootstrap.ts index cbb483c9497..7f8720c89bf 100644 --- a/test/e2e/live/rebuild-hermes-bootstrap.ts +++ b/test/e2e/live/rebuild-hermes-bootstrap.ts @@ -21,10 +21,6 @@ import type { ShellProbeOutputEvent, ShellProbeResult } from "../fixtures/shell- import { requireRebuildHermesCurrentBaseIdentity } from "./rebuild-hermes-base-identity.ts"; const CURRENT_BASE_MARKER = "__NEMOCLAW_REBUILD_HERMES_CURRENT_BASE__"; -const HERMES_DISCORD_PROVIDER_PROFILE = path.join( - REPO_ROOT, - "src/lib/messaging/channels/discord/provider-profile/hermes.yaml", -); export const GATEWAY_BOOTSTRAP_MARKER = "__NEMOCLAW_REBUILD_HERMES_GATEWAY_READY__"; export interface RebuildHermesCurrentBaseResult { @@ -60,16 +56,6 @@ interface RebuildHermesGatewayBootstrapOptions extends RebuildHermesBootstrapOpt sandboxName: string; } -interface RebuildHermesDiscordProviderOptions { - activeOpenshellBin: string; - apiKey: string; - discordToken: string; - envFactory: RebuildHermesChildEnvFactory; - host: HostCliClient; - redactionValues: string[]; - sandboxName: string; -} - interface RebuildHermesDashboardPortOptions { sandboxName: string; forwardListOutput: string; @@ -118,42 +104,6 @@ function requireResolutionMetadata(value: unknown): SandboxBaseImageResolutionMe return value as SandboxBaseImageResolutionMetadata; } -export async function createRebuildHermesDiscordProvider( - options: RebuildHermesDiscordProviderOptions, -): Promise { - const profile = await options.host.command( - options.activeOpenshellBin, - ["provider", "profile", "import", "--file", HERMES_DISCORD_PROVIDER_PROFILE], - { - artifactName: "phase-3-discord-provider-profile-import", - env: options.envFactory(options.apiKey), - redactionValues: options.redactionValues, - timeoutMs: 2 * 60_000, - }, - ); - assertExitZero(profile, "import Hermes Discord provider profile"); - const provider = await options.host.command( - options.activeOpenshellBin, - [ - "provider", - "create", - "--name", - `${options.sandboxName}-discord-bridge`, - "--type", - "discord-hermes-static-v1", - "--credential", - "DISCORD_BOT_TOKEN", - ], - { - artifactName: "phase-3-discord-provider-create", - env: options.envFactory(options.apiKey, { DISCORD_BOT_TOKEN: options.discordToken }), - redactionValues: options.redactionValues, - timeoutMs: 2 * 60_000, - }, - ); - assertExitZero(provider, "create Hermes Discord provider"); -} - export function buildRebuildHermesCurrentBaseScript(): string { return [ '"use strict";', diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index dc29c7f834b..2ec06011361 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -34,7 +34,6 @@ import { bootstrapRebuildHermesGateway, cleanupRebuildHermesForward as cleanupHermesForward, cleanupRebuildHermesTrackedForwards, - createRebuildHermesDiscordProvider, requireRebuildHermesDashboardPort, requireRebuildHermesHostedInferenceRoute, requireRebuildHermesOpenshellBin, @@ -891,15 +890,28 @@ test(STALE_BASE_REBUILD "utf8", ); try { - await createRebuildHermesDiscordProvider({ - activeOpenshellBin, - apiKey, - discordToken: DISCORD_FAKE_TOKEN, - envFactory: testEnv, - host, - redactionValues, - sandboxName: SANDBOX_NAME, - }); + const provider = await host.command( + "bash", + [ + "-lc", + [ + "set -euo pipefail", + '"$OPENSHELL_BIN" provider create --name "$DISCORD_PROVIDER" --type generic --credential DISCORD_BOT_TOKEN ||', + ' "$OPENSHELL_BIN" provider update "$DISCORD_PROVIDER" --credential DISCORD_BOT_TOKEN', + ].join("\n"), + ], + { + artifactName: "phase-3-discord-provider-create-or-update", + env: testEnv(apiKey, { + DISCORD_BOT_TOKEN: DISCORD_FAKE_TOKEN, + DISCORD_PROVIDER: `${SANDBOX_NAME}-discord-bridge`, + OPENSHELL_BIN: activeOpenshellBin, + }), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(provider, "OpenShell Discord provider create/update"); progress.phase("create the historical Hermes sandbox"); const createOldSandbox = await host.command( activeOpenshellBin, diff --git a/test/e2e/support/pr-managed-image-publication.test.ts b/test/e2e/support/pr-managed-image-publication.test.ts index 21fb7cf8cd1..18eb4bc35d9 100644 --- a/test/e2e/support/pr-managed-image-publication.test.ts +++ b/test/e2e/support/pr-managed-image-publication.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, @@ -20,11 +20,9 @@ import { import { assembleManagedImageCatalog, main, - ManagedImagePublicationPendingError, managedImagePublicationRequired, parseManagedImagePullRequestPaths, selectManagedImagePublicationRun, - waitForPrManagedImageCatalog, } from "../../../tools/e2e/pr-managed-image-publication.mts"; const CANDIDATE_SHA = "a".repeat(40); @@ -122,15 +120,11 @@ on: }); it.each([ - ["queued", { status: "queued", conclusion: null }, "still running"], - ["in progress", { status: "in_progress", conclusion: null }, "still running"], - ["waiting", { status: "waiting", conclusion: null }, "still running"], - ["pending", { status: "pending", conclusion: null }, "still running"], - ["requested", { status: "requested", conclusion: null }, "still running"], + ["pending", { status: "in_progress", conclusion: null }, "must complete successfully"], ["failed", { conclusion: "failure" }, "must complete successfully"], ["different commit", { head_sha: "b".repeat(40) }, "commit must be"], ["different PR", { pull_requests: [{ number: 9464 }] }, "PR number"], - ])("classifies a %s publication run", (_label, overrides, message) => { + ])("rejects a %s publication run", (_label, overrides, message) => { expect(() => selectManagedImagePublicationRun(run(overrides), { headSha: CANDIDATE_SHA, @@ -140,66 +134,6 @@ on: ).toThrow(message); }); - it("waits when GitHub has not created the exact publication run", () => { - expect(() => - selectManagedImagePublicationRun( - { total_count: 0, workflow_runs: [] }, - { - headSha: CANDIDATE_SHA, - prNumber: PR_NUMBER, - workflowId: WORKFLOW_ID, - }, - ), - ).toThrow(ManagedImagePublicationPendingError); - }); - - it("waits only while the exact publication is pending", async () => { - const resolve = vi - .fn() - .mockRejectedValueOnce(new ManagedImagePublicationPendingError("still running")) - .mockResolvedValueOnce("written" as const); - const sleep = vi.fn(async () => undefined); - - await expect( - waitForPrManagedImageCatalog( - { - baseSha: "b".repeat(40), - candidateRepository: "NVIDIA/NemoClaw", - candidateSha: CANDIDATE_SHA, - outputPath: "/tmp/catalog.json", - prNumber: PR_NUMBER, - token: "token", - workflowSource: "trusted workflow", - }, - { attempts: 2, delayMs: 1, resolve, sleep }, - ), - ).resolves.toBe("written"); - expect(resolve).toHaveBeenCalledTimes(2); - expect(sleep).toHaveBeenCalledExactlyOnceWith(1); - }); - - it("does not retry a terminal publication failure", async () => { - const resolve = vi.fn().mockRejectedValue(new Error("publication failed")); - const sleep = vi.fn(async () => undefined); - - await expect( - waitForPrManagedImageCatalog( - { - baseSha: "b".repeat(40), - candidateRepository: "NVIDIA/NemoClaw", - candidateSha: CANDIDATE_SHA, - outputPath: "/tmp/catalog.json", - prNumber: PR_NUMBER, - token: "token", - workflowSource: "trusted workflow", - }, - { attempts: 2, delayMs: 1, resolve, sleep }, - ), - ).rejects.toThrow("publication failed"); - expect(resolve).toHaveBeenCalledOnce(); - expect(sleep).not.toHaveBeenCalled(); - }); - it("assembles one exact all-agent catalog", () => { const contracts = SHIPPED_MANAGED_IMAGE_AGENTS.map(contract); diff --git a/test/e2e/support/pr-self-hosted-llama-selector.test.ts b/test/e2e/support/pr-self-hosted-llama-selector.test.ts index 64d72f75624..23e4d1b557d 100644 --- a/test/e2e/support/pr-self-hosted-llama-selector.test.ts +++ b/test/e2e/support/pr-self-hosted-llama-selector.test.ts @@ -9,36 +9,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; -type WorkflowStep = { - env?: Record; - name?: string; - run?: string; - uses?: string; - with?: Record; -}; - type Workflow = { - jobs: Record< - string, - { - env?: Record; - needs?: string | string[]; - outputs?: Record; - permissions?: Record; - steps?: WorkflowStep[]; - [key: string]: unknown; - } - >; + jobs: Record }>; }; const WORKFLOW_PATH = ".github/workflows/pr-self-hosted.yaml"; const CANDIDATE_SHA = "a".repeat(40); -function selectGenericGpuLane( - changedFiles: readonly string[], - copiedSha = CANDIDATE_SHA, - candidateRepository = "NVIDIA/NemoClaw", -) { +function selectGenericGpuLane(changedFiles: readonly string[], copiedSha = CANDIDATE_SHA) { const workflow = YAML.parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow; const script = workflow.jobs["select-llama-cpp-generic-gpu"]?.steps?.find( (step) => step.name === "Select llama.cpp generic GPU E2E from PR files", @@ -79,21 +57,12 @@ fi GITHUB_SHA: copiedSha, PATH: `${binDirectory}:${process.env.PATH ?? ""}`, PR_FILES_JSON: JSON.stringify([changedFiles.map((filename) => ({ filename }))]), - PR_JSON: JSON.stringify({ - number: 8748, - base: { sha: "c".repeat(40) }, - head: { - repo: { full_name: candidateRepository }, - sha: CANDIDATE_SHA, - }, - }), + PR_JSON: JSON.stringify({ number: 8748, head: { sha: CANDIDATE_SHA } }), }, }, ); expect(result.status, result.stderr).toBe(0); - return readFileSync(outputPath, "utf8") - .split("\n") - .find((line) => line.startsWith("selected=")); + return readFileSync(outputPath, "utf8").trim(); } finally { rmSync(directory, { force: true, recursive: true }); } @@ -120,54 +89,4 @@ describe("generic NVIDIA GPU PR selection", () => { "Copied PR branch SHA does not match the current PR head", ); }); - - it("rejects a copied branch from a fork repository", () => { - expect(() => - selectGenericGpuLane(["scripts/install.sh"], CANDIDATE_SHA, "example/NemoClaw"), - ).toThrow("Copied PR branch must come from the workflow repository"); - }); - - it("binds the GPU lane to the exact PR managed-image catalog", () => { - const workflow = YAML.parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow; - const selector = workflow.jobs["select-llama-cpp-generic-gpu"]; - expect(selector?.outputs).toMatchObject({ - base_sha: "${{ steps.changed.outputs.base_sha }}", - candidate_repository: "${{ steps.changed.outputs.candidate_repository }}", - pr_number: "${{ steps.changed.outputs.pr_number }}", - }); - - const resolver = workflow.jobs["resolve-llama-cpp-managed-images"]; - expect(resolver?.needs).toBe("select-llama-cpp-generic-gpu"); - expect(resolver?.permissions).toEqual({ - actions: "read", - contents: "read", - "pull-requests": "read", - }); - const wait = resolver?.steps?.find( - (step) => step.name === "Wait for exact PR managed-image catalog", - ); - expect(wait?.env).toMatchObject({ - BASE_SHA: "${{ needs.select-llama-cpp-generic-gpu.outputs.base_sha }}", - CANDIDATE_REPOSITORY: - "${{ needs.select-llama-cpp-generic-gpu.outputs.candidate_repository }}", - CANDIDATE_SHA: "${{ github.sha }}", - PR_NUMBER: "${{ needs.select-llama-cpp-generic-gpu.outputs.pr_number }}", - }); - expect(wait?.run).toContain("pr-managed-image-publication.mts wait"); - - const gpu = workflow.jobs["llama-cpp-generic-gpu"]; - expect(gpu?.needs).toEqual([ - "select-llama-cpp-generic-gpu", - "resolve-llama-cpp-managed-images", - ]); - const download = gpu?.steps?.find( - (step) => step.name === "Download exact PR managed-image catalog", - ); - expect(download?.with).toEqual({ - name: "llama-cpp-pr-managed-catalog-${{ github.sha }}", - path: "${{ runner.temp }}/pr-managed-image-catalog", - }); - const bind = gpu?.steps?.find((step) => step.name === "Bind exact PR managed-image catalog"); - expect(bind?.run).toContain("NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG"); - }); }); diff --git a/test/e2e/support/rebuild-hermes-bootstrap.test.ts b/test/e2e/support/rebuild-hermes-bootstrap.test.ts index 65aadb20740..85cc2c92068 100644 --- a/test/e2e/support/rebuild-hermes-bootstrap.test.ts +++ b/test/e2e/support/rebuild-hermes-bootstrap.test.ts @@ -14,7 +14,6 @@ import { buildRebuildHermesGatewayBootstrapScript, cleanupRebuildHermesForward, cleanupRebuildHermesTrackedForwards, - createRebuildHermesDiscordProvider, GATEWAY_BOOTSTRAP_MARKER, parseRebuildHermesCurrentBaseResult, requirePublishedRebuildHermesCurrentBase, @@ -236,41 +235,6 @@ describe("rebuild-Hermes direct bootstrap", () => { expect(script).not.toContain("sandbox create"); }); - it("attaches the historical sandbox to the exact Hermes Discord binding", async () => { - const fixture = fakeHost([probe("profile imported"), probe("provider created")]); - - await createRebuildHermesDiscordProvider({ - activeOpenshellBin: "/opt/openshell", - apiKey: "inference-secret", - discordToken: "discord-secret", - envFactory, - host: fixture.host, - redactionValues: ["inference-secret", "discord-secret"], - sandboxName: "e2e-rebuild-hermes", - }); - - expect(fixture.command.mock.calls[0]?.[1]).toEqual([ - "provider", - "profile", - "import", - "--file", - expect.stringMatching(/discord\/provider-profile\/hermes\.yaml$/u), - ]); - expect(fixture.command.mock.calls[1]?.[1]).toEqual([ - "provider", - "create", - "--name", - "e2e-rebuild-hermes-discord-bridge", - "--type", - "discord-hermes-static-v1", - "--credential", - "DISCORD_BOT_TOKEN", - ]); - expect(fixture.command.mock.calls[1]?.[2]).toMatchObject({ - env: { DISCORD_BOT_TOKEN: "discord-secret" }, - }); - }); - it("stops before gateway probes when bootstrap omits completion evidence (#7144)", async () => { const markerlessHost = fakeHost([probe("gateway setup returned without completion evidence")]); const writeJson = vi.fn(async (_name: string, _value: unknown) => "unused-artifact.json"); diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index 647957e2559..67f5d3f37c7 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -8,12 +8,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import { - buildRegisteredRuntimePermissivePolicy, buildRuntimePermissivePolicy, type ExactManagedMcpPolicy, } from "../src/lib/shields/permissive-runtime.js"; -import type { SandboxEntry } from "../src/lib/state/registry/types.js"; -import { makeMessagingPlan } from "./helpers/messaging-plan-fixtures.js"; const BASE_PERMISSIVE = YAML.stringify({ filesystem_policy: { @@ -61,22 +58,6 @@ const HERMES_DISCORD_PERMISSIVE = YAML.stringify({ const tempFilesToClean: string[] = []; -function hermesRegistryEntry(disabledChannels: readonly "discord"[] = []): SandboxEntry { - return { - name: "hermes-box", - agent: "hermes", - messaging: { - schemaVersion: 1, - plan: makeMessagingPlan({ - agent: "hermes", - channels: ["discord"], - disabledChannels, - sandboxName: "hermes-box", - }), - }, - }; -} - function trackTempForCleanup(out: string, basePath: string): void { // Defensive: if the helper degrades to the static base path we must // never try to `rm -rf` its parent dir — that would target the @@ -105,7 +86,7 @@ afterEach(() => { describe("buildRuntimePermissivePolicy (#3942)", () => { it("keeps the Hermes Discord provider binding in Shields down", () => { let stagedPolicy = ""; - const out = buildRegisteredRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { + const out = buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { livePolicyYaml: YAML.stringify({ network_policies: { discord: { @@ -119,7 +100,6 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }, }), readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, - sandboxEntry: hermesRegistryEntry(), sandboxName: "hermes-box", writeTempPolicy: (yaml) => { stagedPolicy = yaml; @@ -152,23 +132,11 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(stagedPolicy).not.toContain("{sandboxName}"); }); - it("removes stale live Hermes Discord bindings when persisted state disables Discord", () => { + it("omits Hermes Discord egress when no live provider binding exists", () => { let stagedPolicy = ""; - const out = buildRegisteredRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { - livePolicyYaml: YAML.stringify({ - network_policies: { - discord: { - endpoints: [ - { - host: "discord.com", - credential_binding: { provider: "hermes-box-discord-bridge" }, - }, - ], - }, - }, - }), + const out = buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { + livePolicyYaml: "", readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, - sandboxEntry: hermesRegistryEntry(["discord"]), sandboxName: "hermes-box", writeTempPolicy: (yaml) => { stagedPolicy = yaml; @@ -177,8 +145,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); expect(out).toBe("/staged-hermes-permissive.yaml"); - expect(YAML.parse(stagedPolicy).network_policies?.discord).toBeUndefined(); - expect(stagedPolicy).not.toContain("hermes-box-discord-bridge"); + expect(YAML.parse(stagedPolicy).network_policies.discord).toBeUndefined(); expect(stagedPolicy).not.toContain("{sandboxName}"); }); @@ -199,7 +166,6 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }, }), readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, - activeMessagingChannels: ["discord"], sandboxName: "bad:provider", writeTempPolicy, }), diff --git a/test/policies-permissive-policy.test.ts b/test/policies-permissive-policy.test.ts index 44f51edb296..d631ce40040 100644 --- a/test/policies-permissive-policy.test.ts +++ b/test/policies-permissive-policy.test.ts @@ -12,9 +12,6 @@ import YAML from "yaml"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "policy", "index.ts")); const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "state", "registry.ts")); -const PLAN_FIXTURE_PATH = JSON.stringify( - path.join(REPO_ROOT, "test", "helpers", "messaging-plan-fixtures.ts"), -); const SOURCE_NODE_ARGS = ["--import", "tsx"]; function parseResultPayload(stdout: string): { error: string } { @@ -24,7 +21,7 @@ function parseResultPayload(stdout: string): { error: string } { return JSON.parse(stdout.slice(markerIndex + marker.length)); } -function runHermesPermissivePolicy(policySetStatus: number, discordActive = false): { +function runHermesPermissivePolicy(policySetStatus: number): { result: ReturnType; policy: string; stagedPath: string; @@ -38,18 +35,7 @@ function runHermesPermissivePolicy(policySetStatus: number, discordActive = fals const script = String.raw` const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); -const { makeMessagingPlan } = require(${PLAN_FIXTURE_PATH}); -registry.registerSandbox({ - name: "hermes-sandbox", - agent: "hermes", - policies: [], - ...(Boolean(${discordActive}) ? { - messaging: { - schemaVersion: 1, - plan: makeMessagingPlan({ sandboxName: "hermes-sandbox", agent: "hermes", channels: ["discord"] }), - }, - } : {}), -}); +registry.registerSandbox({ name: "hermes-sandbox", agent: "hermes", policies: [] }); policies.applyPermissivePolicy("hermes-sandbox"); `; fs.writeFileSync( @@ -104,7 +90,7 @@ describe("applyPermissivePolicy", () => { ["success", 0], ["OpenShell rejection", 17], ])( - "removes inactive Hermes Discord policy and staged material after %s", + "materializes the Hermes Discord provider and removes staged policy material after %s", (_case, policySetStatus) => { const observed = runHermesPermissivePolicy(policySetStatus); try { @@ -112,7 +98,25 @@ describe("applyPermissivePolicy", () => { expect(observed.stagedMode).toBe("600"); expect(fs.existsSync(observed.stagedPath)).toBe(false); const policy = YAML.parse(observed.policy); - expect(policy.network_policies.discord).toBeUndefined(); + const endpoints = policy.network_policies.discord.endpoints as Array<{ + host?: string; + credential_binding?: { provider?: string }; + }>; + const credentialEndpoints = endpoints.filter((endpoint) => + ["discord.com", "gateway.discord.gg", "*.discord.gg"].includes(endpoint.host ?? ""), + ); + expect(credentialEndpoints.map((endpoint) => endpoint.host).sort()).toEqual([ + "*.discord.gg", + "discord.com", + "gateway.discord.gg", + ]); + expect( + credentialEndpoints.map((endpoint) => endpoint.credential_binding?.provider), + ).toEqual([ + "hermes-sandbox-discord-bridge", + "hermes-sandbox-discord-bridge", + "hermes-sandbox-discord-bridge", + ]); expect(observed.policy).not.toContain("{sandboxName}"); } finally { observed.cleanup(); @@ -120,24 +124,6 @@ describe("applyPermissivePolicy", () => { }, ); - it("keeps materialized Hermes Discord bindings for an active channel", () => { - const observed = runHermesPermissivePolicy(0, true); - try { - const endpoints = YAML.parse(observed.policy).network_policies.discord.endpoints as Array<{ - credential_binding?: { provider?: string }; - }>; - expect(endpoints.filter((endpoint) => endpoint.credential_binding)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - credential_binding: { provider: "hermes-sandbox-discord-bridge" }, - }), - ]), - ); - } finally { - observed.cleanup(); - } - }); - it("rejects an invalid sandbox name before the permissive policy command", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-permissive-invalid-")); const fakeOpenshell = path.join(tmpDir, "openshell"); diff --git a/tools/e2e/pr-managed-image-publication.mts b/tools/e2e/pr-managed-image-publication.mts index 47797db5804..0c7e0b4e09f 100644 --- a/tools/e2e/pr-managed-image-publication.mts +++ b/tools/e2e/pr-managed-image-publication.mts @@ -38,23 +38,6 @@ export interface ManagedImagePublicationRun { readonly headSha: string; } -export class ManagedImagePublicationPendingError extends Error { - constructor(message: string) { - super(message); - this.name = "ManagedImagePublicationPendingError"; - } -} - -export interface ResolvePrManagedImageCatalogInput { - readonly baseSha: string; - readonly candidateRepository: string; - readonly candidateSha: string; - readonly outputPath: string; - readonly prNumber: number; - readonly token: string; - readonly workflowSource: string; -} - function record(value: unknown, label: string): JsonRecord { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be a JSON object`); @@ -155,15 +138,7 @@ export function selectManagedImagePublicationRun( positiveInteger(expected.prNumber, "PR number"); positiveInteger(expected.workflowId, "managed-image workflow id"); const response = record(payload, "managed-image workflow runs"); - if (!Array.isArray(response.workflow_runs)) { - throw new Error("exact managed-image workflow run is missing or ambiguous"); - } - if (response.total_count === 0 && response.workflow_runs.length === 0) { - throw new ManagedImagePublicationPendingError( - "exact managed-image workflow run is not available yet", - ); - } - if (response.total_count !== 1) { + if (response.total_count !== 1 || !Array.isArray(response.workflow_runs)) { throw new Error("exact managed-image workflow run is missing or ambiguous"); } if (response.workflow_runs.length !== 1) { @@ -196,18 +171,6 @@ export function selectManagedImagePublicationRun( ) { throw new Error("managed-image workflow run does not match the PR number"); } - if ( - (run.status === "queued" || - run.status === "in_progress" || - run.status === "waiting" || - run.status === "pending" || - run.status === "requested") && - (run.conclusion === null || run.conclusion === undefined) - ) { - throw new ManagedImagePublicationPendingError( - `managed-image workflow for candidate ${expected.headSha} is still running`, - ); - } if (run.status !== "completed" || run.conclusion !== "success") { throw new Error( `managed-image workflow for candidate ${expected.headSha} must complete successfully before live E2E`, @@ -349,7 +312,15 @@ function validatePr( /** Resolve and download the exact all-agent catalog before candidate code executes. */ export async function resolvePrManagedImageCatalog( - input: ResolvePrManagedImageCatalogInput, + input: { + readonly baseSha: string; + readonly candidateRepository: string; + readonly candidateSha: string; + readonly outputPath: string; + readonly prNumber: number; + readonly token: string; + readonly workflowSource: string; + }, request: (path: string) => Promise = (apiPath) => githubRequest(apiPath, input.token), ): Promise<"not-required" | "written"> { if (input.candidateRepository !== REPOSITORY) return "not-required"; @@ -414,42 +385,6 @@ export async function resolvePrManagedImageCatalog( } } -/** Wait only for the exact candidate publication to appear and finish successfully. */ -export async function waitForPrManagedImageCatalog( - input: ResolvePrManagedImageCatalogInput, - options: { - readonly attempts?: number; - readonly delayMs?: number; - readonly resolve?: typeof resolvePrManagedImageCatalog; - readonly sleep?: (delayMs: number) => Promise; - } = {}, -): Promise<"not-required" | "written"> { - const attempts = options.attempts ?? 121; - const delayMs = options.delayMs ?? 30_000; - if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 121) { - throw new Error("managed-image publication wait attempts are invalid"); - } - if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > 30_000) { - throw new Error("managed-image publication wait delay is invalid"); - } - const resolve = options.resolve ?? resolvePrManagedImageCatalog; - const sleep = options.sleep ?? ((delay) => new Promise((done) => setTimeout(done, delay))); - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - return await resolve(input); - } catch (error) { - if (!(error instanceof ManagedImagePublicationPendingError)) throw error; - if (attempt === attempts) { - throw new Error("exact managed-image workflow did not complete within the bounded wait", { - cause: error, - }); - } - await sleep(delayMs); - } - } - throw new Error("exact managed-image workflow wait exhausted unexpectedly"); -} - function requiredInteger(value: string | undefined, label: string): number { if (!value || !/^[1-9][0-9]*$/u.test(value)) throw new Error(`${label} is required`); return positiveInteger(Number(value), label); @@ -464,25 +399,18 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro console.log("pr-managed-image-catalog outcome=assembled"); return; } - const wait = argv[0] === "wait"; - const outputPath = wait ? argv[1] : argv[0]; - if ((wait && argv.length !== 2) || (!wait && argv.length !== 1) || !outputPath) { - throw new Error("expected one managed-image catalog output path"); - } + if (argv.length !== 1) throw new Error("expected one managed-image catalog output path"); const candidateSha = env.CANDIDATE_SHA ?? ""; if (!candidateSha) return; - const input = { + const result = await resolvePrManagedImageCatalog({ baseSha: env.BASE_SHA ?? "", candidateRepository: env.CANDIDATE_REPOSITORY ?? "", candidateSha, - outputPath, + outputPath: argv[0], prNumber: requiredInteger(env.PR_NUMBER, "PR_NUMBER"), token: env.GITHUB_TOKEN ?? "", workflowSource: fs.readFileSync(WORKFLOW_PATH, "utf8"), - }; - const result = wait - ? await waitForPrManagedImageCatalog(input) - : await resolvePrManagedImageCatalog(input); + }); console.log(`pr-managed-image-catalog outcome=${result}`); } From c9762e54553e5fc7960a666ede616dfc19d9150d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sun, 23 Aug 2026 18:49:12 -0700 Subject: [PATCH 31/31] docs(e2e): drop removed publication retry Signed-off-by: Prekshi Vyas --- test/e2e/RETRY_INVENTORY.md | 1 - 1 file changed, 1 deletion(-) diff --git a/test/e2e/RETRY_INVENTORY.md b/test/e2e/RETRY_INVENTORY.md index 923db9ff71c..e4dbbef8410 100644 --- a/test/e2e/RETRY_INVENTORY.md +++ b/test/e2e/RETRY_INVENTORY.md @@ -24,7 +24,6 @@ Exhaustion remains failed. | `trusted-controller-collaborator-permission-read` | Collaborator-permission reads for manual PR dispatch and Launchable E2E dispatch; `.github/workflows/e2e.yaml` | Curl exit 5, 6, 7, 16, 18, 28, 35, 52, 55, 56, 92, 95, or 96; HTTP 408, 429, or 5xx | 3 attempts; linear 1s then 2s | Read-only GitHub API request | GitHub API | Transient API read versus terminal authentication, authorization, actor, or response failure | Operation name, attempt number, and sanitized failure class or HTTP status; no response body, header, or token | Eligible bounded read; HTTP 401, 403, 404, and 422, malformed responses, actor failures, and insufficient roles remain terminal; no cached permission or workflow rerun | | `pr-exact-openclaw-mcp-repetition` | Exact managed-image OpenClaw MCP discovery and lifecycle acceptance; `.github/workflows/managed-images.yaml`, `test/e2e/live/mcp-bridge.test.ts` | Either independent matrix execution fails | 2 required executions on fresh runners; 0 workflow or test retries | Each execution creates and cleans up its own sandbox against the same exact candidate publication cohort | NemoClaw | Each execution passes or fails independently; both must pass | Existing redacted MCP diagnostics, request ledger, cleanup evidence, and fixture-credential scan for each matrix pass | Fixed acceptance repetition required by #8746; not a retry, and one pass never masks the other; trusted-private DNS-rebinding remains in full E2E where the supervisor resolver is authoritative | | `github-exact-artifact-content-read` | Bound base-image or PR managed-image contract artifact; `tools/e2e/exact-artifact-download.mts`, `tools/e2e/pr-managed-image-publication.mts` | Transport failure, HTTP 408, HTTP 429, or HTTP 5xx while reading one pre-bound artifact ID | 3 attempts; Retry-After or linear delay capped at 10s | Read-only request against one immutable artifact ID, name, size, digest, producer run, attempt, and producer commit | GitHub artifact service | `passed-first-attempt`, `passed-after-retry`, `exhausted` for transient exhaustion, or `failed-no-retry` for terminal HTTP; identity, size, digest, archive, and contract failures throw without an aggregate outcome or `failureClass` | Content-read attempts log only the sanitized operation, attempt, HTTP status or transport class, and outcome; thrown validation failures expose only their bounded error message, never headers, body, token, signed URL, or artifact content | Standalone bounded content read; it does not use `retry-policy.ts` or `RetryEvidence`, and all identity, integrity, archive, and contract failures remain terminal | -| `pr-managed-image-publication-readiness` | Exact PR managed-image workflow status; `tools/e2e/pr-managed-image-publication.mts`, `.github/workflows/pr-self-hosted.yaml` | The exact candidate run is absent, queued, in progress, or waiting; every completed failure and identity mismatch is terminal | 121 observations; fixed 30s delay (at most 60m) | Read-only GitHub workflow-status observation before the first artifact read | GitHub Actions | Exact successful publication, terminal failure, or bounded exhaustion | The resolver reports only the bounded outcome or sanitized terminal error; no response body, header, token, or artifact content | Bounded readiness polling on a GitHub-hosted runner avoids reserving the GPU while the exact publication finishes; it never reruns a workflow or accepts another commit | | `inference-set-route-convergence` | Sandbox inference probe after one OpenShell route selection; `src/lib/actions/inference-set-provider.ts`, `src/lib/actions/inference-set.ts` | HTTP 400 or 404 only when the selected API family changes; authentication, authorization, unsafe or malformed input, every other HTTP status, transport failure, and probe failure are terminal | Initial 6s route-cache wait after a provider/model change; then up to 3 probes with 2s and 4s retry delays | Each retry repeats only the read-only sandbox inference probe after one route mutation | OpenShell route cache | Converged, terminal failure, or exhausted rollback | Retry progress records only HTTP status, attempt number, and delay; the final command error stays redacted, and focused tests assert the exact attempt count and rollback | The initial wait covers one full 5s OpenShell 0.0.106 cache-refresh interval even when the stale route returns a valid 2xx; exhaustion restores the prior route, removes the uncommitted provider, and remains failed | | `inference-switch-ts` | Verified inference route update; `test/e2e/fixtures/inference-switch-retry.ts` | Timeout, reset, DNS/connectivity/connect error, request transport error, or exact 502/503/504 status; authentication, authorization, policy, malformed-input, and invalid-request signals take precedence | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Shared `RetryEvidence` classifications | Every attempt classification and aggregate outcome; command artifacts remain separate and redacted | Uses `runBoundedRetry`; deterministic verification mismatches stop; no `--no-verify` exhaustion bypass | | `inference-switch-shell` | Verified shell inference route update; `test/e2e/lib/inference-switch-retry.sh` | Same bounded transient and terminal-precedence signatures as the TypeScript helper | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Exit status remains failed on exhaustion | Existing command output and retry progress | Bounded compatibility helper; no `--no-verify` exhaustion bypass |