From a46738441f26d8cdd0eaeb86e6f6fc7c64065848 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 24 Aug 2026 20:04:24 +0700 Subject: [PATCH 01/19] fix(onboard): restore lifecycle E2E qualification Signed-off-by: San Dang --- .../actions/sandbox/auto-pair-warmup.test.ts | 4 +- src/lib/actions/sandbox/auto-pair-warmup.ts | 15 ++-- .../sandbox-create-plan-materialization.ts | 10 ++- test/e2e/live/hermes-gpu-startup-proof.ts | 78 ++++++++++--------- ...shell-credential-generation-window.test.ts | 21 ++++- 5 files changed, 75 insertions(+), 53 deletions(-) diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index a8ec7284bf4..ce28a3f9f4f 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -223,7 +223,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( 20_000, ); - itWithSh("uses device auth after consuming the trusted proxy environment (#10014)", () => { + itWithSh("keeps trusted gateway auth for initial device pairing (#10014)", () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-producer-")); const binDir = path.join(fixtureRoot, "bin"); const proxyEnv = path.join(fixtureRoot, "proxy-env.sh"); @@ -284,7 +284,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( expect(result.status, result.stderr).toBe(0); expect(fs.readFileSync(sourceLog, "utf8")).toBe("consumed\n"); expect(fs.readFileSync(callLog, "utf8")).toMatch( - /^url=unset\nport=unset\ntoken=unset\npassword=unset\nforce=1\nrestored=unset\nsettlement=unset\nargv=gateway call sessions\.create --params \{"key":"agent:main:nemoclaw-onboard-warmup-\d+-\d+","agentId":"main"\} --json\n$/, + /^url=unset\nport=18789\ntoken=shared-token\npassword=shared-password\nforce=1\nrestored=unset\nsettlement=unset\nargv=gateway call sessions\.create --params \{"key":"agent:main:nemoclaw-onboard-warmup-\d+-\d+","agentId":"main"\} --json\n$/, ); } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 52238be69f4..bfaa6f593bb 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -44,19 +44,16 @@ import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; export const WARMUP_TIMEOUT_MS = 30_000; export const WARMUP_PROBE_TIMEOUT_S = 5; -// Best-effort in-sandbox request producer. Always exits 0. Use the stored CLI -// device credential for the direct `sessions.create` call. Shared gateway -// overrides would authorize the owner instead of publishing the device's scope -// request. Finalization's canonical observer owns pairing-state polling. -// OpenClaw 2026.7.1 can omit CLI identity on loopback shared auth, so force -// device pairing only on this command. +// Best-effort in-sandbox request producer. Always exits 0. Keep the trusted +// gateway credential for the initial request, before a CLI device credential +// exists. OpenClaw 2026.7.1 can omit CLI identity on loopback shared auth, so +// force device pairing only on this command. Finalization's canonical observer +// owns pairing-state polling. export const WARMUP_SCRIPT = ` ${buildTrustedProxyEnvSourceShell()} command -v openclaw >/dev/null 2>&1 || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT \\ - OPENCLAW_GATEWAY_TOKEN OPENCLAW_GATEWAY_PASSWORD \\ - NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING \\ +unset OPENCLAW_GATEWAY_URL NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING \\ NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT || exit 0 session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" params="$(printf '{"key":"%s","agentId":"main"}' "$session_key")" diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index d59133d8416..60aefc052a4 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -226,10 +226,14 @@ export function materializeSandboxCreatePlan({ prepareInitialSandboxCreatePolicy = getInitialSandboxCreatePolicy, }: MaterializeSandboxCreatePlanInput): SandboxCreatePlan { const enabledMessagingTokenDefs = validateSandboxCreateIntentBindings(intent, messagingTokenDefs); + const disabledChannelNames = new Set(intent.disabledChannelNames); + const activeMessagingChannels = intent.policy.activeMessagingChannels.filter( + (channel) => !disabledChannelNames.has(channel), + ); const driverConfig = buildSandboxDriverConfig(intent, managedStateMount); const { initialSandboxPolicy, compatibilityPolicyPath } = prepareSandboxGpuRoutePolicies( intent.policy.basePolicyPath, - [...intent.policy.activeMessagingChannels], + activeMessagingChannels, { directGpu: intent.policy.options.directGpu, hostGpuAvailable: intent.policy.options.hostGpuAvailable, @@ -277,7 +281,7 @@ export function materializeSandboxCreatePlan({ ]), ], providerChannels, - new Set(intent.disabledChannelNames), + disabledChannelNames, ); const createProviders = new Set(); if (intent.inferenceProvider) createProviders.add(intent.inferenceProvider); @@ -291,7 +295,7 @@ export function materializeSandboxCreatePlan({ } return { - activeMessagingChannels: [...intent.activeMessagingChannels], + activeMessagingChannels, initialSandboxPolicy, policyTier: intent.policy.options.policyTier, createArgs, diff --git a/test/e2e/live/hermes-gpu-startup-proof.ts b/test/e2e/live/hermes-gpu-startup-proof.ts index 703d8f6fe70..80d2baaa6dc 100644 --- a/test/e2e/live/hermes-gpu-startup-proof.ts +++ b/test/e2e/live/hermes-gpu-startup-proof.ts @@ -193,11 +193,6 @@ export async function assertHermesGpuStartupProof({ ); } const managedAuthority = readManagedWorkloadAuthority(registryEntry); - const managedImageReference = assertHermesManagedWorkloadAuthority( - sandboxName, - registryEntry.imageTag, - managedAuthority, - ); const expectedExtraPlaceholderAssignment = `NEMOCLAW_EXTRA_PLACEHOLDER_KEYS=${HERMES_GPU_EXTRA_PLACEHOLDER_KEYS.join(",")}`; const extraPlaceholderEnv = await host.command( @@ -302,38 +297,49 @@ raise SystemExit(1)`, resultText(dockerCommandBoundary), ).toBe(0); const commandBoundary = JSON.parse(dockerCommandBoundary.stdout); - const verifiedManagedAuthority = managedAuthority!; - expect(verifiedManagedAuthority.agent).toBe("hermes"); - const managedBootstrapCommand = commandBoundary.cmd; - expect(Array.isArray(managedBootstrapCommand)).toBe(true); - const bootstrapIdentity = managedBootstrapCommand[5]; - expect(typeof bootstrapIdentity).toBe("string"); - assertManagedBootstrapIdentity(bootstrapIdentity); - const agentIdentity = managedImageRuntimeIdentity(verifiedManagedAuthority.agent); - expect(commandBoundary.entrypoint).toEqual([MANAGED_BOOTSTRAP_TRAMPOLINE_EXECUTABLE]); - expect(managedBootstrapCommand).toEqual([ - "--agent", - verifiedManagedAuthority.agent, - "--profile-fingerprint", - fingerprintManagedStartupProfile(verifiedManagedAuthority.profile), - "--bootstrap-identity", - bootstrapIdentity, - "--agent-uid", - String(agentIdentity.uid), - "--agent-gid", - String(agentIdentity.gid), - "--agent-workdir", - agentIdentity.workdir, - "--request-file", - MANAGED_BOOTSTRAP_REQUEST_FILE, - "--", - ...OPENSHELL_SANDBOX_SUPERVISOR_ARGV, - ]); + if (managedAuthority) { + const managedImageReference = assertHermesManagedWorkloadAuthority( + sandboxName, + registryEntry.imageTag, + managedAuthority, + ); + expect(managedAuthority.agent).toBe("hermes"); + const managedBootstrapCommand = commandBoundary.cmd; + expect(Array.isArray(managedBootstrapCommand)).toBe(true); + const bootstrapIdentity = managedBootstrapCommand[5]; + expect(typeof bootstrapIdentity).toBe("string"); + assertManagedBootstrapIdentity(bootstrapIdentity); + const agentIdentity = managedImageRuntimeIdentity(managedAuthority.agent); + expect(commandBoundary.entrypoint).toEqual([MANAGED_BOOTSTRAP_TRAMPOLINE_EXECUTABLE]); + expect(managedBootstrapCommand).toEqual([ + "--agent", + managedAuthority.agent, + "--profile-fingerprint", + fingerprintManagedStartupProfile(managedAuthority.profile), + "--bootstrap-identity", + bootstrapIdentity, + "--agent-uid", + String(agentIdentity.uid), + "--agent-gid", + String(agentIdentity.gid), + "--agent-workdir", + agentIdentity.workdir, + "--request-file", + MANAGED_BOOTSTRAP_REQUEST_FILE, + "--", + ...OPENSHELL_SANDBOX_SUPERVISOR_ARGV, + ]); + assertHermesContainerImageAuthority(commandBoundary.image, managedImageReference); + } else { + expect(installText).toContain( + "Managed image unavailable; using the trusted Dockerfile recipe.", + ); + expect(commandBoundary).toMatchObject({ + cmd: ["--workdir", "/sandbox"], + entrypoint: ["/opt/openshell/bin/openshell-sandbox"], + }); + } expect(commandBoundary.has_openshell_sandbox_command).toBe(true); - assertHermesContainerImageAuthority( - commandBoundary.image, - managedImageReference, - ); expect(commandBoundary.command_ends_with_nemoclaw_start).toBe(true); expect(commandBoundary.command_is_sleep_infinity).toBe(false); diff --git a/test/e2e/live/openshell-credential-generation-window.test.ts b/test/e2e/live/openshell-credential-generation-window.test.ts index bbc9b811aae..aca5da9c164 100644 --- a/test/e2e/live/openshell-credential-generation-window.test.ts +++ b/test/e2e/live/openshell-credential-generation-window.test.ts @@ -329,7 +329,7 @@ test("openshell-credential-generation-window", { "attach the MCP provider and observe its initial generation", "prove a retained credential generation expires", "rotate beyond the retained generation window", - "prove key removal and provider detach revoke access", + "prove key removal and provider teardown revoke access", "restart the bridge and confirm old-process fallback", "rebuild the sandbox and confirm credential reuse", "remove the MCP bridge and audit denied requests", @@ -433,7 +433,12 @@ test("openshell-credential-generation-window", { timeoutMs: 60_000, }); expectExitZero(status, "inspect credential-window MCP bridge"); - const providerName = (JSON.parse(status.stdout) as { provider: { name: string } }).provider.name; + const bridgeStatus = JSON.parse(status.stdout) as { + policy: { name: string }; + provider: { name: string }; + }; + const providerName = bridgeStatus.provider.name; + const policyName = bridgeStatus.policy.name; expect(providerName).toMatch(/^e2e-cred-window-mcp-fake-[a-f0-9]{16}$/u); const originalRevision = await observeFreshRevision( @@ -677,7 +682,7 @@ test("openshell-credential-generation-window", { placeholderAbsent: true, }); - progress.phase("prove key removal and provider detach revoke access"); + progress.phase("prove key removal and provider teardown revoke access"); await updateProviderCredential( sandbox, providerName, @@ -736,6 +741,16 @@ test("openshell-credential-generation-window", { placeholderAbsent: true, }); + const removeBinding = await host.nemoclaw( + [SANDBOX_NAME, "policy", "remove", policyName, "--yes"], + { + artifactName: "credential-window-remove-binding-before-provider-detach", + env: buildAvailabilityProbeEnv(), + timeoutMs: 90_000, + }, + ); + expectExitZero(removeBinding, "remove credential-window binding"); + const detach = await sandbox.openshell( ["sandbox", "provider", "detach", SANDBOX_NAME, providerName], { From 7d65f9c4b75149a2bb8dd2a5e9d5cdbddb0c0f03 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 24 Aug 2026 06:25:30 -0700 Subject: [PATCH 02/19] fix(e2e): require exact lifecycle cleanup evidence Signed-off-by: Carlos Villela --- .../actions/sandbox/auto-pair-warmup.test.ts | 8 -- test/e2e/live/hermes-gpu-startup-proof.ts | 74 ++++++++----------- ...shell-credential-generation-window.test.ts | 17 +++++ 3 files changed, 49 insertions(+), 50 deletions(-) diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index ce28a3f9f4f..c9dfdff092b 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -44,14 +44,6 @@ describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => { expect(WARMUP_TIMEOUT_MS).toBeGreaterThan(0); expect(WARMUP_PROBE_TIMEOUT_S).toBe(5); }); - - it("stays within the bounds the contract budgeted for finalization latency", () => { - // The architect budgeted worst-case added finalization latency at the - // warm-up cap (<=30s) plus the existing 15s approval pass. Guard that the - // warm-up cap has not crept past its 30s ceiling — anything larger would - // blow the budget the contract signed off on for a one-time onboard. - expect(WARMUP_TIMEOUT_MS).toBeLessThanOrEqual(30_000); - }); }); describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", () => { diff --git a/test/e2e/live/hermes-gpu-startup-proof.ts b/test/e2e/live/hermes-gpu-startup-proof.ts index 80d2baaa6dc..fd7fab405fc 100644 --- a/test/e2e/live/hermes-gpu-startup-proof.ts +++ b/test/e2e/live/hermes-gpu-startup-proof.ts @@ -193,6 +193,11 @@ export async function assertHermesGpuStartupProof({ ); } const managedAuthority = readManagedWorkloadAuthority(registryEntry); + const managedImageReference = assertHermesManagedWorkloadAuthority( + sandboxName, + registryEntry.imageTag, + managedAuthority, + ); const expectedExtraPlaceholderAssignment = `NEMOCLAW_EXTRA_PLACEHOLDER_KEYS=${HERMES_GPU_EXTRA_PLACEHOLDER_KEYS.join(",")}`; const extraPlaceholderEnv = await host.command( @@ -297,48 +302,33 @@ raise SystemExit(1)`, resultText(dockerCommandBoundary), ).toBe(0); const commandBoundary = JSON.parse(dockerCommandBoundary.stdout); - if (managedAuthority) { - const managedImageReference = assertHermesManagedWorkloadAuthority( - sandboxName, - registryEntry.imageTag, - managedAuthority, - ); - expect(managedAuthority.agent).toBe("hermes"); - const managedBootstrapCommand = commandBoundary.cmd; - expect(Array.isArray(managedBootstrapCommand)).toBe(true); - const bootstrapIdentity = managedBootstrapCommand[5]; - expect(typeof bootstrapIdentity).toBe("string"); - assertManagedBootstrapIdentity(bootstrapIdentity); - const agentIdentity = managedImageRuntimeIdentity(managedAuthority.agent); - expect(commandBoundary.entrypoint).toEqual([MANAGED_BOOTSTRAP_TRAMPOLINE_EXECUTABLE]); - expect(managedBootstrapCommand).toEqual([ - "--agent", - managedAuthority.agent, - "--profile-fingerprint", - fingerprintManagedStartupProfile(managedAuthority.profile), - "--bootstrap-identity", - bootstrapIdentity, - "--agent-uid", - String(agentIdentity.uid), - "--agent-gid", - String(agentIdentity.gid), - "--agent-workdir", - agentIdentity.workdir, - "--request-file", - MANAGED_BOOTSTRAP_REQUEST_FILE, - "--", - ...OPENSHELL_SANDBOX_SUPERVISOR_ARGV, - ]); - assertHermesContainerImageAuthority(commandBoundary.image, managedImageReference); - } else { - expect(installText).toContain( - "Managed image unavailable; using the trusted Dockerfile recipe.", - ); - expect(commandBoundary).toMatchObject({ - cmd: ["--workdir", "/sandbox"], - entrypoint: ["/opt/openshell/bin/openshell-sandbox"], - }); - } + expect(managedAuthority!.agent).toBe("hermes"); + const managedBootstrapCommand = commandBoundary.cmd; + expect(Array.isArray(managedBootstrapCommand)).toBe(true); + const bootstrapIdentity = managedBootstrapCommand[5]; + expect(typeof bootstrapIdentity).toBe("string"); + assertManagedBootstrapIdentity(bootstrapIdentity); + const agentIdentity = managedImageRuntimeIdentity(managedAuthority!.agent); + expect(commandBoundary.entrypoint).toEqual([MANAGED_BOOTSTRAP_TRAMPOLINE_EXECUTABLE]); + expect(managedBootstrapCommand).toEqual([ + "--agent", + managedAuthority!.agent, + "--profile-fingerprint", + fingerprintManagedStartupProfile(managedAuthority!.profile), + "--bootstrap-identity", + bootstrapIdentity, + "--agent-uid", + String(agentIdentity.uid), + "--agent-gid", + String(agentIdentity.gid), + "--agent-workdir", + agentIdentity.workdir, + "--request-file", + MANAGED_BOOTSTRAP_REQUEST_FILE, + "--", + ...OPENSHELL_SANDBOX_SUPERVISOR_ARGV, + ]); + assertHermesContainerImageAuthority(commandBoundary.image, managedImageReference); expect(commandBoundary.has_openshell_sandbox_command).toBe(true); expect(commandBoundary.command_ends_with_nemoclaw_start).toBe(true); expect(commandBoundary.command_is_sleep_infinity).toBe(false); diff --git a/test/e2e/live/openshell-credential-generation-window.test.ts b/test/e2e/live/openshell-credential-generation-window.test.ts index aca5da9c164..d814699ce27 100644 --- a/test/e2e/live/openshell-credential-generation-window.test.ts +++ b/test/e2e/live/openshell-credential-generation-window.test.ts @@ -5,6 +5,7 @@ import { buildMcpCredentialDetachedCommand, buildMcpCredentialRevisionObservationCommand, } from "../../../src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts"; +import { getNetworkPolicyNames } from "../../../src/lib/onboard/initial-policy.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { assertCleanupSucceededOrAbsent } from "../fixtures/cleanup-resources.ts"; import { assertExitZero as expectExitZero, resultText } from "../fixtures/clients/command.ts"; @@ -297,6 +298,21 @@ async function updateProviderCredential( expect(resultText(result)).toMatch(/Updated provider/iu); } +async function expectPolicyAbsent( + sandbox: SandboxClient, + policyName: string, +): Promise { + const result = await sandbox.openshell(["policy", "get", "--full", SANDBOX_NAME], { + artifactName: "credential-window-confirm-binding-removal", + env: openshellEnv(), + timeoutMs: 90_000, + }); + expectExitZero(result, "inspect credential-window policy after binding removal"); + const policyNames = getNetworkPolicyNames(result.stdout); + expect(policyNames, "expected an unambiguous live policy document after binding removal").not.toBeNull(); + expect(policyNames).not.toContain(policyName); +} + async function runFreshRequest( sandbox: SandboxClient, mcpUrl: string, @@ -750,6 +766,7 @@ test("openshell-credential-generation-window", { }, ); expectExitZero(removeBinding, "remove credential-window binding"); + await expectPolicyAbsent(sandbox, policyName); const detach = await sandbox.openshell( ["sandbox", "provider", "detach", SANDBOX_NAME, providerName], From df684827fdb62ab8f07caf743cdc9109cf0e0248 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 24 Aug 2026 06:34:21 -0700 Subject: [PATCH 03/19] fix(e2e): reject incomplete policy cleanup evidence Signed-off-by: Carlos Villela --- .../openshell-credential-generation-window.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/openshell-credential-generation-window.test.ts b/test/e2e/live/openshell-credential-generation-window.test.ts index d814699ce27..775844cc49a 100644 --- a/test/e2e/live/openshell-credential-generation-window.test.ts +++ b/test/e2e/live/openshell-credential-generation-window.test.ts @@ -12,6 +12,7 @@ import { assertExitZero as expectExitZero, resultText } from "../fixtures/client import type { HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; +import YAML from "yaml"; import { MCP_BRIDGE_TEST_CREDENTIALS } from "../fixtures/mcp-bridge-credentials.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { hostAddressForSandbox } from "./mcp-bridge-sandbox.ts"; @@ -308,8 +309,15 @@ async function expectPolicyAbsent( timeoutMs: 90_000, }); expectExitZero(result, "inspect credential-window policy after binding removal"); + const parsedPolicy = YAML.parse(result.stdout); + expect( + parsedPolicy?.network_policies && + typeof parsedPolicy.network_policies === "object" && + !Array.isArray(parsedPolicy.network_policies), + "expected a complete live policy document after binding removal", + ).toBe(true); const policyNames = getNetworkPolicyNames(result.stdout); - expect(policyNames, "expected an unambiguous live policy document after binding removal").not.toBeNull(); + expect(policyNames).not.toBeNull(); expect(policyNames).not.toContain(policyName); } From a025a900671211ebaf30a0349b0fea626aba0516 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 24 Aug 2026 22:08:00 +0700 Subject: [PATCH 04/19] fix(e2e): repair deterministic state failures Signed-off-by: San Dang --- .../channels/slack/policy/hermes.yaml | 13 + .../channels/slack/policy/openclaw.yaml | 13 + src/lib/onboard/machine/handlers/sandbox.ts | 11 +- .../sandbox-create-intent-resolution.ts | 16 +- .../sandbox-create-plan-materialization.ts | 52 +--- test/e2e/live/hermes-gpu-startup.test.ts | 229 +++++++++++------- test/e2e/live/messaging-providers-helpers.ts | 58 +++++ test/e2e/live/messaging-providers.test.ts | 23 ++ ...shell-credential-generation-window.test.ts | 9 +- 9 files changed, 282 insertions(+), 142 deletions(-) diff --git a/src/lib/messaging/channels/slack/policy/hermes.yaml b/src/lib/messaging/channels/slack/policy/hermes.yaml index a026778e700..e1aac14d631 100644 --- a/src/lib/messaging/channels/slack/policy/hermes.yaml +++ b/src/lib/messaging/channels/slack/policy/hermes.yaml @@ -11,12 +11,25 @@ network_policies: endpoints: - host: slack.com port: 443 + path: "/**" protocol: rest enforcement: enforce request_body_credential_rewrite: true + credential_binding: + provider: "{sandboxName}-slack-bridge" rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + - host: slack.com + port: 443 + path: "/api/apps.connections.open" + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + credential_binding: + provider: "{sandboxName}-slack-app" + rules: + - allow: { method: POST, path: "/api/apps.connections.open" } - host: api.slack.com port: 443 protocol: rest diff --git a/src/lib/messaging/channels/slack/policy/openclaw.yaml b/src/lib/messaging/channels/slack/policy/openclaw.yaml index da400a01309..9d8aaa3c436 100644 --- a/src/lib/messaging/channels/slack/policy/openclaw.yaml +++ b/src/lib/messaging/channels/slack/policy/openclaw.yaml @@ -11,12 +11,25 @@ network_policies: endpoints: - host: slack.com port: 443 + path: "/**" protocol: rest enforcement: enforce request_body_credential_rewrite: true + credential_binding: + provider: "{sandboxName}-slack-bridge" rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + - host: slack.com + port: 443 + path: "/api/apps.connections.open" + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + credential_binding: + provider: "{sandboxName}-slack-app" + rules: + - allow: { method: POST, path: "/api/apps.connections.open" } - host: api.slack.com port: 443 protocol: rest diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 7039dd650b4..4b47247ee98 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -499,19 +499,27 @@ function rebuildPolicyPresetsForCreateIntent( return Array.isArray(selectedValue) ? { rebuildPolicyPresets: [...selectedValue] } : {}; } +function disabledChannelNamesForCreateIntent(session: Session | null) { + const disabledChannelNames = session?.messagingPlan?.disabledChannels; + return disabledChannelNames ? { disabledChannelNames } : {}; +} + /** Replace a resumed create-plan snapshot with the outer rebuild's normalized built-ins. */ function applyAuthoritativeRebuildPolicyPresets( intent: ResolvedSandboxCreateIntent, rebuildPolicyPresets: readonly string[] | undefined, ): ResolvedSandboxCreateIntent { if (!Array.isArray(rebuildPolicyPresets)) return intent; + const disabledChannelNames = new Set(intent.disabledChannelNames); return { ...intent, policy: { ...intent.policy, options: { ...intent.policy.options, - additionalPresets: [...rebuildPolicyPresets], + additionalPresets: rebuildPolicyPresets.filter( + (preset) => !disabledChannelNames.has(preset), + ), }, }, }; @@ -1606,6 +1614,7 @@ class SandboxStateFlow< inferenceProvider: this.options.provider, hostLocalInferenceRouteOnly: this.options.hostLocalInferenceRouteOnly === true, enabledChannels: state.selectedMessagingChannels, + ...disabledChannelNamesForCreateIntent(state.session), webSearchConfig: state.webSearchConfig, agent: this.options.agent, sandboxGpuConfig: this.options.sandboxGpuConfig, diff --git a/src/lib/onboard/sandbox-create-intent-resolution.ts b/src/lib/onboard/sandbox-create-intent-resolution.ts index 2d34fe67553..cd4188da038 100644 --- a/src/lib/onboard/sandbox-create-intent-resolution.ts +++ b/src/lib/onboard/sandbox-create-intent-resolution.ts @@ -28,6 +28,7 @@ export type CompleteSandboxCreateIntentInput = { inferenceProvider?: string | null; hostLocalInferenceRouteOnly?: boolean; enabledChannels: readonly string[] | null; + disabledChannelNames?: readonly string[]; webSearchConfig: WebSearchConfig | null; agent: Agent; sandboxGpuConfig: SandboxGpuCreateConfig; @@ -70,11 +71,17 @@ export function createSandboxCreateIntentResolver< async function prepareMessagingCapabilities( input: Pick< CompleteSandboxCreateIntentInput, - "sandboxName" | "enabledChannels" | "webSearchConfig" | "agent" | "reuseRegisteredCredentials" + | "sandboxName" + | "enabledChannels" + | "disabledChannelNames" + | "webSearchConfig" + | "agent" + | "reuseRegisteredCredentials" >, expectedIntent?: SandboxCreateIntent, credentialRegistration = false, ) { + const disabledChannelNames = input.disabledChannelNames; const preflightDeps = expectedIntent ? { ...deps.messagingPreflightDeps, @@ -87,7 +94,12 @@ export function createSandboxCreateIntentResolver< readMessagingPlanFromEnv: () => null, registerExtraPlaceholderProviders: () => [], } - : deps.messagingPreflightDeps; + : disabledChannelNames + ? { + ...deps.messagingPreflightDeps, + resolveDisabledChannels: () => [...disabledChannelNames], + } + : deps.messagingPreflightDeps; const result = await prepareSandboxMessagingPreflight( { channels: deps.channels, diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index 60aefc052a4..31660a64a64 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -192,27 +192,6 @@ export function validateSandboxCreateIntentBindings( }); } -function resolveProviderChannelMap( - requests: readonly SandboxCreateMessagingProviderRequest[], -): Map { - const providerChannels = new Map(); - for (const { channel, name } of requests) { - if (channel) providerChannels.set(name, channel); - } - return providerChannels; -} - -function filterDisabledMessagingProviders( - providerNames: string[], - providerChannels: ReadonlyMap, - disabledChannelNames: ReadonlySet, -): string[] { - return providerNames.filter((providerName) => { - const channel = providerChannels.get(providerName); - return !channel || !disabledChannelNames.has(channel); - }); -} - /** Materialize policy, route metadata, resources, and providers from a secretless intent. */ export function materializeSandboxCreatePlan({ intent, @@ -226,14 +205,10 @@ export function materializeSandboxCreatePlan({ prepareInitialSandboxCreatePolicy = getInitialSandboxCreatePolicy, }: MaterializeSandboxCreatePlanInput): SandboxCreatePlan { const enabledMessagingTokenDefs = validateSandboxCreateIntentBindings(intent, messagingTokenDefs); - const disabledChannelNames = new Set(intent.disabledChannelNames); - const activeMessagingChannels = intent.policy.activeMessagingChannels.filter( - (channel) => !disabledChannelNames.has(channel), - ); const driverConfig = buildSandboxDriverConfig(intent, managedStateMount); const { initialSandboxPolicy, compatibilityPolicyPath } = prepareSandboxGpuRoutePolicies( intent.policy.basePolicyPath, - activeMessagingChannels, + [...intent.policy.activeMessagingChannels], { directGpu: intent.policy.options.directGpu, hostGpuAvailable: intent.policy.options.hostGpuAvailable, @@ -269,20 +244,15 @@ export function materializeSandboxCreatePlan({ ]; runProviderPreDeleteCleanup(); - const providerChannels = resolveProviderChannelMap(intent.messagingProviderRequests); - const messagingProviders = filterDisabledMessagingProviders( - [ - ...new Set([ - ...upsertMessagingProviders(enabledMessagingTokenDefs, { - replaceExisting: true, - allowedSandboxes: [intent.sandboxName], - }), - ...intent.reusableMessagingProviders, - ]), - ], - providerChannels, - disabledChannelNames, - ); + const messagingProviders = [ + ...new Set([ + ...upsertMessagingProviders(enabledMessagingTokenDefs, { + replaceExisting: true, + allowedSandboxes: [intent.sandboxName], + }), + ...intent.reusableMessagingProviders, + ]), + ]; const createProviders = new Set(); if (intent.inferenceProvider) createProviders.add(intent.inferenceProvider); for (const provider of messagingProviders) createProviders.add(provider); @@ -295,7 +265,7 @@ export function materializeSandboxCreatePlan({ } return { - activeMessagingChannels, + activeMessagingChannels: [...intent.activeMessagingChannels], initialSandboxPolicy, policyTier: intent.policy.options.policyTier, createArgs, diff --git a/test/e2e/live/hermes-gpu-startup.test.ts b/test/e2e/live/hermes-gpu-startup.test.ts index d7ad88ec214..fa6fb5d64c1 100644 --- a/test/e2e/live/hermes-gpu-startup.test.ts +++ b/test/e2e/live/hermes-gpu-startup.test.ts @@ -27,7 +27,6 @@ import { import { assertHermesGpuStartupProof, HERMES_GPU_EXTRA_PLACEHOLDER_KEYS, - HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS, } from "./hermes-gpu-startup-proof.ts"; const GATEWAY_CLEANUP_MODULE = path.join(REPO_ROOT, "dist/lib/actions/sandbox/destroy-gateway.js"); @@ -62,6 +61,10 @@ const GPU_ROUTE_CONTROL = : GPU_ROUTE === "compatibility-fallback" ? "fallback" : undefined; +const GPU_STARTUP_EXPECTS_SECURE_STOP = GPU_STARTUP_SCENARIO === "fallback"; +const GPU_STARTUP_OUTCOME = GPU_STARTUP_EXPECTS_SECURE_STOP + ? "stops before an unsafe compatibility retry" + : "reaches stable Ready state"; validateSandboxName(SANDBOX_NAME); function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { @@ -299,15 +302,15 @@ done`; } test( - `hermes-gpu-startup: ${GPU_STARTUP_SCENARIO} OpenShell GPU route reaches stable Ready state`, + `hermes-gpu-startup: ${GPU_STARTUP_SCENARIO} OpenShell GPU route ${GPU_STARTUP_OUTCOME}`, { timeout: LIVE_TIMEOUT_MS, meta: { e2ePhases: [ "prepare clean Hermes GPU runner", - "install Hermes sandbox on selected GPU route", - "validate GPU startup and supervisor proof", - "exercise authenticated GPU inference route", + "exercise selected GPU route", + "validate selected GPU route outcome", + "validate selected GPU inference boundary", "remove Hermes GPU resources", ], }, @@ -423,7 +426,7 @@ test( ); await artifacts.writeJson("gpu-fallback-wrapper.json", { behavior: - "create real native state while dropping GPU attachment, reject exactly the first post-create nvidia-smi proof, then delegate compatibility retry", + "create real native state without GPU attachment and reject exactly the first post-create nvidia-smi proof", eventVocabulary: HERMES_GPU_FALLBACK_EVENTS, }); return wrapper; @@ -444,7 +447,7 @@ test( [HERMES_GPU_EXTRA_PLACEHOLDER_KEYS[0]]: EXTRA_PLACEHOLDER_TOKEN_A, [HERMES_GPU_EXTRA_PLACEHOLDER_KEYS[1]]: EXTRA_PLACEHOLDER_TOKEN_B, }); - progress.phase("install Hermes sandbox on selected GPU route"); + progress.phase("exercise selected GPU route"); const install = await host.command("bash", ["install.sh", "--non-interactive", "--fresh"], { artifactName: "phase-2-install-hermes-gpu-startup", cwd: REPO_ROOT, @@ -456,107 +459,149 @@ test( await (install.exitCode !== 0 ? captureFailedGpuContainer(host, gpuDiagnosticsDir) : Promise.resolve()); - expect(install.exitCode, resultText(install)).toBe(0); + expect(install.exitCode === 0, resultText(install)).toBe(!GPU_STARTUP_EXPECTS_SECURE_STOP); const verifyFallback = async (wrapper: ReturnType) => { const fallbackEvents = readHermesGpuFallbackEvents(wrapper.eventsPath); await artifacts.writeJson("gpu-fallback-events.json", fallbackEvents); - expect(fallbackEvents).toEqual([ + expect(fallbackEvents.slice(0, 2)).toEqual([ HERMES_GPU_FALLBACK_EVENTS.delegateNativeCreateWithoutGpu, HERMES_GPU_FALLBACK_EVENTS.rejectNativeNvidiaSmiProof, - HERMES_GPU_FALLBACK_EVENTS.delegateCompatibilityCreate, - HERMES_GPU_FALLBACK_EVENTS.delegateNvidiaSmiProofAfterRejection, ]); + expect(fallbackEvents).not.toContain( + HERMES_GPU_FALLBACK_EVENTS.delegateNvidiaSmiProofAfterRejection, + ); expect(resultText(install)).toContain("Native GPU diagnostics saved:"); - expect(HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS.every((fragment) => - resultText(install).includes(fragment))).toBe(true); + expect(resultText(install)).toContain( + "Operator-authorized GPU fallback stopped before compatibility retry.", + ); + expect(resultText(install)).toContain( + "Cleanup could not be proven safe: managed bootstrap owner cleanup is required for the exact sandbox and runtime identities", + ); }; await (fallbackWrapper ? verifyFallback(fallbackWrapper) : Promise.resolve()); - progress.phase("validate GPU startup and supervisor proof"); - const status = await host.command("nemoclaw", [SANDBOX_NAME, "status"], { - artifactName: "phase-3-nemoclaw-status", - env: commandEnv(), - timeoutMs: 60_000, - }); - expect(status.exitCode, resultText(status)).toBe(0); + const completeSecureStop = async () => { + progress.phase("validate selected GPU route outcome"); + expect(resultText(install)).toContain( + `Managed bootstrap retained exact owner-cleanup authority for sandbox '${SANDBOX_NAME}'.`, + ); - await assertHermesGpuStartupProof({ - env: commandEnv(), - gpuRoute: GPU_ROUTE, - host, - install, - sandbox, - sandboxName: SANDBOX_NAME, - status, - }); + progress.phase("validate selected GPU inference boundary"); + const inferencePosts = fake + .requests() + .filter( + (request) => + request.method === "POST" && + ["/v1/chat/completions", "/chat/completions", "/v1/responses", "/responses"].includes( + request.path, + ), + ); + expect(inferencePosts).toEqual([]); - progress.phase("exercise authenticated GPU inference route"); - const inference = await sandbox.execShell( - SANDBOX_NAME, - trustedSandboxShellScript( - `curl -fsS --max-time 60 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' --data '${JSON.stringify( - { - model: FAKE_MODEL, - messages: [{ role: "user", content: "reply with OK" }], - max_tokens: 8, - }, - )}'`, - ), - { - artifactName: "phase-5-authenticated-inference-post", + progress.phase("remove Hermes GPU resources"); + await cleanupHermes(host, sandbox, "phase-4-clean-teardown"); + cleanTeardownVerified = true; + await artifacts.target.complete({ + id: "hermes-gpu-startup", + gpuRoute: GPU_ROUTE, + scenario: GPU_STARTUP_SCENARIO, + assertions: { + exactOwnerCleanupRequired: true, + unsafeCompatibilityRetryBlocked: true, + cleanTeardownVerified, + }, + }); + }; + + const completeReadyRoute = async () => { + progress.phase("validate selected GPU route outcome"); + const status = await host.command("nemoclaw", [SANDBOX_NAME, "status"], { + artifactName: "phase-3-nemoclaw-status", env: commandEnv(), - timeoutMs: 90_000, - }, - ); - expect(inference.exitCode, resultText(inference)).toBe(0); - - const fakeRequests = fake.requests(); - const inferencePosts = fakeRequests.filter( - (request) => - request.method === "POST" && - ["/v1/chat/completions", "/chat/completions", "/v1/responses", "/responses"].includes( - request.path, + timeoutMs: 60_000, + }); + expect(status.exitCode, resultText(status)).toBe(0); + + await assertHermesGpuStartupProof({ + env: commandEnv(), + gpuRoute: GPU_ROUTE, + host, + install, + sandbox, + sandboxName: SANDBOX_NAME, + status, + }); + + progress.phase("validate selected GPU inference boundary"); + const inference = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + `curl -fsS --max-time 60 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' --data '${JSON.stringify( + { + model: FAKE_MODEL, + messages: [{ role: "user", content: "reply with OK" }], + max_tokens: 8, + }, + )}'`, ), - ); - expect( - inferencePosts.length, - `expected authenticated fake inference POST, got ${JSON.stringify(fakeRequests)}`, - ).toBeGreaterThan(0); - expect(inferencePosts.filter((request) => request.auth !== "ok")).toEqual([]); - expect(inferencePosts.filter((request) => request.authorizationSent !== true)).toEqual([]); - expect(inferencePosts.filter((request) => (request.forbiddenMarkerMatches ?? 0) > 0)).toEqual( - [], - ); - expect(JSON.stringify(fakeRequests)).not.toContain(EXTRA_PLACEHOLDER_TOKEN_A); - expect(JSON.stringify(fakeRequests)).not.toContain(EXTRA_PLACEHOLDER_TOKEN_B); + { + artifactName: "phase-5-authenticated-inference-post", + env: commandEnv(), + timeoutMs: 90_000, + }, + ); + expect(inference.exitCode, resultText(inference)).toBe(0); + + const fakeRequests = fake.requests(); + const inferencePosts = fakeRequests.filter( + (request) => + request.method === "POST" && + ["/v1/chat/completions", "/chat/completions", "/v1/responses", "/responses"].includes( + request.path, + ), + ); + expect( + inferencePosts.length, + `expected authenticated fake inference POST, got ${JSON.stringify(fakeRequests)}`, + ).toBeGreaterThan(0); + expect(inferencePosts.filter((request) => request.auth !== "ok")).toEqual([]); + expect(inferencePosts.filter((request) => request.authorizationSent !== true)).toEqual([]); + expect(inferencePosts.filter((request) => (request.forbiddenMarkerMatches ?? 0) > 0)).toEqual( + [], + ); + expect(JSON.stringify(fakeRequests)).not.toContain(EXTRA_PLACEHOLDER_TOKEN_A); + expect(JSON.stringify(fakeRequests)).not.toContain(EXTRA_PLACEHOLDER_TOKEN_B); - progress.phase("remove Hermes GPU resources"); - await cleanupHermes(host, sandbox, "phase-5-clean-teardown"); - cleanTeardownVerified = true; + progress.phase("remove Hermes GPU resources"); + await cleanupHermes(host, sandbox, "phase-5-clean-teardown"); + cleanTeardownVerified = true; - await artifacts.target.complete({ - id: "hermes-gpu-startup", - gpuRoute: GPU_ROUTE, - scenario: GPU_STARTUP_SCENARIO, - assertions: { - selectedGpuRouteVerified: true, - ...(GPU_ROUTE === "compatibility-fallback" - ? { automaticCompatibilityFallbackVerified: true } - : GPU_ROUTE === "native-success" - ? { nativeGpuRouteVerified: true } - : { compatibilityOnlyRouteVerified: true }), - openshellReady: true, - sandboxCudaVerified: true, - managedWorkloadAuthorityVerified: true, - extraPlaceholderCommandRoundTripValid: true, - stableSingleContainer: true, - startupConfigHashesValid: true, - supervisorTopologyValid: true, - authenticatedInferenceRequestVerified: true, - placeholderTokensAbsentFromInference: true, - cleanTeardownVerified, - }, - }); + await artifacts.target.complete({ + id: "hermes-gpu-startup", + gpuRoute: GPU_ROUTE, + scenario: GPU_STARTUP_SCENARIO, + assertions: { + selectedGpuRouteVerified: true, + ...(GPU_ROUTE === "compatibility-fallback" + ? { automaticCompatibilityFallbackVerified: true } + : GPU_ROUTE === "native-success" + ? { nativeGpuRouteVerified: true } + : { compatibilityOnlyRouteVerified: true }), + openshellReady: true, + sandboxCudaVerified: true, + managedWorkloadAuthorityVerified: true, + extraPlaceholderCommandRoundTripValid: true, + stableSingleContainer: true, + startupConfigHashesValid: true, + supervisorTopologyValid: true, + authenticatedInferenceRequestVerified: true, + placeholderTokensAbsentFromInference: true, + cleanTeardownVerified, + }, + }); + }; + + await (GPU_STARTUP_EXPECTS_SECURE_STOP ? completeSecureStop() : completeReadyRoute()); }, ); diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 5cfdfc289d3..87d52fa585c 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -6,6 +6,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import YAML from "yaml"; + +import { parseOpenShellPolicy } from "../../../src/lib/policy/merge.ts"; import type { ArtifactSink } from "../fixtures/artifacts.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { @@ -693,6 +696,61 @@ export async function applyRestRewritePolicy( expectExitZero(result, `apply ${api.kind} fake REST policy`); } +export async function bindRestRewriteProvider( + host: HostCliClient, + api: FakeDockerApi, + providerName: string, + env: NodeJS.ProcessEnv, + redactionValues: string[], +): Promise { + const current = await runHost(host, "openshell", ["policy", "get", "--full", SANDBOX_NAME], { + artifactName: `read-${api.kind}-rest-policy`, + env, + redactionValues, + timeoutMs: 60_000, + }); + expectExitZero(current, `read ${api.kind} fake REST policy`); + + const policy = parseOpenShellPolicy(resultText(current)).policy; + const endpoint = Object.values(policy.network_policies ?? {}) + .flatMap((entry) => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return []; + const endpoints = (entry as { endpoints?: unknown }).endpoints; + return Array.isArray(endpoints) ? endpoints : []; + }) + .find( + (candidate): candidate is Record => + typeof candidate === "object" && + candidate !== null && + !Array.isArray(candidate) && + (candidate as Record).host === "host.openshell.internal" && + (candidate as Record).port === Number(api.port) && + (candidate as Record).protocol === "rest", + ); + if (!endpoint) throw new Error(`fake ${api.kind} REST endpoint is missing from live policy`); + endpoint.credential_binding = { provider: providerName }; + + const directory = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-${api.kind}-policy-`)); + const policyFile = path.join(directory, "policy.yaml"); + try { + fs.writeFileSync(policyFile, YAML.stringify(policy), { mode: 0o600 }); + const updated = await runHost( + host, + "openshell", + ["policy", "set", "--policy", policyFile, "--wait", SANDBOX_NAME], + { + artifactName: `bind-${api.kind}-rest-policy-${path.basename(providerName)}`, + env, + redactionValues, + timeoutMs: 120_000, + }, + ); + expectExitZero(updated, `bind ${api.kind} fake REST policy to ${providerName}`); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + export async function applyWebSocketRewritePolicy( host: HostCliClient, api: FakeDockerApi, diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index c81e3fa14b0..7848018fb34 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -18,6 +18,7 @@ import { accountString, applyRestRewritePolicy, applyWebSocketRewritePolicy, + bindRestRewriteProvider, CLI_ENTRYPOINT, channelAccount, channelEnabled, @@ -831,6 +832,13 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); redactionValues, }); await applyRestRewritePolicy(host, fakeSlack, state.env, redactionValues); + await bindRestRewriteProvider( + host, + fakeSlack, + `${SANDBOX_NAME}-slack-bridge`, + state.env, + redactionValues, + ); const slackAuth = await runSlackApiRequest( sandbox, @@ -880,6 +888,13 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); `M-S15c: unset-var failed closed before upstream exposure (${slackUnset.slice(0, 200)})`, ); + await bindRestRewriteProvider( + host, + fakeSlack, + `${SANDBOX_NAME}-slack-app`, + state.env, + redactionValues, + ); const slackApp = await runSlackApiRequest( sandbox, fakeSlack.port, @@ -903,6 +918,14 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); "M-S16a: fake Slack saw host-side app token in header/body", ); + await bindRestRewriteProvider( + host, + fakeSlack, + `${SANDBOX_NAME}-slack-bridge`, + state.env, + redactionValues, + ); + const allowedSlackUser = state.slackIds .split(",") .map((value) => value.trim()) diff --git a/test/e2e/live/openshell-credential-generation-window.test.ts b/test/e2e/live/openshell-credential-generation-window.test.ts index 775844cc49a..5685ba66a0e 100644 --- a/test/e2e/live/openshell-credential-generation-window.test.ts +++ b/test/e2e/live/openshell-credential-generation-window.test.ts @@ -5,14 +5,13 @@ import { buildMcpCredentialDetachedCommand, buildMcpCredentialRevisionObservationCommand, } from "../../../src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts"; -import { getNetworkPolicyNames } from "../../../src/lib/onboard/initial-policy.ts"; +import { parseOpenShellPolicy } from "../../../src/lib/policy/merge.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { assertCleanupSucceededOrAbsent } from "../fixtures/cleanup-resources.ts"; import { assertExitZero as expectExitZero, resultText } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; -import YAML from "yaml"; import { MCP_BRIDGE_TEST_CREDENTIALS } from "../fixtures/mcp-bridge-credentials.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { hostAddressForSandbox } from "./mcp-bridge-sandbox.ts"; @@ -309,16 +308,14 @@ async function expectPolicyAbsent( timeoutMs: 90_000, }); expectExitZero(result, "inspect credential-window policy after binding removal"); - const parsedPolicy = YAML.parse(result.stdout); + const parsedPolicy = parseOpenShellPolicy(result.stdout).policy; expect( parsedPolicy?.network_policies && typeof parsedPolicy.network_policies === "object" && !Array.isArray(parsedPolicy.network_policies), "expected a complete live policy document after binding removal", ).toBe(true); - const policyNames = getNetworkPolicyNames(result.stdout); - expect(policyNames).not.toBeNull(); - expect(policyNames).not.toContain(policyName); + expect(Object.keys(parsedPolicy.network_policies ?? {})).not.toContain(policyName); } async function runFreshRequest( From 98b6a93577606daf0f4f496d7f9979f532ecf7f1 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 10:05:15 -0700 Subject: [PATCH 05/19] fix(security): preserve messaging credential boundaries Signed-off-by: Apurv Kumaria --- .../actions/sandbox/auto-pair-warmup.test.ts | 109 ++++++++++++++---- src/lib/actions/sandbox/auto-pair-warmup.ts | 40 +++---- .../initial-policy-real-policy.test.ts | 80 ++++++++----- test/channels-add-preset.test.ts | 2 +- test/e2e/live/messaging-providers-helpers.ts | 62 ---------- test/e2e/live/messaging-providers.test.ts | 71 +++++++++--- test/policies.test.ts | 4 +- 7 files changed, 216 insertions(+), 152 deletions(-) diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index c9dfdff092b..3a8f43567ca 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { RESTORED_CLONE_WARMUP_SCRIPT, sandboxWarmupExecArgs, + WARMUP_OPENCLAW_BIN, WARMUP_PROBE_TIMEOUT_S, WARMUP_SCRIPT, WARMUP_TIMEOUT_MS, @@ -49,7 +50,8 @@ describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => { describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", () => { it("keeps the real warm-up as one multiline command on the owning gateway (#10014)", () => { expect(WARMUP_SCRIPT).toContain("\n"); - expect(WARMUP_SCRIPT).toContain("command -v openclaw"); + expect(WARMUP_SCRIPT).toContain(`test -x ${WARMUP_OPENCLAW_BIN}`); + expect(WARMUP_SCRIPT).not.toContain("command -v openclaw"); expect(WARMUP_SCRIPT).not.toContain("base64 -d"); expect(WARMUP_SCRIPT).not.toContain("mktemp"); expect(sandboxWarmupExecArgs("alpha", "nemoclaw-19000", WARMUP_SCRIPT)).toEqual([ @@ -80,7 +82,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( it("tags the provoke session with the shared warm-up prefix", () => { expect(WARMUP_SESSION_ID_PREFIX).toBe("nemoclaw-onboard-warmup-"); expect(WARMUP_SCRIPT).toContain( - `session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`, + `session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(/bin/date +%s)"`, ); }); @@ -193,7 +195,8 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( ); try { - const result = spawnSync("sh", ["-c", WARMUP_SCRIPT], { + const script = WARMUP_SCRIPT.replaceAll(WARMUP_OPENCLAW_BIN, path.join(binDir, "openclaw")); + const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { ...process.env, @@ -232,28 +235,28 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( "export NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=ambient-force-marker", "export NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING=ambient-clone-marker", "export NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT=ambient-settlement-marker", - 'printf \'consumed\\n\' > "$NEMOCLAW_TEST_PROXY_SOURCE_LOG"', + "printf 'consumed\\n' > \"$NEMOCLAW_TEST_PROXY_SOURCE_LOG\"", "", ].join("\n"), { mode: 0o444 }, ); fs.writeFileSync( path.join(binDir, "openclaw"), - [ - "#!/bin/sh", - "{", - " printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"", - " printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"", - " printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"", - " printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"", - " printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"", - " printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"", - " printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"", - " printf 'argv=%s\\n' \"$*\"", - '} > "$NEMOCLAW_TEST_CALL_LOG"', - "exit 1", - "", - ].join("\n"), + [ + "#!/bin/sh", + "{", + " printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"", + " printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"", + " printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"", + " printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"", + " printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"", + " printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"", + " printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"", + " printf 'argv=%s\\n' \"$*\"", + '} > "$NEMOCLAW_TEST_CALL_LOG"', + "exit 1", + "", + ].join("\n"), { mode: 0o700 }, ); @@ -261,7 +264,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( const script = WARMUP_SCRIPT.replace( buildTrustedProxyEnvSourceShell(), buildTrustedProxyEnvSourceShell(proxyEnv), - ); + ).replaceAll(WARMUP_OPENCLAW_BIN, path.join(binDir, "openclaw")); const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { @@ -283,6 +286,72 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( } }); + itWithSh("does not pass gateway credentials to an OpenClaw program earlier in PATH", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-path-")); + const pathBinDir = path.join(fixtureRoot, "path-bin"); + const trustedBinDir = path.join(fixtureRoot, "trusted-bin"); + const proxyEnv = path.join(fixtureRoot, "proxy-env.sh"); + const pathLog = path.join(fixtureRoot, "path.log"); + const trustedLog = path.join(fixtureRoot, "trusted.log"); + fs.mkdirSync(pathBinDir); + fs.mkdirSync(trustedBinDir); + fs.writeFileSync( + proxyEnv, + [ + "export OPENCLAW_GATEWAY_TOKEN=shared-token", + "export OPENCLAW_GATEWAY_PASSWORD=shared-password", + "", + ].join("\n"), + { mode: 0o444 }, + ); + fs.writeFileSync( + path.join(pathBinDir, "openclaw"), + [ + "#!/bin/sh", + 'printf \'token=%s password=%s\\n\' "${OPENCLAW_GATEWAY_TOKEN-unset}" "${OPENCLAW_GATEWAY_PASSWORD-unset}" > "$NEMOCLAW_TEST_PATH_LOG"', + "exit 0", + "", + ].join("\n"), + { mode: 0o700 }, + ); + const trustedOpenClaw = path.join(trustedBinDir, "openclaw"); + fs.writeFileSync( + trustedOpenClaw, + [ + "#!/bin/sh", + 'printf \'token=%s password=%s\\n\' "${OPENCLAW_GATEWAY_TOKEN-unset}" "${OPENCLAW_GATEWAY_PASSWORD-unset}" > "$NEMOCLAW_TEST_TRUSTED_LOG"', + "exit 0", + "", + ].join("\n"), + { mode: 0o700 }, + ); + + try { + const script = WARMUP_SCRIPT.replace( + buildTrustedProxyEnvSourceShell(), + buildTrustedProxyEnvSourceShell(proxyEnv), + ).replaceAll(WARMUP_OPENCLAW_BIN, trustedOpenClaw); + const result = spawnSync("sh", ["-c", script], { + encoding: "utf-8", + env: { + ...process.env, + NEMOCLAW_TEST_PATH_LOG: pathLog, + NEMOCLAW_TEST_TRUSTED_LOG: trustedLog, + PATH: `${pathBinDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }, + timeout: 10_000, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(pathLog)).toBe(false); + expect(fs.readFileSync(trustedLog, "utf8")).toBe( + "token=shared-token password=shared-password\n", + ); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + it("rejects unsafe proxy source paths before a warm-up child can read credentials (#10014)", () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-unsafe-proxy-")); const unsafeProxy = path.join(fixtureRoot, "proxy-env.sh"); diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index bfaa6f593bb..936b00020ee 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -43,23 +43,25 @@ import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; // wedged sandbox from blocking onboard or restore. export const WARMUP_TIMEOUT_MS = 30_000; export const WARMUP_PROBE_TIMEOUT_S = 5; +export const WARMUP_OPENCLAW_BIN = "/usr/local/bin/openclaw"; -// Best-effort in-sandbox request producer. Always exits 0. Keep the trusted -// gateway credential for the initial request, before a CLI device credential -// exists. OpenClaw 2026.7.1 can omit CLI identity on loopback shared auth, so -// force device pairing only on this command. Finalization's canonical observer -// owns pairing-state polling. +// Best-effort in-sandbox request producer. Probe failures exit 0; rejecting an +// unsafe trusted-proxy source can exit nonzero before the outer wrapper ignores +// the result. Keep the trusted gateway credential for the initial request, +// before a CLI device credential exists. OpenClaw 2026.7.1 can omit CLI +// identity on loopback shared auth, so force device pairing only on this +// command. Finalization's canonical observer owns pairing-state polling. Use +// only root-owned executable paths after loading the gateway credential. export const WARMUP_SCRIPT = ` ${buildTrustedProxyEnvSourceShell()} -command -v openclaw >/dev/null 2>&1 || exit 0 -command -v python3 >/dev/null 2>&1 || exit 0 +test -x ${WARMUP_OPENCLAW_BIN} || exit 0 +test -x /usr/bin/python3 || exit 0 unset OPENCLAW_GATEWAY_URL NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING \\ NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT || exit 0 -session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" +session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(/bin/date +%s)" params="$(printf '{"key":"%s","agentId":"main"}' "$session_key")" -OPENCLAW_BIN="$(command -v openclaw)" -OPENCLAW_BIN="$OPENCLAW_BIN" NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ - python3 - "$params" <<'PYPROBE' +OPENCLAW_BIN="${WARMUP_OPENCLAW_BIN}" NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ + /usr/bin/python3 - "$params" <<'PYPROBE' import os import subprocess import sys @@ -123,16 +125,12 @@ function runSandboxWarmupScript( try { const openshellBinary = resolveOpenshell(); if (!openshellBinary) return; - spawnSync( - openshellBinary, - sandboxWarmupExecArgs(sandboxName, gatewayName, script), - { - cwd: ROOT, - env: process.env, - stdio: ["ignore", "ignore", "ignore"], - timeout: WARMUP_TIMEOUT_MS, - }, - ); + spawnSync(openshellBinary, sandboxWarmupExecArgs(sandboxName, gatewayName, script), { + cwd: ROOT, + env: process.env, + stdio: ["ignore", "ignore", "ignore"], + timeout: WARMUP_TIMEOUT_MS, + }); } catch { /* defense-in-depth — never throw from a warm-up path */ } diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index 63ff814f014..7c0921fe381 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -30,6 +30,7 @@ type PolicyRule = { type PolicyEndpoint = { host?: string; port?: number; + path?: string; access?: string; protocol?: string; enforcement?: string; @@ -70,9 +71,7 @@ function filesystemPolicyAncestors(policyPath: string): string[] { const segments = normalizeFilesystemPolicyPath(policyPath).split("/").filter(Boolean); return [ "/", - ...segments - .slice(0, -1) - .map((_, index) => `/${segments.slice(0, index + 1).join("/")}`), + ...segments.slice(0, -1).map((_, index) => `/${segments.slice(0, index + 1).join("/")}`), ]; } @@ -95,9 +94,7 @@ describe("initial sandbox policy real preset merge", () => { ["agents", "hermes", "policy-additions.yaml"], ["agents", "hermes", "policy-permissive.yaml"], ], - "langchain-deepagents-code": [ - ["agents", "langchain-deepagents-code", "policy-additions.yaml"], - ], + "langchain-deepagents-code": [["agents", "langchain-deepagents-code", "policy-additions.yaml"]], } as const satisfies Record< (typeof SHIPPED_MANAGED_IMAGE_AGENTS)[number], readonly (readonly string[])[] @@ -398,28 +395,48 @@ describe("initial sandbox policy real preset merge", () => { ); }); - it.each( - [ - { - path: repoPath("nemoclaw-blueprint", "policies", "openclaw-sandbox-permissive.yaml"), - agent: "openclaw", - }, - { path: repoPath("agents", "hermes", "policy-permissive.yaml"), agent: "hermes" }, - ].flatMap((policyCase) => - ["slack.com", "api.slack.com", "hooks.slack.com"].map((host) => ({ policyCase, host })), - ), - )("keeps Slack credential rewrite for $policyCase.agent on $host", ({ policyCase, host }) => { + it.each([ + { + path: repoPath("nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), + agent: "openclaw", + }, + { path: repoPath("agents", "hermes", "policy-additions.yaml"), agent: "hermes" }, + ])("keeps separate Slack bot and app credential routes for $agent", ({ path, agent }) => { + const sandboxName = `${agent}-slack-e2e`; const effective = readPreparedPolicy( - prepareInitialSandboxCreatePolicy(policyCase.path, ["slack"], { - agentName: policyCase.agent, + prepareInitialSandboxCreatePolicy(path, ["slack"], { + agentName: agent, + sandboxName, }), ); const slackEndpoints = effective.network_policies?.slack?.endpoints ?? []; - const endpoint = slackEndpoints.find((candidate) => candidate.host === host); - expect(endpoint, `${policyCase.agent}:${host}`).toMatchObject({ + const botEndpoint = slackEndpoints.find( + (endpoint) => endpoint.host === "slack.com" && endpoint.path === "/**", + ); + const appEndpoint = slackEndpoints.find( + (endpoint) => endpoint.host === "slack.com" && endpoint.path === "/api/apps.connections.open", + ); + + expect(botEndpoint, `${agent}: ordinary Slack route`).toMatchObject({ protocol: "rest", request_body_credential_rewrite: true, + credential_binding: { provider: `${sandboxName}-slack-bridge` }, }); + expect(appEndpoint, `${agent}: Socket Mode app route`).toMatchObject({ + protocol: "rest", + request_body_credential_rewrite: true, + credential_binding: { provider: `${sandboxName}-slack-app` }, + rules: [{ allow: { method: "POST", path: "/api/apps.connections.open" } }], + }); + expect( + slackEndpoints.find((endpoint) => endpoint.host === "api.slack.com"), + `${agent}:api.slack.com`, + ).toMatchObject({ protocol: "rest", request_body_credential_rewrite: true }); + expect( + slackEndpoints.find((endpoint) => endpoint.host === "hooks.slack.com"), + `${agent}:hooks.slack.com`, + ).toMatchObject({ protocol: "rest", request_body_credential_rewrite: true }); + expect(JSON.stringify(effective)).not.toContain("{sandboxName}"); }); it("materializes Hermes Discord credential bindings from the target sandbox name", () => { @@ -452,15 +469,18 @@ describe("initial sandbox policy real preset merge", () => { it.each([ ["missing", undefined], ["unsafe", "bad:provider"], - ])("rejects a Hermes Discord create policy with a %s target sandbox name", (_case, sandboxName) => { - expect(() => - prepareInitialSandboxCreatePolicy( - repoPath("agents", "hermes", "policy-additions.yaml"), - ["discord"], - { agentName: "hermes", sandboxName }, - ), - ).toThrow("a valid sandbox name is required to materialize credential bindings"); - }); + ])( + "rejects a Hermes Discord create policy with a %s target sandbox name", + (_case, sandboxName) => { + expect(() => + prepareInitialSandboxCreatePolicy( + repoPath("agents", "hermes", "policy-additions.yaml"), + ["discord"], + { agentName: "hermes", sandboxName }, + ), + ).toThrow("a valid sandbox name is required to materialize credential bindings"); + }, + ); it.each(shippingPolicyCases.slice(0, 3).concat(shippingPolicyCases.slice(4)))( "keeps optional Claude hosts out of $agent create policy $path", diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 85b5b7614e8..ccb41ac9ec4 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -867,7 +867,7 @@ describe("channel preset source-of-truth", () => { it.each(knownChannelNames())( "channel $name ships a preset that parsePresetPolicyKeys accepts", (name) => { - const content = policies.loadPreset(name); + const content = policies.loadPresetForSandbox("test-sb", name); expect(content, `${name}: preset YAML not found on disk`).not.toBeNull(); expect( policies.parsePresetPolicyKeys(content!).length, diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 87d52fa585c..38821a5a018 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -29,12 +29,6 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; export { CLI_ENTRYPOINT, expectExitZero, REPO_ROOT }; -export const BASE_POLICY = path.join( - REPO_ROOT, - "nemoclaw-blueprint", - "policies", - "openclaw-sandbox.yaml", -); export const FAKE_LIB_DIR = path.join(REPO_ROOT, "test", "e2e", "lib"); export const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? `e2e-msg-${process.pid}`; export const INSTALL_TIMEOUT_MS = 45 * 60_000; @@ -436,62 +430,6 @@ export function policyTextHasHost(text: string, host: string): boolean { return text.split(/\r?\n/).some((line) => accepted.has(line.trim())); } -export async function premergeSlackPolicyIfNeeded(): Promise<() => void> { - const original = fs.readFileSync(BASE_POLICY, "utf8"); - if (policyTextHasHost(original, "api.slack.com")) { - return () => {}; - } - fs.appendFileSync( - BASE_POLICY, - ` - - # Slack - pre-merged for messaging provider E2E (#2340) - slack: - name: slack - endpoints: - - host: slack.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: api.slack.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: hooks.slack.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: wss-primary.slack.com - port: 443 - protocol: websocket - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - - host: wss-backup.slack.com - port: 443 - protocol: websocket - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: WEBSOCKET_TEXT, path: "/**" } - binaries: - - { path: /usr/local/bin/node } - - { path: /usr/bin/node } -`, - ); - return () => fs.writeFileSync(BASE_POLICY, original); -} - export async function readOpenClawConfig( sandbox: SandboxClient, redactionValues: string[], diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 7848018fb34..59c432b21b8 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -11,6 +11,7 @@ import fs from "node:fs"; +import { parseOpenShellPolicy } from "../../../src/lib/policy/merge.ts"; import { testTimeoutOptions } from "../../helpers/timeouts"; import { test } from "../fixtures/e2e-test.ts"; import { @@ -35,7 +36,6 @@ import { outputText, pluginEnabled, policyTextHasHost, - premergeSlackPolicyIfNeeded, REBUILD_TIMEOUT_MS, rawTokenSurfaceProbe, readOpenClawConfig, @@ -113,9 +113,6 @@ test( timeoutMs: 15 * 60_000, }); - const restoreSlackPolicy = await premergeSlackPolicyIfNeeded(); - cleanup.add("restore messaging E2E Slack policy pre-merge", restoreSlackPolicy); - await runSecondaryCleanup(() => runHost(host, "node", [CLI_ENTRYPOINT, SANDBOX_NAME, "destroy", "--yes"], { artifactName: "preclean-nemoclaw-destroy-messaging-providers", @@ -308,6 +305,42 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w /\/usr\/local\/bin\/node|\/usr\/bin\/node/.test(whatsappPolicyPostText), "M-WA5: WhatsApp policy preset survived rebuild with Node binary scope", ); + const livePolicy = parseOpenShellPolicy(whatsappPolicyPostText).policy; + const slackPolicy = livePolicy.network_policies?.slack; + const slackPolicyRecord = + slackPolicy && typeof slackPolicy === "object" && !Array.isArray(slackPolicy) + ? (slackPolicy as Record) + : null; + const slackEndpoints = + slackPolicyRecord && Array.isArray(slackPolicyRecord.endpoints) + ? (slackPolicyRecord.endpoints as Array>) + : []; + const slackBotEndpoint = slackEndpoints.find( + (endpoint) => endpoint.host === "slack.com" && endpoint.path === "/**", + ); + const slackAppEndpoint = slackEndpoints.find( + (endpoint) => endpoint.host === "slack.com" && endpoint.path === "/api/apps.connections.open", + ); + check( + slackBotEndpoint?.request_body_credential_rewrite === true && + (slackBotEndpoint.credential_binding as { provider?: unknown } | undefined)?.provider === + `${SANDBOX_NAME}-slack-bridge`, + "M-WA6: installed ordinary Slack route uses the bot credential provider", + ); + check( + slackAppEndpoint?.request_body_credential_rewrite === true && + (slackAppEndpoint.credential_binding as { provider?: unknown } | undefined)?.provider === + `${SANDBOX_NAME}-slack-app`, + "M-WA7: installed Socket Mode route uses the app credential provider", + ); + check( + ["wss-primary.slack.com", "wss-backup.slack.com"].every((host) => + slackEndpoints.some( + (endpoint) => endpoint.host === host && endpoint.websocket_credential_rewrite === true, + ), + ), + "M-WA8: installed Slack Socket Mode routes retain WebSocket credential rewrite", + ); progress.phase("inspect providers placeholders and credential isolation"); const providerList = await runHost(host, "openshell", ["provider", "list"], { @@ -448,12 +481,14 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w ); const config = await readOpenClawConfig(sandbox, redactionValues); - ([ - ["M6a", "telegram", "telegram"], - ["M6b", "discord", "discord"], - ["M6c", "slack", "slack"], - ["M6d", "whatsapp", "whatsapp"], - ] as const).forEach(([assertionId, channel, plugin]) => { + ( + [ + ["M6a", "telegram", "telegram"], + ["M6b", "discord", "discord"], + ["M6c", "slack", "slack"], + ["M6d", "whatsapp", "whatsapp"], + ] as const + ).forEach(([assertionId, channel, plugin]) => { check(channelEnabled(config, channel), `${assertionId}: channels.${channel}.enabled is true`); check( pluginEnabled(config, plugin), @@ -525,8 +560,8 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w check( Boolean( whatsappHealth && - typeof whatsappHealth === "object" && - (whatsappHealth as Record).enabled === false, + typeof whatsappHealth === "object" && + (whatsappHealth as Record).enabled === false, ), "M-WA8a: WhatsApp health monitor is disabled for unpaired QR session", ); @@ -583,11 +618,13 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w const parsedRuntime = JSON.parse(runtimeChannels) as { chat?: Record; }; - ([ - ["M6e", "telegram", "default"], - ["M6f", "discord", "default"], - ["M6g", "slack", "default"], - ] as const).forEach(([assertionId, channel, accountId]) => { + ( + [ + ["M6e", "telegram", "default"], + ["M6f", "discord", "default"], + ["M6g", "slack", "default"], + ] as const + ).forEach(([assertionId, channel, accountId]) => { const entry = parsedRuntime.chat?.[channel]; check( entry?.installed === true && diff --git a/test/policies.test.ts b/test/policies.test.ts index e194f4894b5..7554d4408e1 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1130,7 +1130,9 @@ network_policies: " port: 443\n" + " access: full\n"; - const result = policies.mergePresetNamesIntoPolicy(current, ["slack"]); + const result = policies.mergePresetNamesIntoPolicy(current, ["slack"], { + sandboxName: "policy-test", + }); expect(result.appliedPresets).toEqual(["slack"]); expect(result.missingPresets).toEqual([]); From 1dfce9d8233d180e3c965d49cfb3ec1cd8962443 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 00:14:03 +0700 Subject: [PATCH 06/19] fix(e2e): stabilize credential-bound workflows Signed-off-by: San Dang --- agents/hermes/runtime-config-guard.py | 7 +++ agents/hermes/validate-env-secret-boundary.py | 4 +- scripts/nemoclaw-start.sh | 48 +++++++++++++++++-- src/lib/onboard/machine/handlers/sandbox.ts | 6 --- .../sandbox-create-intent-resolution.ts | 10 +--- .../onboard/sandbox-gpu-create-flow.test.ts | 4 +- .../onboard/sandbox-gpu-create-run-attempt.ts | 14 ++---- test/e2e/live/messaging-providers-helpers.ts | 28 +++++++++-- test/e2e/live/messaging-providers.test.ts | 8 ++-- test/hermes-runtime-api-key.test.ts | 2 +- test/nemoclaw-start-runtime-env-alias.test.ts | 4 +- test/nemoclaw-start.test.ts | 8 ++-- 12 files changed, 98 insertions(+), 45 deletions(-) diff --git a/agents/hermes/runtime-config-guard.py b/agents/hermes/runtime-config-guard.py index fdb1222f0aa..3e5ffe97fe3 100755 --- a/agents/hermes/runtime-config-guard.py +++ b/agents/hermes/runtime-config-guard.py @@ -4962,6 +4962,13 @@ def _runtime_plan_replacements_and_provider_keys( if not _placeholder_suffix_matches_env_key(suffix, env_key): continue if compiled.search(runtime_value): + revision = re.fullmatch( + rf"openshell:resolve:env:(v[0-9]{{1,20}}_){re.escape(env_key)}", + runtime_value, + ) + marker = f"-OPENSHELL-RESOLVE-ENV-{env_key}" + if revision and value.endswith(marker): + value = value[: -len(env_key)] + revision.group(1) + env_key replacements[env_key] = (value, message) return replacements, provider_env_keys, True diff --git a/agents/hermes/validate-env-secret-boundary.py b/agents/hermes/validate-env-secret-boundary.py index e1a6fd71961..7c0ae24a348 100755 --- a/agents/hermes/validate-env-secret-boundary.py +++ b/agents/hermes/validate-env-secret-boundary.py @@ -29,7 +29,9 @@ from typing import Iterable, TextIO SECRET_KEY_RE = re.compile(r"(^|_)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)(_|$)") -PLACEHOLDER_RE = re.compile(r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-[A-Z0-9_]+$") +PLACEHOLDER_RE = re.compile( + r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-(?:v[0-9]{1,20}_)?[A-Z][A-Z0-9_]*$" +) KEY_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") API_SERVER_KEY_RE = re.compile(r"^[0-9a-f]{64}$") HERMES_API_PORT_RANGE_START = 8642 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 1f0b7f95f44..062c034a13b 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1698,12 +1698,16 @@ prefix = "openshell:resolve:env:" alias_marker = "-OPENSHELL-RESOLVE-ENV-" keys = os.environ.get("NEMOCLAW_PROVIDER_PLACEHOLDER_KEYS", "").split() replacements = {} +alias_replacements = {} warnings = [] for key in keys: value = os.environ.get(key, "") if value.startswith(prefix) and value != f"{prefix}{key}": replacements[f"{prefix}{key}"] = (key, value) + suffix = value[len(prefix) :] + if re.fullmatch(rf"v[0-9]+_{re.escape(key)}", suffix): + alias_replacements[key] = suffix with open(config_file, encoding="utf-8") as f: config = json.load(f) @@ -1722,6 +1726,17 @@ replacement_patterns = [ (re.compile(re.escape(old) + r"(?![A-Za-z0-9_])"), key, new) for old, (key, new) in sorted(replacements.items(), key=lambda kv: -len(kv[0])) ] +alias_replacement_patterns = [ + ( + re.compile( + re.escape(alias_marker) + + rf"(?:v[0-9]+_)?{re.escape(key)}(?![A-Za-z0-9_])" + ), + key, + f"{alias_marker}{suffix}", + ) + for key, suffix in alias_replacements.items() +] def rewrite(value): @@ -1731,6 +1746,11 @@ def rewrite(value): if count: refreshed.add(key) value = updated + for pattern, key, new in alias_replacement_patterns: + updated, count = pattern.subn(new, value) + if count: + refreshed.add(key) + value = updated return value if isinstance(value, list): return [rewrite(item) for item in value] @@ -1784,7 +1804,7 @@ def walk_for_warnings(value, path): alias_env_key = value[alias_index + len(alias_marker) :] token_scheme = value[:alias_index] + "-" for env_key in keys: - if env_key != alias_env_key: + if not placeholder_suffix_matches_env_key(alias_env_key, env_key): continue label = path_label(path) env_value = os.environ.get(env_key, "") @@ -1795,7 +1815,16 @@ def walk_for_warnings(value, path): warnings.append( f"[channels] {label} expects the {env_key} provider placeholder but it is missing from the runtime environment" ) - elif not placeholder_re.match(env_value) and not env_value.startswith(token_scheme): + elif placeholder_re.match(env_value): + expected = ( + f"{token_scheme}OPENSHELL-RESOLVE-ENV-" + f"{env_value[len(prefix):]}" + ) + if value != expected: + warnings.append( + f"[channels] {label} placeholder does not match the OpenShell runtime placeholder for {env_key}" + ) + elif not env_value.startswith(token_scheme): warnings.append( f"[channels] {label} runtime {env_key} is neither the {env_key} OpenShell placeholder nor a {token_scheme} token; runtime may reject it" ) @@ -2067,11 +2096,20 @@ import sys with open(sys.argv[1], encoding="utf-8") as handle: plan = json.load(handle) for alias in plan.get("envAliases", []): - if not re.search(alias["match"], os.environ.get(alias["envKey"], "")): + env_key = alias["envKey"] + runtime_value = os.environ.get(env_key, "") + if not re.search(alias["match"], runtime_value): continue + value = alias["value"] + revision = re.fullmatch( + rf"openshell:resolve:env:(v[0-9]+_){re.escape(env_key)}", runtime_value + ) + marker = f"-OPENSHELL-RESOLVE-ENV-{env_key}" + if revision and value.endswith(marker): + value = value[: -len(env_key)] + revision.group(1) + env_key print("\t".join([ - alias["envKey"], - alias["value"], + env_key, + value, alias.get("message", ""), ])) PYMESSAGINGALIASES diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 4b47247ee98..7c3732615aa 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -499,11 +499,6 @@ function rebuildPolicyPresetsForCreateIntent( return Array.isArray(selectedValue) ? { rebuildPolicyPresets: [...selectedValue] } : {}; } -function disabledChannelNamesForCreateIntent(session: Session | null) { - const disabledChannelNames = session?.messagingPlan?.disabledChannels; - return disabledChannelNames ? { disabledChannelNames } : {}; -} - /** Replace a resumed create-plan snapshot with the outer rebuild's normalized built-ins. */ function applyAuthoritativeRebuildPolicyPresets( intent: ResolvedSandboxCreateIntent, @@ -1614,7 +1609,6 @@ class SandboxStateFlow< inferenceProvider: this.options.provider, hostLocalInferenceRouteOnly: this.options.hostLocalInferenceRouteOnly === true, enabledChannels: state.selectedMessagingChannels, - ...disabledChannelNamesForCreateIntent(state.session), webSearchConfig: state.webSearchConfig, agent: this.options.agent, sandboxGpuConfig: this.options.sandboxGpuConfig, diff --git a/src/lib/onboard/sandbox-create-intent-resolution.ts b/src/lib/onboard/sandbox-create-intent-resolution.ts index cd4188da038..36329b73cc1 100644 --- a/src/lib/onboard/sandbox-create-intent-resolution.ts +++ b/src/lib/onboard/sandbox-create-intent-resolution.ts @@ -28,7 +28,6 @@ export type CompleteSandboxCreateIntentInput = { inferenceProvider?: string | null; hostLocalInferenceRouteOnly?: boolean; enabledChannels: readonly string[] | null; - disabledChannelNames?: readonly string[]; webSearchConfig: WebSearchConfig | null; agent: Agent; sandboxGpuConfig: SandboxGpuCreateConfig; @@ -73,7 +72,6 @@ export function createSandboxCreateIntentResolver< CompleteSandboxCreateIntentInput, | "sandboxName" | "enabledChannels" - | "disabledChannelNames" | "webSearchConfig" | "agent" | "reuseRegisteredCredentials" @@ -81,7 +79,6 @@ export function createSandboxCreateIntentResolver< expectedIntent?: SandboxCreateIntent, credentialRegistration = false, ) { - const disabledChannelNames = input.disabledChannelNames; const preflightDeps = expectedIntent ? { ...deps.messagingPreflightDeps, @@ -94,12 +91,7 @@ export function createSandboxCreateIntentResolver< readMessagingPlanFromEnv: () => null, registerExtraPlaceholderProviders: () => [], } - : disabledChannelNames - ? { - ...deps.messagingPreflightDeps, - resolveDisabledChannels: () => [...disabledChannelNames], - } - : deps.messagingPreflightDeps; + : deps.messagingPreflightDeps; const result = await prepareSandboxMessagingPreflight( { channels: deps.channels, diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index 0730e6b44bd..f8f6ccacc47 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -993,7 +993,7 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { expect(exit).toHaveBeenCalledWith(23); }); - it("keeps native readiness on the single-Ready contract", async () => { + it("confirms native readiness before live operations", async () => { const deps = createDeps(); await expect(runSandboxGpuCreateFlow(createInput(), deps)).resolves.toMatchObject({ @@ -1001,7 +1001,7 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { }); expect(mocks.waitForCreatedSandboxReadyWithTrace).toHaveBeenCalledWith( - expect.objectContaining({ stableReadyPolls: 1 }), + expect.objectContaining({ stableReadyPolls: 2 }), ); expect(mocks.enforceDockerGpuPatchPreserveNetwork).not.toHaveBeenCalled(); }); diff --git a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts index fcfb7dd8cbe..d4ddbd555bd 100644 --- a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts +++ b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts @@ -54,10 +54,9 @@ export type SandboxGpuCreateAttemptState = { portableLifecycleGeneration: string | null; }; -// A runtime-managed container replacement can briefly observe the original -// container's stale Ready row. Require one confirmation poll before advancing -// to live validation or the GPU proof. -const REPLACEMENT_STABLE_READY_POLLS = 2; +// OpenShell can briefly report Ready before the sandbox accepts live operations. +// Require one confirmation poll before advancing to validation or forwarding. +const CREATE_STABLE_READY_POLLS = 2; const SANDBOX_READY_PROBE_TIMEOUT_MS = 5_000; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/gu; @@ -426,7 +425,7 @@ export function createSandboxGpuCreateAttemptRunner( runCaptureOpenshell: deps.runCaptureOpenshell, isSandboxReady, getSandboxFailurePhase, - stableReadyPolls: REPLACEMENT_STABLE_READY_POLLS, + stableReadyPolls: CREATE_STABLE_READY_POLLS, sleep: deps.sleep, }); if (!readiness.ready) { @@ -584,10 +583,7 @@ export function createSandboxGpuCreateAttemptRunner( runCaptureOpenshell: deps.runCaptureOpenshell, isSandboxReady, getSandboxFailurePhase, - stableReadyPolls: - compatibility || managedBootstrap || expectedRecreatedSandboxId - ? REPLACEMENT_STABLE_READY_POLLS - : 1, + stableReadyPolls: CREATE_STABLE_READY_POLLS, checkReadyIdentity: expectedRecreatedSandboxId ? (getRemainingMs = () => SANDBOX_RECREATE_PROBE_TIMEOUT_MS) => checkRecreatedSandboxReadyIdentity( diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index 38821a5a018..9a0ca2dfc30 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -743,15 +743,37 @@ export async function runSlackApiRequest( sandbox: SandboxClient, port: string, apiPath: string, - authorization: string, + authorization: + | string + | { readonly envKey: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN"; readonly aliasPrefix?: string }, redactionValues: string[], ): Promise { + const authorizationEnv: Record = + typeof authorization === "string" + ? { FAKE_SLACK_AUTH: authorization } + : { + FAKE_SLACK_AUTH_ENV_KEY: authorization.envKey, + FAKE_SLACK_AUTH_ALIAS_PREFIX: authorization.aliasPrefix ?? "", + }; const result = await runSandboxNode( sandbox, ` import http from "node:http"; -const authorization = process.env.FAKE_SLACK_AUTH ?? ""; +let authorization = process.env.FAKE_SLACK_AUTH ?? ""; +const envKey = process.env.FAKE_SLACK_AUTH_ENV_KEY ?? ""; +if (envKey) { + const runtimeValue = process.env[envKey] ?? ""; + const suffix = runtimeValue.match( + new RegExp("^openshell:resolve:env:((?:v[0-9]+_)?" + envKey + ")$"), + )?.[1]; + if (!suffix) throw new Error("runtime placeholder for " + envKey + " is unavailable"); + const aliasPrefix = process.env.FAKE_SLACK_AUTH_ALIAS_PREFIX ?? ""; + const token = aliasPrefix + ? aliasPrefix + "-OPENSHELL-RESOLVE-ENV-" + suffix + : runtimeValue; + authorization = "Bearer " + token; +} const token = authorization.replace(/^Bearer\\s+/, ""); const data = new URLSearchParams({ token }).toString(); const req = http.request({ @@ -784,7 +806,7 @@ req.end(); env: { FAKE_SLACK_PORT: port, FAKE_SLACK_PATH: apiPath, - FAKE_SLACK_AUTH: authorization, + ...authorizationEnv, }, redactionValues, timeoutMs: 60_000, diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 59c432b21b8..d117fa5598a 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -881,7 +881,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); sandbox, fakeSlack.port, "/api/auth.test", - "Bearer xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + { envKey: "SLACK_BOT_TOKEN", aliasPrefix: "xoxb" }, redactionValues, ); check( @@ -905,12 +905,12 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); sandbox, fakeSlack.port, "/api/auth.test", - "Bearer openshell:resolve:env:SLACK_BOT_TOKEN", + { envKey: "SLACK_BOT_TOKEN" }, redactionValues, ); check( /^200\b/.test(slackCanonical) && /invalid_auth|not_authed|ok":true/.test(slackCanonical), - "M-S15b: L7 proxy substitutes canonical SLACK_BOT_TOKEN placeholder", + "M-S15b: L7 proxy substitutes the runtime-scoped SLACK_BOT_TOKEN placeholder", ); const slackUnset = await runSlackApiRequest( sandbox, @@ -936,7 +936,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); sandbox, fakeSlack.port, "/api/apps.connections.open", - "Bearer xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + { envKey: "SLACK_APP_TOKEN", aliasPrefix: "xapp" }, redactionValues, ); check( diff --git a/test/hermes-runtime-api-key.test.ts b/test/hermes-runtime-api-key.test.ts index fd75b1d86b5..0bdb8ac3d01 100644 --- a/test/hermes-runtime-api-key.test.ts +++ b/test/hermes-runtime-api-key.test.ts @@ -849,7 +849,7 @@ describe("agents/hermes/start.sh runtime API server key", () => { expect(run.result.status, run.result.stderr).toBe(0); expect(run.envFileContent).toContain( - "SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN\n", + "SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-v222_SLACK_BOT_TOKEN\n", ); expect(run.envFileContent).toContain( "SLACK_APP_TOKEN=xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN\n", diff --git a/test/nemoclaw-start-runtime-env-alias.test.ts b/test/nemoclaw-start-runtime-env-alias.test.ts index eb960438f85..07e71cece99 100644 --- a/test/nemoclaw-start-runtime-env-alias.test.ts +++ b/test/nemoclaw-start-runtime-env-alias.test.ts @@ -104,7 +104,9 @@ describe("messaging runtime env aliases", () => { timeout: 5000, }); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(result.stdout).toContain( + "SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-v42_SLACK_BOT_TOKEN", + ); expect(result.stderr).toContain("[channels] normalized Slack alias"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 1e5d8b92bf6..4297146d1ac 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2857,13 +2857,13 @@ describe("provider placeholder refresh (#4251)", () => { expect(run.result.status, run.result.stderr).toBe(0); expect(run.result.stderr).not.toContain("slack.default"); - // The Bolt-compatible alias is never rewritten on disk; it does not match - // the canonical "openshell:resolve:env:SLACK_BOT_TOKEN" placeholder key. + // Keep the OpenShell credential revision while retaining the SDK-required + // xoxb/xapp token shape. expect(run.config.channels.slack.accounts.default.botToken).toBe( - "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + "xoxb-OPENSHELL-RESOLVE-ENV-v42_SLACK_BOT_TOKEN", ); expect(run.config.channels.slack.accounts.default.appToken).toBe( - "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + "xapp-OPENSHELL-RESOLVE-ENV-v42_SLACK_APP_TOKEN", ); }); From eeb5f1f692c95c718637af33ff09adacb33d576f Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 00:26:26 +0700 Subject: [PATCH 07/19] fix(e2e): refresh Hermes validator integrity pin Signed-off-by: San Dang --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 35f78abd726..e21f44cd4d7 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -704,7 +704,7 @@ RUN node --experimental-strip-types \ ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 -ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=b355d1365fb1d15475e327f312ceb854ae96f9ebed28cf96bc8817f550df2688 +ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=4121dfcc56cff35278795ce8482fd892480d0e179f4221f391f9935816db0623 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0 # hadolint ignore=DL4006 From a562ca859b3a53da34d1b8b285c9265938b2f888 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 01:32:31 +0700 Subject: [PATCH 08/19] fix(e2e): align credential lifecycle checks Signed-off-by: San Dang --- .../channels/discord/policy/openclaw.yaml | 6 +++ .../channels/telegram/policy/hermes.yaml | 2 + .../channels/telegram/policy/openclaw.yaml | 2 + .../initial-policy-real-policy.test.ts | 6 ++- .../machine/handlers/sandbox-messaging.ts | 28 +++++++++++- src/lib/onboard/messaging-prep.ts | 5 ++- test/e2e/live/messaging-providers.test.ts | 9 ++-- ...shell-credential-generation-window.test.ts | 44 +++++++------------ .../openshell-credential-generation-window.ts | 6 +-- ...shell-credential-generation-window.test.ts | 2 +- 10 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/lib/messaging/channels/discord/policy/openclaw.yaml b/src/lib/messaging/channels/discord/policy/openclaw.yaml index c5950ea663f..caf5f4e861d 100644 --- a/src/lib/messaging/channels/discord/policy/openclaw.yaml +++ b/src/lib/messaging/channels/discord/policy/openclaw.yaml @@ -13,6 +13,8 @@ network_policies: port: 443 protocol: rest enforcement: enforce + credential_binding: + provider: "{sandboxName}-discord-bridge" rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -32,6 +34,8 @@ network_policies: protocol: websocket enforcement: enforce websocket_credential_rewrite: true + credential_binding: + provider: "{sandboxName}-discord-bridge" rules: - allow: { method: GET, path: "/**" } - allow: { method: WEBSOCKET_TEXT, path: "/**" } @@ -40,6 +44,8 @@ network_policies: protocol: websocket enforcement: enforce websocket_credential_rewrite: true + credential_binding: + provider: "{sandboxName}-discord-bridge" rules: - allow: { method: GET, path: "/**" } - allow: { method: WEBSOCKET_TEXT, path: "/**" } diff --git a/src/lib/messaging/channels/telegram/policy/hermes.yaml b/src/lib/messaging/channels/telegram/policy/hermes.yaml index 823d6ea4a1e..0ddae3e1655 100644 --- a/src/lib/messaging/channels/telegram/policy/hermes.yaml +++ b/src/lib/messaging/channels/telegram/policy/hermes.yaml @@ -13,6 +13,8 @@ network_policies: port: 443 protocol: rest enforcement: enforce + credential_binding: + provider: "{sandboxName}-telegram-bridge" rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } diff --git a/src/lib/messaging/channels/telegram/policy/openclaw.yaml b/src/lib/messaging/channels/telegram/policy/openclaw.yaml index 55a2fdfb0e3..510fd25f6da 100644 --- a/src/lib/messaging/channels/telegram/policy/openclaw.yaml +++ b/src/lib/messaging/channels/telegram/policy/openclaw.yaml @@ -13,6 +13,8 @@ network_policies: port: 443 protocol: rest enforcement: enforce + credential_binding: + provider: "{sandboxName}-telegram-bridge" rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index 7c0921fe381..b02b2e0469f 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -310,7 +310,11 @@ describe("initial sandbox policy real preset merge", () => { const prepared = prepareInitialSandboxCreatePolicy( repoPath("nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), [], - { agentName: "openclaw", additionalPresets: ["discord"] }, + { + agentName: "openclaw", + sandboxName: "oc-discord", + additionalPresets: ["discord"], + }, ); const policy = readPreparedPolicy(prepared); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index d179382d723..7fdb01de216 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -224,10 +224,30 @@ function selectionFromReusablePlan( }; } +function hasReusableLegacyHermesDiscordProvider( + selection: SandboxMessagingSelection, + providerMatches: SandboxMessagingDeps["providerMatchesGatewayCredential"] | undefined, +): boolean { + if (selection.plan?.agent !== "hermes" || !providerMatches) return false; + const bindings = selection.plan.credentialBindings.filter( + (binding) => binding.channelId === "discord", + ); + return ( + bindings.length > 0 && + bindings.every((binding) => + providerMatches(binding.providerName, "generic", binding.providerEnvKey), + ) + ); +} + function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, - deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, + deps: Pick< + SandboxMessagingDeps, + "clearPlanEnv" | "note" | "writePlanToEnv" + > & + Partial, "providerMatchesGatewayCredential">>, ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host // input. Rebuild the host-backed selection so policy reconciliation can @@ -240,6 +260,12 @@ function filterUnconfiguredHostChannelsFromSelection( agent as Parameters[2], ), ); + if ( + unconfiguredChannels.has("discord") && + hasReusableLegacyHermesDiscordProvider(selection, deps.providerMatchesGatewayCredential) + ) { + unconfiguredChannels.delete("discord"); + } if (unconfiguredChannels.size === 0) return selection; deps.note( ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index 2e52abe84cd..03a1e42e974 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -213,7 +213,10 @@ export function prepareCreateSandboxMessaging( // provider already holding that authority. if (token && !channelDisabled) continue; const providerReusable = providerType - ? input.providerMatchesGatewayCredential(name, providerType, envKey) + ? input.providerMatchesGatewayCredential(name, providerType, envKey) || + (input.agentName?.trim().toLowerCase() === "hermes" && + channel === "discord" && + input.providerMatchesGatewayCredential(name, "generic", envKey)) : requiresExactOpenClawProviderBinding ? input.providerMatchesGatewayCredential(name, "generic", envKey) : input.providerExistsInGateway(name); diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index d117fa5598a..a5ed5716040 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -458,14 +458,13 @@ process.exit(Array.isArray(channels) && channels.some((c) => c?.channelId === "w redactionValues, ); check( - extraA.startsWith("openshell:resolve:env:"), - "X4a: TELEGRAM_BOT_TOKEN_AGENT_A is canonical resolve placeholder", + extraA === "", + "X4a: unbound TELEGRAM_BOT_TOKEN_AGENT_A stays out of the child environment", ); check( - extraB.startsWith("openshell:resolve:env:"), - "X4b: TELEGRAM_BOT_TOKEN_AGENT_B is canonical resolve placeholder", + extraB === "", + "X4b: unbound TELEGRAM_BOT_TOKEN_AGENT_B stays out of the child environment", ); - check(extraA !== extraB, "X4b: extension keys resolve to distinct placeholders"); const startLog = await sandboxOutput( sandbox, diff --git a/test/e2e/live/openshell-credential-generation-window.test.ts b/test/e2e/live/openshell-credential-generation-window.test.ts index 5685ba66a0e..588d5d2e712 100644 --- a/test/e2e/live/openshell-credential-generation-window.test.ts +++ b/test/e2e/live/openshell-credential-generation-window.test.ts @@ -351,7 +351,7 @@ test("openshell-credential-generation-window", { "prove a retained credential generation expires", "rotate beyond the retained generation window", "prove key removal and provider teardown revoke access", - "restart the bridge and confirm old-process fallback", + "restart the bridge and confirm old-process revocation", "rebuild the sandbox and confirm credential reuse", "remove the MCP bridge and audit denied requests", ], @@ -371,10 +371,10 @@ test("openshell-credential-generation-window", { await artifacts.target.declare({ id: "openshell-credential-generation-window", contracts: [ - "OpenShell f27ff150 retained credential generations", + "OpenShell endpoint-bound credential generation lifecycle", "NemoClaw MCP detach, restart, and rebuild lifecycle", ], - sourceRevision: "3dee5570a46076a57a3b056f35f35ebc0861ac85", + sourceRevision: "0120535efc20953eca565773c9c77f8eb34db0b1", }); const compatibleMock = await startCompatibleMock({ @@ -802,7 +802,7 @@ test("openshell-credential-generation-window", { ).seen, ).toBe(false); - progress.phase("restart the bridge and confirm old-process fallback"); + progress.phase("restart the bridge and confirm old-process revocation"); await rotateCredential( host, fakeMcp, @@ -818,29 +818,17 @@ test("openshell-credential-generation-window", { expect(restartedRevision).not.toBe(restoredKeyRevision); await writeControl( sandbox, - CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart, - "credential-window-signal-fallback-after-restart", + CREDENTIAL_WINDOW_STEPS.deniedAfterRestart, + "credential-window-signal-denied-after-restart", ); - await waitForAcknowledgement(sandbox, CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart, "allowed"); - await expect - .poll( - () => - requestEvidence( - fakeMcp, - credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart), - restartSecret, - ), - { - interval: 500, - timeout: 30_000, - message: "old revision fallback after restart", - }, - ) - .toEqual({ - seen: true, - credentialRewritten: true, - placeholderAbsent: true, - }); + await waitForAcknowledgement(sandbox, CREDENTIAL_WINDOW_STEPS.deniedAfterRestart, "denied"); + expect( + requestEvidence( + fakeMcp, + credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.deniedAfterRestart), + restartSecret, + ).seen, + ).toBe(false); } finally { await writeControl( sandbox, @@ -868,8 +856,8 @@ test("openshell-credential-generation-window", { { step: CREDENTIAL_WINDOW_STEPS.deniedAfterKeyRemoval, outcome: "denied" }, { step: CREDENTIAL_WINDOW_STEPS.deniedAfterDetach, outcome: "denied" }, { - step: CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart, - outcome: "allowed", + step: CREDENTIAL_WINDOW_STEPS.deniedAfterRestart, + outcome: "denied", }, ], }); diff --git a/test/e2e/live/openshell-credential-generation-window.ts b/test/e2e/live/openshell-credential-generation-window.ts index 5d56d44993c..8cc789f116e 100644 --- a/test/e2e/live/openshell-credential-generation-window.ts +++ b/test/e2e/live/openshell-credential-generation-window.ts @@ -24,7 +24,7 @@ export const CREDENTIAL_WINDOW_STEPS = { fallbackAfterEviction: "fallback-after-eviction", deniedAfterKeyRemoval: "denied-after-key-removal", deniedAfterDetach: "denied-after-detach", - fallbackAfterRestart: "fallback-after-restart", + deniedAfterRestart: "denied-after-restart", stop: "stop", } as const; @@ -34,7 +34,7 @@ export type CredentialWindowRequestStep = | (typeof CREDENTIAL_WINDOW_STEPS)["fallbackAfterEviction"] | (typeof CREDENTIAL_WINDOW_STEPS)["deniedAfterKeyRemoval"] | (typeof CREDENTIAL_WINDOW_STEPS)["deniedAfterDetach"] - | (typeof CREDENTIAL_WINDOW_STEPS)["fallbackAfterRestart"]; + | (typeof CREDENTIAL_WINDOW_STEPS)["deniedAfterRestart"]; export function credentialWindowSecret(generation: number): string { return `${MCP_BRIDGE_TEST_CREDENTIALS.generationWindow}${String(generation).padStart(2, "0")}`; @@ -108,7 +108,7 @@ const requestSteps = new Set([ config.steps.fallbackAfterEviction, config.steps.deniedAfterKeyRemoval, config.steps.deniedAfterDetach, - config.steps.fallbackAfterRestart, + config.steps.deniedAfterRestart, ]); const seen = new Set(); const outcomes = []; diff --git a/test/openshell-credential-generation-window.test.ts b/test/openshell-credential-generation-window.test.ts index 849cbdddd75..58e2b4498ec 100644 --- a/test/openshell-credential-generation-window.test.ts +++ b/test/openshell-credential-generation-window.test.ts @@ -52,7 +52,7 @@ describe("OpenShell exact-main credential generation-window proof", () => { expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction)); expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.deniedAfterKeyRemoval)); expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.deniedAfterDetach)); - expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart)); + expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.deniedAfterRestart)); expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.stop)); expect(script).not.toContain(MCP_BRIDGE_TEST_CREDENTIALS.generationWindow); }); From 02eb1358e22acb27700b314563700bf800ee329b Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 24 Aug 2026 11:59:42 -0700 Subject: [PATCH 09/19] fix(security): enforce credential lifecycle boundaries Filter providers for disabled channels and validate warm-up executables before loading gateway credentials. Align revision-aware Slack and sandbox-scoped policy checks. Signed-off-by: Apurv Kumaria --- .../actions/sandbox/auto-pair-warmup.test.ts | 85 ++++++++++++++++--- src/lib/actions/sandbox/auto-pair-warmup.ts | 32 +++++-- .../policy-channel-remove-flow.test.ts | 2 + src/lib/onboard/sandbox-create-intent.ts | 8 +- .../sandbox-create-plan-materialization.ts | 8 +- src/lib/onboard/sandbox-create-plan.test.ts | 25 ++++-- .../onboard/sandbox-fresh-readiness.test.ts | 7 +- test/e2e/live/channels-stop-start-helpers.ts | 2 +- .../hermes-sandbox-secret-boundary.test.ts | 8 +- test/e2e/live/hermes-slack-e2e-helpers.ts | 13 ++- test/nemoclaw-start-slack-runtime.test.ts | 10 +-- .../cli/policy-dispatch.test.ts | 7 +- test/package-contract/repro-2010.test.ts | 22 ++++- 13 files changed, 183 insertions(+), 46 deletions(-) diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index 3a8f43567ca..f62af72cb3e 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -21,6 +21,14 @@ import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; const shAvailable = spawnSync("sh", ["-c", "exit 0"], { encoding: "utf-8" }).status === 0; const itWithSh = shAvailable ? it : it.skip; +function useWarmupExecutableFixture(script: string, executable: string): string { + const withFixture = script.replaceAll(WARMUP_OPENCLAW_BIN, executable); + return withFixture.replace( + `warmup_is_trusted_executable ${executable} || exit 0`, + `test -x ${executable} || exit 0`, + ); +} + // NOTE on coverage shape (#4504-v2): `runSandboxScopeWarmupRun` is not exercised // in-process here. Like its sibling `runSandboxAutoPairApprovalPass`, the leaf // lazily does a raw `require("../../adapters/openshell/runtime")` — a native @@ -50,7 +58,12 @@ describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => { describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", () => { it("keeps the real warm-up as one multiline command on the owning gateway (#10014)", () => { expect(WARMUP_SCRIPT).toContain("\n"); - expect(WARMUP_SCRIPT).toContain(`test -x ${WARMUP_OPENCLAW_BIN}`); + expect(WARMUP_SCRIPT).toContain( + `warmup_is_trusted_executable ${WARMUP_OPENCLAW_BIN} || exit 0`, + ); + expect(WARMUP_SCRIPT.indexOf("warmup_is_trusted_executable")).toBeLessThan( + WARMUP_SCRIPT.indexOf(buildTrustedProxyEnvSourceShell()), + ); expect(WARMUP_SCRIPT).not.toContain("command -v openclaw"); expect(WARMUP_SCRIPT).not.toContain("base64 -d"); expect(WARMUP_SCRIPT).not.toContain("mktemp"); @@ -195,7 +208,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( ); try { - const script = WARMUP_SCRIPT.replaceAll(WARMUP_OPENCLAW_BIN, path.join(binDir, "openclaw")); + const script = useWarmupExecutableFixture(WARMUP_SCRIPT, path.join(binDir, "openclaw")); const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { @@ -261,10 +274,13 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( ); try { - const script = WARMUP_SCRIPT.replace( - buildTrustedProxyEnvSourceShell(), - buildTrustedProxyEnvSourceShell(proxyEnv), - ).replaceAll(WARMUP_OPENCLAW_BIN, path.join(binDir, "openclaw")); + const script = useWarmupExecutableFixture( + WARMUP_SCRIPT.replace( + buildTrustedProxyEnvSourceShell(), + buildTrustedProxyEnvSourceShell(proxyEnv), + ), + path.join(binDir, "openclaw"), + ); const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { @@ -286,6 +302,50 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( } }); + itWithSh("rejects an untrusted warm-up executable before sourcing gateway credentials", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-trust-")); + const openClaw = path.join(fixtureRoot, "openclaw"); + const proxyEnv = path.join(fixtureRoot, "proxy-env.sh"); + const sourceLog = path.join(fixtureRoot, "source.log"); + const callLog = path.join(fixtureRoot, "call.log"); + fs.writeFileSync( + openClaw, + ["#!/bin/sh", 'printf called > "$NEMOCLAW_TEST_CALL_LOG"', ""].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + proxyEnv, + [ + "export OPENCLAW_GATEWAY_TOKEN=shared-token", + 'printf consumed > "$NEMOCLAW_TEST_PROXY_SOURCE_LOG"', + "", + ].join("\n"), + { mode: 0o444 }, + ); + + try { + const script = WARMUP_SCRIPT.replaceAll(WARMUP_OPENCLAW_BIN, openClaw).replace( + buildTrustedProxyEnvSourceShell(), + buildTrustedProxyEnvSourceShell(proxyEnv), + ); + const result = spawnSync("sh", ["-c", script], { + encoding: "utf-8", + env: { + ...process.env, + NEMOCLAW_TEST_CALL_LOG: callLog, + NEMOCLAW_TEST_PROXY_SOURCE_LOG: sourceLog, + }, + timeout: 10_000, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(sourceLog)).toBe(false); + expect(fs.existsSync(callLog)).toBe(false); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + itWithSh("does not pass gateway credentials to an OpenClaw program earlier in PATH", () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-path-")); const pathBinDir = path.join(fixtureRoot, "path-bin"); @@ -327,10 +387,13 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( ); try { - const script = WARMUP_SCRIPT.replace( - buildTrustedProxyEnvSourceShell(), - buildTrustedProxyEnvSourceShell(proxyEnv), - ).replaceAll(WARMUP_OPENCLAW_BIN, trustedOpenClaw); + const script = useWarmupExecutableFixture( + WARMUP_SCRIPT.replace( + buildTrustedProxyEnvSourceShell(), + buildTrustedProxyEnvSourceShell(proxyEnv), + ), + trustedOpenClaw, + ); const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { @@ -363,7 +426,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( "sh", [ "-c", - WARMUP_SCRIPT.replace( + useWarmupExecutableFixture(WARMUP_SCRIPT, "/usr/bin/true").replace( buildTrustedProxyEnvSourceShell(), buildTrustedProxyEnvSourceShell(unsafeProxy), ), diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 936b00020ee..65dd57d6b30 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -44,24 +44,44 @@ import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; export const WARMUP_TIMEOUT_MS = 30_000; export const WARMUP_PROBE_TIMEOUT_S = 5; export const WARMUP_OPENCLAW_BIN = "/usr/local/bin/openclaw"; +const WARMUP_PYTHON_BIN = "/usr/bin/python3"; +const WARMUP_DATE_BIN = "/bin/date"; // Best-effort in-sandbox request producer. Probe failures exit 0; rejecting an // unsafe trusted-proxy source can exit nonzero before the outer wrapper ignores // the result. Keep the trusted gateway credential for the initial request, // before a CLI device credential exists. OpenClaw 2026.7.1 can omit CLI // identity on loopback shared auth, so force device pairing only on this -// command. Finalization's canonical observer owns pairing-state polling. Use -// only root-owned executable paths after loading the gateway credential. +// command. Finalization's canonical observer owns pairing-state polling. +// Validate each executable before loading the gateway credential, then invoke +// only absolute root-owned paths. export const WARMUP_SCRIPT = ` +WARMUP_TRUSTED_OWNER_UID=0 +warmup_is_trusted_executable() { + candidate="$1" + test -f "$candidate" && test -x "$candidate" || return 1 + set -- $(/bin/ls -ldnL "$candidate" 2>/dev/null) || return 1 + case "\${1:-}" in + -*) ;; + *) return 1 ;; + esac + test "\${3:-}" = "$WARMUP_TRUSTED_OWNER_UID" || return 1 + # Root ownership protects the owner-write bit. Reject write access available + # to the sandbox group or any other user before credentials enter the shell. + case "$1" in + ?????w????*|????????w?*) return 1 ;; + esac +} +warmup_is_trusted_executable ${WARMUP_OPENCLAW_BIN} || exit 0 +warmup_is_trusted_executable ${WARMUP_PYTHON_BIN} || exit 0 +warmup_is_trusted_executable ${WARMUP_DATE_BIN} || exit 0 ${buildTrustedProxyEnvSourceShell()} -test -x ${WARMUP_OPENCLAW_BIN} || exit 0 -test -x /usr/bin/python3 || exit 0 unset OPENCLAW_GATEWAY_URL NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING \\ NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT || exit 0 -session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(/bin/date +%s)" +session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(${WARMUP_DATE_BIN} +%s)" params="$(printf '{"key":"%s","agentId":"main"}' "$session_key")" OPENCLAW_BIN="${WARMUP_OPENCLAW_BIN}" NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ - /usr/bin/python3 - "$params" <<'PYPROBE' + ${WARMUP_PYTHON_BIN} - "$params" <<'PYPROBE' import os import subprocess import sys diff --git a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts index 00bd2b0619e..3ca0a5623e3 100644 --- a/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts +++ b/src/lib/actions/sandbox/policy-channel-remove-flow.test.ts @@ -268,6 +268,8 @@ describe("policy channel remove/enable flows", () => { " port: 443", " protocol: rest", " enforcement: enforce", + " credential_binding:", + " provider: alpha-telegram-bridge", " rules:", " - allow: { method: GET, path: '/bot*/**' }", " - allow: { method: POST, path: '/bot*/**' }", diff --git a/src/lib/onboard/sandbox-create-intent.ts b/src/lib/onboard/sandbox-create-intent.ts index d7372fd70ba..b8cee94d266 100644 --- a/src/lib/onboard/sandbox-create-intent.ts +++ b/src/lib/onboard/sandbox-create-intent.ts @@ -37,11 +37,12 @@ function resolveTokenProviderChannelMap( return providerChannels; } -function filterMessagingProvidersByEnabledChannel( +export function filterMessagingProvidersByEnabledChannel( providerNames: string[], - providerChannels: ReadonlyMap, + requests: readonly SandboxCreateMessagingProviderRequest[], disabledChannelNames: ReadonlySet, ): string[] { + const providerChannels = resolveTokenProviderChannelMap(requests); return providerNames.filter((providerName) => { const channel = providerChannels.get(providerName); return !channel || !disabledChannelNames.has(channel); @@ -157,7 +158,6 @@ export function resolveSandboxCreateIntent({ messagingProviderRequests, disabledChannelNames, ); - const providerChannels = resolveTokenProviderChannelMap(messagingProviderRequests); const activeMessagingChannels = resolveActiveMessagingChannels({ channels, disabledChannelNames, @@ -168,7 +168,7 @@ export function resolveSandboxCreateIntent({ }); const enabledReusableMessagingProviders = filterMessagingProvidersByEnabledChannel( [...new Set(reusableMessagingProviders)], - providerChannels, + messagingProviderRequests, disabledChannelNames, ); diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index 31660a64a64..a9722764b0d 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -8,6 +8,7 @@ import type { SandboxCreateIntent, SandboxCreateMessagingProviderRequest, } from "./sandbox-create-intent-types"; +import { filterMessagingProvidersByEnabledChannel } from "./sandbox-create-intent"; import { containerPathsOverlap } from "./host-mount/path-overlap"; import { normalizeSandboxGpuDeviceForCdi } from "./sandbox-gpu-create"; import { prepareSandboxGpuRoutePolicies } from "./sandbox-gpu-route-policy"; @@ -244,13 +245,18 @@ export function materializeSandboxCreatePlan({ ]; runProviderPreDeleteCleanup(); + const enabledReusableMessagingProviders = filterMessagingProvidersByEnabledChannel( + [...intent.reusableMessagingProviders], + intent.messagingProviderRequests, + new Set(intent.disabledChannelNames), + ); const messagingProviders = [ ...new Set([ ...upsertMessagingProviders(enabledMessagingTokenDefs, { replaceExisting: true, allowedSandboxes: [intent.sandboxName], }), - ...intent.reusableMessagingProviders, + ...enabledReusableMessagingProviders, ]), ]; const createProviders = new Set(); diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index 44d4bba1eb5..9e690cd5f12 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -243,16 +243,21 @@ describe("resolveSandboxCreateIntent", () => { expect(JSON.stringify(first)).not.toContain("/tmp/"); }); - it("attaches a retained static provider while its channel runtime is stopped (#9773)", () => { - const intent = resolveSandboxCreateIntent({ + it("does not attach a retained provider while its channel runtime is stopped (#9773)", () => { + const resolvedIntent = resolveSandboxCreateIntent({ basePolicyPath: "/repo/hermes-policy.yaml", sandboxName: "sandbox", channels, enabledChannels: ["discord"], disabledChannelNames: new Set(["discord"]), - // Disabled credential definitions are not provider requests: onboard must - // not create or update their gateway providers during the rebuild. - messagingProviderRequests: [], + messagingProviderRequests: [ + { + name: "sandbox-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + credentialConfigured: false, + channel: "discord", + }, + ], primaryMessagingCredentialEnvKeys: ["DISCORD_BOT_TOKEN"], reusableMessagingChannels: [], reusableMessagingProviders: ["sandbox-discord-bridge"], @@ -265,6 +270,12 @@ describe("resolveSandboxCreateIntent", () => { agentName: "hermes", policyTier: "balanced", }); + const intent = { + ...resolvedIntent, + // Materialization must enforce the disabled-channel boundary even for a + // retained provider in a resumed or previously serialized intent. + reusableMessagingProviders: ["sandbox-discord-bridge"], + }; const upsertMessagingProviders = vi.fn(() => []); const plan = materializeSandboxCreatePlan({ @@ -284,8 +295,8 @@ describe("resolveSandboxCreateIntent", () => { replaceExisting: true, allowedSandboxes: ["sandbox"], }); - expect(plan.messagingProviders).toEqual(["sandbox-discord-bridge"]); - expect(plan.createArgs).toContain("sandbox-discord-bridge"); + expect(plan.messagingProviders).toEqual([]); + expect(plan.createArgs).not.toContain("sandbox-discord-bridge"); }); it("keeps the real gateway provider while excluding direct host-local inference policy", () => { diff --git a/src/lib/onboard/sandbox-fresh-readiness.test.ts b/src/lib/onboard/sandbox-fresh-readiness.test.ts index ad571847d61..a74a753ab07 100644 --- a/src/lib/onboard/sandbox-fresh-readiness.test.ts +++ b/src/lib/onboard/sandbox-fresh-readiness.test.ts @@ -100,7 +100,10 @@ describe("fresh sandbox executable readiness", () => { const deps = createDeps(); vi.mocked(deps.runOpenshell).mockImplementation( createSequencedOpenShellRunner([ - ["sandbox get alpha", [readySandboxGetResult(), readySandboxGetResult()]], + [ + "sandbox get alpha", + [readySandboxGetResult(), readySandboxGetResult(), readySandboxGetResult()], + ], [ "sandbox exec --name alpha -- true", [ @@ -123,7 +126,7 @@ describe("fresh sandbox executable readiness", () => { vi .mocked(deps.runOpenshell) .mock.calls.filter(([args]) => args.join(" ") === "sandbox exec --name alpha -- true"), - ).toHaveLength(2); + ).toHaveLength(3); expect(deps.runOpenshell).not.toHaveBeenCalledWith( ["sandbox", "delete", "alpha"], expect.anything(), diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts index b69e4f148f9..83a45107252 100644 --- a/test/e2e/live/channels-stop-start-helpers.ts +++ b/test/e2e/live/channels-stop-start-helpers.ts @@ -339,7 +339,7 @@ async function hermesChannelIsActive( wechat: 'grep -Eq "^WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN$" /sandbox/.hermes/.env', slack: - 'grep -Eq "^SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN$" /sandbox/.hermes/.env && grep -Eq "^SLACK_APP_TOKEN=xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN$" /sandbox/.hermes/.env', + 'grep -Eq "^SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-(v[0-9]{1,20}_)?SLACK_BOT_TOKEN$" /sandbox/.hermes/.env && grep -Eq "^SLACK_APP_TOKEN=xapp-OPENSHELL-RESOLVE-ENV-(v[0-9]{1,20}_)?SLACK_APP_TOKEN$" /sandbox/.hermes/.env', // The DM policy is derived from the mode and the allowlist rather than // supplied, so the live sealed .env is where that derivation is proven. whatsapp: diff --git a/test/e2e/live/hermes-sandbox-secret-boundary.test.ts b/test/e2e/live/hermes-sandbox-secret-boundary.test.ts index 037dff8735d..49ddad7787c 100644 --- a/test/e2e/live/hermes-sandbox-secret-boundary.test.ts +++ b/test/e2e/live/hermes-sandbox-secret-boundary.test.ts @@ -27,7 +27,9 @@ import sys from pathlib import Path secret_key_re = re.compile(r"(^|_)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)(_|$)") -slack_alias_re = re.compile(r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-[A-Z0-9_]+$") +slack_alias_re = re.compile( + r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-(?:v[0-9]{1,20}_)?[A-Z][A-Z0-9_]*$" +) allowed_nonsecret_keys = {"API_SERVER_HOST", "API_SERVER_PORT"} allowed_raw_secret_keys = set() allowed_literals = {"", "[STRIPPED_BY_MIGRATION]"} @@ -183,7 +185,9 @@ import sys from pathlib import Path secret_key_re = re.compile(r"(^|_)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)(_|$)") -slack_alias_re = re.compile(r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-[A-Z0-9_]+$") +slack_alias_re = re.compile( + r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-(?:v[0-9]{1,20}_)?[A-Z][A-Z0-9_]*$" +) allowed_nonsecret_keys = {"API_SERVER_HOST", "API_SERVER_PORT"} allowed_raw_secret_keys = {"API_SERVER_KEY"} allowed_literals = {"", "[STRIPPED_BY_MIGRATION]"} diff --git a/test/e2e/live/hermes-slack-e2e-helpers.ts b/test/e2e/live/hermes-slack-e2e-helpers.ts index 3738691adb0..fc22502cc40 100644 --- a/test/e2e/live/hermes-slack-e2e-helpers.ts +++ b/test/e2e/live/hermes-slack-e2e-helpers.ts @@ -459,15 +459,20 @@ PY`, sandbox, SANDBOX_NAME, String.raw`python3 - <<'PY' +import re from pathlib import Path text = Path("/sandbox/.hermes/.env").read_text(encoding="utf-8") lines = set(text.splitlines()) required = { - "SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - "SLACK_APP_TOKEN=xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", "API_SERVER_PORT=18642", } missing = sorted(required - lines) +for env_key, prefix in (("SLACK_BOT_TOKEN", "xoxb"), ("SLACK_APP_TOKEN", "xapp")): + pattern = re.compile( + rf"^{env_key}={prefix}-OPENSHELL-RESOLVE-ENV-(?:v[0-9]{{1,20}}_)?{env_key}$" + ) + if not any(pattern.fullmatch(line) for line in lines): + missing.append(env_key) if missing: print("FAIL missing " + ", ".join(missing)) else: @@ -491,7 +496,9 @@ import re from pathlib import Path secret_key_re = re.compile(r"(^|_)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API)(_|$)") -slack_alias_re = re.compile(r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-[A-Z0-9_]+$") +slack_alias_re = re.compile( + r"^(xoxb|xapp)-OPENSHELL-RESOLVE-ENV-(?:v[0-9]{1,20}_)?[A-Z][A-Z0-9_]*$" +) allowed_nonsecret_keys = {"API_SERVER_HOST", "API_SERVER_PORT"} allowed_raw_secret_keys = {"API_SERVER_KEY"} allowed_literals = {"", "[STRIPPED_BY_MIGRATION]"} diff --git a/test/nemoclaw-start-slack-runtime.test.ts b/test/nemoclaw-start-slack-runtime.test.ts index 702f587014a..313d141f37b 100644 --- a/test/nemoclaw-start-slack-runtime.test.ts +++ b/test/nemoclaw-start-slack-runtime.test.ts @@ -131,19 +131,19 @@ describe("Slack runtime env normalization (#4274)", () => { }); expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); - expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); + expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-v51_SLACK_BOT_TOKEN"); + expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-v51_SLACK_APP_TOKEN"); }); - it("does not leak the revision suffix into the normalized env or logs", () => { + it("keeps the credential revision in the alias without exposing it in logs", () => { const run = runNormalize({ SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SLACK_BOT_TOKEN", SLACK_APP_TOKEN: "openshell:resolve:env:v51_SLACK_APP_TOKEN", }); expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).not.toContain("v51_"); - expect(run.app).not.toContain("v51_"); + expect(run.bot).toContain("v51_SLACK_BOT_TOKEN"); + expect(run.app).toContain("v51_SLACK_APP_TOKEN"); expect(run.result.stderr).not.toContain("v51_"); expect(run.bot).not.toContain("openshell:resolve:env:"); expect(run.app).not.toContain("openshell:resolve:env:"); diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index f34baf4b514..94d1a809f76 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -34,12 +34,15 @@ describe("compiled CLI policy contracts", () => { const YAML = require(${YAML_PATH}); const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "openclaw-contract", agent: "openclaw", policies: [] }); registry.registerSandbox({ name: "hermes-contract", agent: "hermes", policies: [] }); -const openclaw = YAML.parse(policies.loadPreset("telegram")); +const openclaw = YAML.parse(policies.loadPresetForSandbox("openclaw-contract", "telegram")); const hermes = YAML.parse(policies.loadPresetForSandbox("hermes-contract", "telegram")); process.stdout.write("__RESULT__" + JSON.stringify({ openclawKeys: Object.keys(openclaw.network_policies || {}), hermesKeys: Object.keys(hermes.network_policies || {}), + openclawProvider: openclaw.network_policies?.telegram_bot?.endpoints?.[0]?.credential_binding?.provider, + hermesProvider: hermes.network_policies?.telegram?.endpoints?.[0]?.credential_binding?.provider, })); `; fs.writeFileSync(scriptPath, script); @@ -52,6 +55,8 @@ process.stdout.write("__RESULT__" + JSON.stringify({ const payload = JSON.parse(result.stdout.split("__RESULT__")[1].trim()); expect(payload.openclawKeys).toEqual(["telegram_bot"]); expect(payload.hermesKeys).toEqual(["telegram"]); + expect(payload.openclawProvider).toBe("openclaw-contract-telegram-bridge"); + expect(payload.hermesProvider).toBe("hermes-contract-telegram-bridge"); }); describe("policy-remove custom presets", () => { diff --git a/test/package-contract/repro-2010.test.ts b/test/package-contract/repro-2010.test.ts index a20c7153215..ef11d3e6a95 100644 --- a/test/package-contract/repro-2010.test.ts +++ b/test/package-contract/repro-2010.test.ts @@ -21,6 +21,7 @@ const POLICIES_PATH = path.join(REPO_ROOT, "dist", "lib", "policy", "index.js"); const RUNNER_PATH = path.join(REPO_ROOT, "dist", "lib", "runner.js"); const CLI_PATH = path.join(REPO_ROOT, "bin", "nemoclaw.js"); const REGISTRY_PATH = path.join(REPO_ROOT, "dist", "lib", "state", "registry.js"); +const POLICY_MATCH_SANDBOX = "policy-match"; /** * Run a CJS script in a subprocess and return stdout. @@ -29,8 +30,13 @@ const REGISTRY_PATH = path.join(REPO_ROOT, "dist", "lib", "state", "registry.js" function runScript(body: string): { stdout: string; stderr: string; status: number | null } { const preamble = ` const policies = require(${JSON.stringify(POLICIES_PATH)}); + const registry = require(${JSON.stringify(REGISTRY_PATH)}); const runner = require(${JSON.stringify(RUNNER_PATH)}); const YAML = require("yaml"); + registry.getSandbox = (name) => + name === ${JSON.stringify(POLICY_MATCH_SANDBOX)} + ? { name, agent: "openclaw", policies: [] } + : null; `; const result = spawnSync(process.execPath, ["-e", preamble + body], { cwd: REPO_ROOT, @@ -48,7 +54,10 @@ function buildGatewayYaml(presetNames: string[]): string { const { stdout } = runScript(` const parts = ["version: 1", "", "network_policies:"]; for (const name of ${names}) { - const content = policies.loadPreset(name); + const content = policies.loadPresetForSandbox( + ${JSON.stringify(POLICY_MATCH_SANDBOX)}, + name, + ); if (!content) continue; const entries = policies.extractPresetEntries(content); if (!entries) continue; @@ -72,7 +81,10 @@ function buildGatewayYamlWithCustom( const { stdout } = runScript(` const parts = ["version: 1", "", "network_policies:"]; for (const name of ${names}) { - const content = policies.loadPreset(name); + const content = policies.loadPresetForSandbox( + ${JSON.stringify(POLICY_MATCH_SANDBOX)}, + name, + ); if (!content) continue; const entries = policies.extractPresetEntries(content); if (!entries) continue; @@ -140,7 +152,11 @@ function callGetGatewayPresets( return pk.length > 0 && pk.every(k => keys.has(k)); }; for (const preset of policies.listPresets()) { - const c = policies.loadPreset(preset.name); if (!c) continue; + const c = policies.loadPresetForSandbox( + ${JSON.stringify(POLICY_MATCH_SANDBOX)}, + preset.name, + ); + if (!c) continue; if (matchContent(c)) matched.push(preset.name); } for (const entry of customPresets) { From a46e9ec4e322d2f96898196cef2d4ff6c28935ef Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 02:59:42 +0700 Subject: [PATCH 10/19] fix(e2e): migrate credential-bound provider state Signed-off-by: San Dang --- scripts/nemoclaw-start.sh | 3 +- .../machine/handlers/sandbox-messaging.ts | 28 +------------------ src/lib/onboard/messaging-prep.ts | 5 +--- test/e2e/live/rebuild-hermes.test.ts | 1 + 4 files changed, 5 insertions(+), 32 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 062c034a13b..f7e42904753 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1510,7 +1510,8 @@ refresh_openclaw_provider_placeholders() { local hash_file="/sandbox/.openclaw/.config-hash" [ -f "$config_file" ] || return 0 - if [ "$(openclaw_config_dir_owner "$(dirname "$config_file")")" = "root" ]; then + if [ "$(id -u)" -ne 0 ] \ + && [ "$(openclaw_config_dir_owner "$(dirname "$config_file")")" = "root" ]; then printf '[config] Shields are up; preserving sealed provider placeholders unchanged\n' >&2 return 0 fi diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 7fdb01de216..d179382d723 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -224,30 +224,10 @@ function selectionFromReusablePlan( }; } -function hasReusableLegacyHermesDiscordProvider( - selection: SandboxMessagingSelection, - providerMatches: SandboxMessagingDeps["providerMatchesGatewayCredential"] | undefined, -): boolean { - if (selection.plan?.agent !== "hermes" || !providerMatches) return false; - const bindings = selection.plan.credentialBindings.filter( - (binding) => binding.channelId === "discord", - ); - return ( - bindings.length > 0 && - bindings.every((binding) => - providerMatches(binding.providerName, "generic", binding.providerEnvKey), - ) - ); -} - function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, - deps: Pick< - SandboxMessagingDeps, - "clearPlanEnv" | "note" | "writePlanToEnv" - > & - Partial, "providerMatchesGatewayCredential">>, + 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 @@ -260,12 +240,6 @@ function filterUnconfiguredHostChannelsFromSelection( agent as Parameters[2], ), ); - if ( - unconfiguredChannels.has("discord") && - hasReusableLegacyHermesDiscordProvider(selection, deps.providerMatchesGatewayCredential) - ) { - unconfiguredChannels.delete("discord"); - } if (unconfiguredChannels.size === 0) return selection; deps.note( ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index 03a1e42e974..2e52abe84cd 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -213,10 +213,7 @@ export function prepareCreateSandboxMessaging( // provider already holding that authority. if (token && !channelDisabled) continue; const providerReusable = providerType - ? input.providerMatchesGatewayCredential(name, providerType, envKey) || - (input.agentName?.trim().toLowerCase() === "hermes" && - channel === "discord" && - input.providerMatchesGatewayCredential(name, "generic", envKey)) + ? input.providerMatchesGatewayCredential(name, providerType, envKey) : requiresExactOpenClawProviderBinding ? input.providerMatchesGatewayCredential(name, "generic", envKey) : input.providerExistsInGateway(name); diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 5beb2cb9642..c877a53047d 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -1108,6 +1108,7 @@ test(STALE_BASE_REBUILD await artifacts.writeJson("phase-5-inference-route-before-rebuild.json", routeBeforeRebuild); progress.phase("rebuild the Hermes sandbox"); const rebuildEnv = testEnv(undefined, { + DISCORD_BOT_TOKEN: DISCORD_FAKE_TOKEN, NEMOCLAW_REBUILD_VERBOSE: "1", ...baseReusePlan?.childEnv, }); From e6737b270b7a96a64eb0c48a7ad5200e929ad22e Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 03:33:44 +0700 Subject: [PATCH 11/19] fix(security): preserve sealed provider runtime config Signed-off-by: San Dang --- scripts/nemoclaw-start.sh | 52 ++++++++++++++++--- ...saging-providers-telegram-runtime-proof.ts | 4 +- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index f7e42904753..12ee0f7129a 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1509,11 +1509,18 @@ refresh_openclaw_provider_placeholders() { local config_file="/sandbox/.openclaw/openclaw.json" local hash_file="/sandbox/.openclaw/.config-hash" [ -f "$config_file" ] || return 0 + if [ -L "$config_file" ] || [ -L "$hash_file" ]; then + printf '[SECURITY] Refusing provider placeholder refresh — config or hash path is a symlink\n' >&2 + return 1 + fi - if [ "$(id -u)" -ne 0 ] \ - && [ "$(openclaw_config_dir_owner "$(dirname "$config_file")")" = "root" ]; then - printf '[config] Shields are up; preserving sealed provider placeholders unchanged\n' >&2 - return 0 + local sealed_config=0 + if [ "$(openclaw_config_dir_owner "$(dirname "$config_file")")" = "root" ]; then + if [ "$(id -u)" -ne 0 ]; then + printf '[config] Shields are up; preserving sealed provider placeholders unchanged\n' >&2 + return 0 + fi + sealed_config=1 fi local keys @@ -1677,9 +1684,37 @@ PYPLACEHOLDERKEYS "$_extras_accepted" "$_accepted_extra_keys" >&2 fi - if [ -L "$config_file" ] || [ -L "$hash_file" ]; then - printf '[SECURITY] Refusing provider placeholder refresh — config or hash path is a symlink\n' >&2 - return 1 + local runtime_config=0 + if [ "$sealed_config" -eq 1 ]; then + local key runtime_value needs_runtime_config=0 + for key in $keys; do + runtime_value="${!key-}" + if [[ "$runtime_value" = openshell:resolve:env:* ]] \ + && [ "$runtime_value" != "openshell:resolve:env:$key" ]; then + needs_runtime_config=1 + break + fi + done + [ "$needs_runtime_config" -eq 1 ] || return 0 + + # Keep the persistent seal unchanged. OpenClaw reads this root-owned, + # read-only copy through OPENCLAW_CONFIG_PATH for the current process. + local sealed_config_file="$config_file" + local runtime_dir="/run/nemoclaw/openclaw-provider-config" + if [ -L /run/nemoclaw ] || [ -L "$runtime_dir" ]; then + printf '[SECURITY] Refusing provider placeholder refresh — runtime config path is a symlink\n' >&2 + return 1 + fi + install -d -o root -g root -m 755 /run/nemoclaw "$runtime_dir" || return 1 + config_file="$runtime_dir/openclaw.json" + hash_file="$runtime_dir/.config-hash" + if [ -L "$config_file" ] || [ -L "$hash_file" ]; then + printf '[SECURITY] Refusing provider placeholder refresh — runtime config or hash path is a symlink\n' >&2 + return 1 + fi + emit_sandbox_sourced_file "$config_file" <"$sealed_config_file" || return 1 + export OPENCLAW_CONFIG_PATH="$config_file" + runtime_config=1 fi prepare_openclaw_config_for_write "$config_file" "$hash_file" @@ -1858,7 +1893,8 @@ PYPLACEHOLDERS local _refreshed_keys _refreshed_keys="$(printf '%s\n' "$_placeholder_report" | sed -n 's/^refreshed=//p' | tail -n 1)" if [ -n "$_refreshed_keys" ]; then - if (cd /sandbox/.openclaw && sha256sum openclaw.json >"$hash_file"); then + if [ "$runtime_config" -eq 1 ] \ + || (cd /sandbox/.openclaw && sha256sum openclaw.json >"$hash_file"); then printf '[config] Refreshed provider placeholders from OpenShell runtime env: %s\n' "$_refreshed_keys" >&2 else _write_rc=$? diff --git a/test/e2e/live/messaging-providers-telegram-runtime-proof.ts b/test/e2e/live/messaging-providers-telegram-runtime-proof.ts index 601209078ea..f1e3ef0e060 100644 --- a/test/e2e/live/messaging-providers-telegram-runtime-proof.ts +++ b/test/e2e/live/messaging-providers-telegram-runtime-proof.ts @@ -156,7 +156,9 @@ const { sendMessageTelegram } = await import(pathToFileURL(runtimeApiPath).href) if (typeof sendMessageTelegram !== "function") { throw new Error("installed Telegram runtime API does not export sendMessageTelegram"); } -const cfg = JSON.parse(fs.readFileSync("/sandbox/.openclaw/openclaw.json", "utf8")); +const cfg = JSON.parse( + fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json", "utf8"), +); const account = cfg.channels?.telegram?.accounts?.default; if (!account?.botToken) { throw new Error("missing channels.telegram.accounts.default.botToken in openclaw.json"); From 9a77bef543be59618c822e78a709b13946256016 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 05:02:21 +0700 Subject: [PATCH 12/19] fix(e2e): read runtime provider config in proof Signed-off-by: San Dang --- .../live/messaging-providers-telegram-runtime-proof.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/messaging-providers-telegram-runtime-proof.ts b/test/e2e/live/messaging-providers-telegram-runtime-proof.ts index f1e3ef0e060..a0bb3995489 100644 --- a/test/e2e/live/messaging-providers-telegram-runtime-proof.ts +++ b/test/e2e/live/messaging-providers-telegram-runtime-proof.ts @@ -156,9 +156,11 @@ const { sendMessageTelegram } = await import(pathToFileURL(runtimeApiPath).href) if (typeof sendMessageTelegram !== "function") { throw new Error("installed Telegram runtime API does not export sendMessageTelegram"); } -const cfg = JSON.parse( - fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json", "utf8"), -); +const runtimeConfigPath = "/run/nemoclaw/openclaw-provider-config/openclaw.json"; +const configPath = fs.existsSync(runtimeConfigPath) + ? runtimeConfigPath + : process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; +const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); const account = cfg.channels?.telegram?.accounts?.default; if (!account?.botToken) { throw new Error("missing channels.telegram.accounts.default.botToken in openclaw.json"); From 5542a686d3591f3d694afdc208a4b5ad05a2442e Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 05:47:17 +0700 Subject: [PATCH 13/19] fix(e2e): bind proof to exec credential revision Signed-off-by: San Dang --- .../e2e/live/messaging-providers-telegram-runtime-proof.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/messaging-providers-telegram-runtime-proof.ts b/test/e2e/live/messaging-providers-telegram-runtime-proof.ts index a0bb3995489..f00c6780616 100644 --- a/test/e2e/live/messaging-providers-telegram-runtime-proof.ts +++ b/test/e2e/live/messaging-providers-telegram-runtime-proof.ts @@ -165,9 +165,14 @@ const account = cfg.channels?.telegram?.accounts?.default; if (!account?.botToken) { throw new Error("missing channels.telegram.accounts.default.botToken in openclaw.json"); } +const runtimeToken = process.env.TELEGRAM_BOT_TOKEN ?? ""; +if (!/^openshell:resolve:env:v[0-9]+_TELEGRAM_BOT_TOKEN$/.test(runtimeToken)) { + throw new Error("missing revision-scoped TELEGRAM_BOT_TOKEN runtime placeholder"); +} +account.botToken = runtimeToken; const target = process.env.OPENCLAW_MESSAGE_TARGET || "42424242"; const text = process.env.OPENCLAW_MESSAGE_TEXT || "NemoClaw OpenClaw Telegram plugin mock E2E"; -const token = account.botToken; +const token = runtimeToken; const api = { sendMessage: (chatId, body, params = {}) => requestFakeTelegram( From 50e9af94db28e6021f8da06425e057a558dbc8c8 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 06:39:07 +0700 Subject: [PATCH 14/19] fix(e2e): bind fake Telegram provider endpoint Signed-off-by: San Dang --- test/e2e/live/messaging-providers.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index a5ed5716040..a255efea999 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -1016,6 +1016,13 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); redactionValues, }); await applyRestRewritePolicy(host, fakeTelegram, state.env, redactionValues); + await bindRestRewriteProvider( + host, + fakeTelegram, + `${SANDBOX_NAME}-telegram-bridge`, + state.env, + redactionValues, + ); const telegramMockTarget = "42424242"; const telegramMockText = "NemoClaw OpenClaw Telegram plugin mock E2E"; const installedTelegramProof = await runInstalledTelegramRuntimeProof( From 8a7e4e453ffb6d235ab25673ef713cf762e13e3d Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 06:58:42 +0700 Subject: [PATCH 15/19] fix(test): repair grouped integration checks Signed-off-by: San Dang --- scripts/find-source-shape-tests.mts | 2 +- .../langchain-deepagents-code-image.test.ts | 2 +- .../openclaw-2026-7-startup-compat.test.ts | 53 +++++++++---------- .../openclaw-lifecycle-policy.test.ts | 2 +- ...aw-security-revision-container-e2e.test.ts | 8 ++- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/scripts/find-source-shape-tests.mts b/scripts/find-source-shape-tests.mts index 19352e0a758..c4012df77c0 100755 --- a/scripts/find-source-shape-tests.mts +++ b/scripts/find-source-shape-tests.mts @@ -213,7 +213,7 @@ function hasDirectProductionPathHint(text: string): boolean { /["'`]\.\.\/["'`]\s*,\s*["'`](?:\.github|agents|bin|dist|nemoclaw|nemoclaw-blueprint|scripts|src|Dockerfile(?:\.base)?|install\.sh|package\.json)["'`]/.test( text, ) || - /["'`]\.\.["'`]\s*,\s*["'`](?:\.github|agents|bin|dist|nemoclaw|nemoclaw-blueprint|scripts|src|Dockerfile(?:\.base)?|install\.sh|package\.json)["'`]/.test( + /["'`](?:\.\.\/)*\.\.["'`]\s*,\s*["'`](?:\.github|agents|bin|dist|nemoclaw|nemoclaw-blueprint|scripts|src|Dockerfile(?:\.base)?|install\.sh|package\.json)["'`]/.test( text, ) || /join\(\s*["'`]\.\.["'`]\s*,\s*["'`](?:\.github|agents|bin|dist|nemoclaw|nemoclaw-blueprint|scripts|src|Dockerfile(?:\.base)?|install\.sh|package\.json)["'`]\s*\)/.test( diff --git a/test/agents/deepagents/langchain-deepagents-code-image.test.ts b/test/agents/deepagents/langchain-deepagents-code-image.test.ts index df57960dd71..2f18ec065f2 100644 --- a/test/agents/deepagents/langchain-deepagents-code-image.test.ts +++ b/test/agents/deepagents/langchain-deepagents-code-image.test.ts @@ -13,7 +13,7 @@ import YAML from "yaml"; import { loadAgent } from "../../../src/lib/agent/defs.ts"; import { prepareInitialSandboxCreatePolicy } from "../../../src/lib/onboard/initial-policy.ts"; import { TOKEN_PREFIX_PATTERNS } from "../../../src/lib/security/secret-patterns.ts"; -import { cloudExperimentalChecksForOnboarding } from "./e2e/live/cloud-experimental-check-list.ts"; +import { cloudExperimentalChecksForOnboarding } from "../../e2e/live/cloud-experimental-check-list.ts"; import { ANALYTICS_DISABLE_ENV_NAMES, DCODE_CANONICAL_PATH, diff --git a/test/agents/openclaw/openclaw-2026-7-startup-compat.test.ts b/test/agents/openclaw/openclaw-2026-7-startup-compat.test.ts index 2f8d09e11b3..ab60fed412f 100644 --- a/test/agents/openclaw/openclaw-2026-7-startup-compat.test.ts +++ b/test/agents/openclaw/openclaw-2026-7-startup-compat.test.ts @@ -8,7 +8,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { safeTmpHelpers } from "./nemoclaw-start-gateway.test-helpers"; +import { safeTmpHelpers } from "../../nemoclaw-start-gateway.test-helpers"; const ROOT = path.resolve(import.meta.dirname, "../../.."); const NORMALIZER = path.join(ROOT, "scripts", "lib", "normalize_mutable_config_perms.py"); @@ -116,32 +116,31 @@ describe("OpenClaw 2026.7 startup compatibility", () => { }, ); - it.each([ - "symlink", - "directory", - "hardlink", - ] as const)("rejects a %s update-check path", (kind) => { - const configDir = temporaryConfigDir(); - const statePath = path.join(configDir, "update-check.json"); - const target = path.join(path.dirname(configDir), "target.json"); - switch (kind) { - case "symlink": - fs.writeFileSync(target, ""); - fs.symlinkSync(target, statePath); - break; - case "directory": - fs.mkdirSync(statePath); - break; - case "hardlink": - fs.writeFileSync(target, "{}"); - fs.linkSync(target, statePath); - break; - } - - const result = repairUpdateCheck(configDir); - - expect(result.status).toBe(1); - }); + it.each(["symlink", "directory", "hardlink"] as const)( + "rejects a %s update-check path", + (kind) => { + const configDir = temporaryConfigDir(); + const statePath = path.join(configDir, "update-check.json"); + const target = path.join(path.dirname(configDir), "target.json"); + switch (kind) { + case "symlink": + fs.writeFileSync(target, ""); + fs.symlinkSync(target, statePath); + break; + case "directory": + fs.mkdirSync(statePath); + break; + case "hardlink": + fs.writeFileSync(target, "{}"); + fs.linkSync(target, statePath); + break; + } + + const result = repairUpdateCheck(configDir); + + expect(result.status).toBe(1); + }, + ); it("starts the root-mode gateway with the sandbox home", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-home-")); diff --git a/test/agents/openclaw/openclaw-lifecycle-policy.test.ts b/test/agents/openclaw/openclaw-lifecycle-policy.test.ts index ca9ff25df92..c0f2374628e 100644 --- a/test/agents/openclaw/openclaw-lifecycle-policy.test.ts +++ b/test/agents/openclaw/openclaw-lifecycle-policy.test.ts @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import policy from "../ci/reviewed-npm-lifecycle-allowlist.json"; +import policy from "../../../ci/reviewed-npm-lifecycle-allowlist.json"; import { reviewedOpenClawPluginIntegrityByPackageSpec } from "../../../src/lib/messaging/applier/build/messaging-build-applier.mts"; const REPO_ROOT = path.join(import.meta.dirname, "../../.."); diff --git a/test/agents/openclaw/openclaw-security-revision-container-e2e.test.ts b/test/agents/openclaw/openclaw-security-revision-container-e2e.test.ts index 93406a17945..999d3f53783 100644 --- a/test/agents/openclaw/openclaw-security-revision-container-e2e.test.ts +++ b/test/agents/openclaw/openclaw-security-revision-container-e2e.test.ts @@ -5,8 +5,12 @@ import { randomUUID } from "node:crypto"; import { describe } from "vitest"; -import { type DockerCommandResult, DockerProbe, resultText } from "./e2e/fixtures/docker-probe.ts"; -import { expect, test } from "./e2e/fixtures/e2e-test.ts"; +import { + type DockerCommandResult, + DockerProbe, + resultText, +} from "../../e2e/fixtures/docker-probe.ts"; +import { expect, test } from "../../e2e/fixtures/e2e-test.ts"; const TARGET_ID = "openclaw-security-revision-container-e2e"; const RUN_ENV = "NEMOCLAW_RUN_OPENCLAW_SECURITY_REVISION_CONTAINER_E2E"; From 5a8e381552863bed15dc7a7783edcf6f30929b2a Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 07:04:12 +0700 Subject: [PATCH 16/19] fix(test): remove grouped move artifacts Signed-off-by: San Dang --- ci/test-file-size-budget.json | 2 +- .../openclaw-2026.6.10-dependency-review.md | 4 -- .../hermes/hermes-final-image-layout.test.ts | 44 ++++++--------- ...nboard-managed-image-buildless-e2e.test.ts | 54 ++++++++++--------- 4 files changed, 47 insertions(+), 57 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index f0d6bd454ae..6eab87bc9ef 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -9,6 +9,6 @@ "test/installer-integration/install-preflight.test.ts": 3025, "test/nemoclaw-start.test.ts": 4671, "test/onboarding/onboard-messaging.test.ts": 2023, - "test/onboarding/onboard-selection.test.ts": 4177 + "test/onboarding/onboard-selection.test.ts": 4176 } } diff --git a/internal/security-reviews/openclaw-2026.6.10-dependency-review.md b/internal/security-reviews/openclaw-2026.6.10-dependency-review.md index 9f7c0323e5d..315b3a78729 100644 --- a/internal/security-reviews/openclaw-2026.6.10-dependency-review.md +++ b/internal/security-reviews/openclaw-2026.6.10-dependency-review.md @@ -409,11 +409,7 @@ No real Microsoft Teams tenant proof is included in this PR. The work remains tr Pull requests execute that WeChat audit action from the PR base SHA. If the PR base SHA does not contain the action, the pull request workflow fails. The production installer routes registry metadata lookup, archive packing, and installation through the disposable writable-cache boundary so retrieval cannot fall back to `HOME/.npm`; the trusted source cache remains read-only and the disposable copy is removed in the same image layer. -<<<<<<< HEAD - The stale nonterminal rebuild-resume repair in `src/lib/actions/sandbox/rebuild-resume-session.ts` remains a migration compatibility shim tracked against #4533's onboard FSM/resume compatibility boundary. Its removal condition is to delete it after a session-version migration proves recreate sessions are always persisted at a resumable pre-sandbox boundary; `src/lib/actions/sandbox/rebuild-resume-session.test.ts` covers the helper directly, `test/onboarding/onboard-resume-provider-recovery.test.ts` carries the onboard-suite producer-level regression for `machine.state='openclaw'`, and `src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts` owns the rebuild handoff regression. -======= -- The stale nonterminal rebuild-resume repair in `src/lib/actions/sandbox/rebuild-resume-session.ts` remains a migration compatibility shim tracked against #4533's onboard FSM/resume compatibility boundary. Its removal condition is to delete it after a session-version migration proves recreate sessions are always persisted at a resumable pre-sandbox boundary; `src/lib/actions/sandbox/rebuild-resume-session.test.ts` covers the helper directly, `test/onboard-resume-provider-recovery.test.ts` carries the onboard-suite producer-level regression for `machine.state='openclaw'`, and `src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts` owns the rebuild handoff regression. ->>>>>>> origin/main - Production OpenClaw image build paths call `scripts/check-production-build-args.sh` before production `docker build` or `docker/build-push-action` use. `test/agents/openclaw/openclaw-dependency-review.test.ts` keeps that workflow contract documented. - The rebuild-reasoning cases added by this PR live in the focused `rebuild-resume-reasoning.test.ts` file; the smaller route-provenance additions remain with their `rebuild-resume-config.ts` boundary tests. - `src/lib/state/sandbox.ts` is 100 lines smaller than current `main` in this PR. Managed-extension policy, restore exclusions, symlink predicates, and cleanup construction now live in `openclaw-managed-extensions.ts`; further decomposition of unrelated snapshot orchestration is outside this dependency bump. diff --git a/test/agents/hermes/hermes-final-image-layout.test.ts b/test/agents/hermes/hermes-final-image-layout.test.ts index d9d28c65885..ab549983f73 100644 --- a/test/agents/hermes/hermes-final-image-layout.test.ts +++ b/test/agents/hermes/hermes-final-image-layout.test.ts @@ -6,13 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -<<<<<<< HEAD -import { requireSingleReviewedDockerfileRunCommand } from "../../helpers/dockerfile-run-commands"; import { dockerRunCommandBetween, runDockerShell } from "../../helpers/dockerfile-run-shell"; -import { expectManagedBootstrapNativeImageContract } from "../../support/managed-bootstrap-image-contract"; -======= -import { dockerRunCommandBetween, runDockerShell } from "../../helpers/dockerfile-run-shell"; ->>>>>>> origin/main const ROOT = path.resolve(import.meta.dirname, "../../.."); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); @@ -170,8 +164,6 @@ function indexOfRequired(haystack: string, needle: string): number { return index; } - - function runFinalLayout({ legacyData = "none", openclaw = "none", @@ -212,7 +204,6 @@ function runFinalLayout({ } describe("Hermes final image layout", () => { - it("rejects retired OpenClaw state represented as a directory", () => { const run = runFinalLayout({ openclaw: "directory" }); try { @@ -255,22 +246,21 @@ describe("Hermes final image layout", () => { } }); - it.each([ - "directory-symlink", - "entry-symlink", - "nested-symlink", - ] as const)("refuses a legacy data %s before migration", (legacyData) => { - const run = runFinalLayout({ legacyData }); - try { - expect(run.result.status).toBe(1); - expect(run.result.stderr).toContain("refusing legacy layout cleanup"); - const sentinel = - legacyData === "directory-symlink" - ? path.join(run.legacyTarget, "sentinel") - : run.legacyTarget; - expect(readText(sentinel)).toBe("keep\n"); - } finally { - fs.rmSync(run.tmp, { recursive: true, force: true }); - } - }); + it.each(["directory-symlink", "entry-symlink", "nested-symlink"] as const)( + "refuses a legacy data %s before migration", + (legacyData) => { + const run = runFinalLayout({ legacyData }); + try { + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("refusing legacy layout cleanup"); + const sentinel = + legacyData === "directory-symlink" + ? path.join(run.legacyTarget, "sentinel") + : run.legacyTarget; + expect(readText(sentinel)).toBe("keep\n"); + } finally { + fs.rmSync(run.tmp, { recursive: true, force: true }); + } + }, + ); }); diff --git a/test/onboarding/onboard-managed-image-buildless-e2e.test.ts b/test/onboarding/onboard-managed-image-buildless-e2e.test.ts index d48729b7904..66a2a826969 100644 --- a/test/onboarding/onboard-managed-image-buildless-e2e.test.ts +++ b/test/onboarding/onboard-managed-image-buildless-e2e.test.ts @@ -7,34 +7,38 @@ import path from "node:path"; import { describe, expect } from "vitest"; -import { test } from "./e2e/fixtures/workflow-e2e-test.ts"; +import { test } from "../e2e/fixtures/workflow-e2e-test.ts"; import { runManagedImageBuildlessE2e } from "../helpers/managed-image-buildless-e2e"; describe("managed image buildless onboarding orchestration contract", () => { - test("renders every shipped agent's immutable launch without entering Dockerfile orchestration (#7744)", { - timeout: 240_000, - meta: { - e2ePhases: [ - "validate managed-image fail-closed documentation", - "validate mocked all-agent buildless orchestration boundaries", - "release managed onboarding fixtures", - ], + test( + "renders every shipped agent's immutable launch without entering Dockerfile orchestration (#7744)", + { + timeout: 240_000, + meta: { + e2ePhases: [ + "validate managed-image fail-closed documentation", + "validate mocked all-agent buildless orchestration boundaries", + "release managed onboarding fixtures", + ], + }, }, - }, ({ progress }) => { - progress.phase("validate managed-image fail-closed documentation"); - const commands = readFileSync( - path.join(import.meta.dirname, "../..", "docs", "reference", "commands.mdx"), - "utf8", - ); - expect(commands).toContain( - "If registry or catalog availability prevents resolution, the ordinary `prefer-managed` path builds the shipped, reviewed repository Dockerfile instead; it never selects an unpinned `:latest` image.", - ); - expect(commands).toContain( - "Available catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent fails closed before sandbox creation.", - ); + ({ progress }) => { + progress.phase("validate managed-image fail-closed documentation"); + const commands = readFileSync( + path.join(import.meta.dirname, "../..", "docs", "reference", "commands.mdx"), + "utf8", + ); + expect(commands).toContain( + "If registry or catalog availability prevents resolution, the ordinary `prefer-managed` path builds the shipped, reviewed repository Dockerfile instead; it never selects an unpinned `:latest` image.", + ); + expect(commands).toContain( + "Available catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent fails closed before sandbox creation.", + ); - progress.phase("validate mocked all-agent buildless orchestration boundaries"); - runManagedImageBuildlessE2e(); - progress.phase("release managed onboarding fixtures"); - }); + progress.phase("validate mocked all-agent buildless orchestration boundaries"); + runManagedImageBuildlessE2e(); + progress.phase("release managed onboarding fixtures"); + }, + ); }); From 2fcd211eb68e058dfea2d776d80e11c7ee02a5a8 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 07:12:37 +0700 Subject: [PATCH 17/19] fix(hermes): refresh wrapper integrity pin Signed-off-by: San Dang --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index e21f44cd4d7..115fceea698 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -701,7 +701,7 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py,cron-restore-control.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=4db45043f45d8296dd39228315b721ee19b0a4e0591579ec0ceeec2777bbb40d ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=4121dfcc56cff35278795ce8482fd892480d0e179f4221f391f9935816db0623 From 39e60f76c868ff1ec518aaa2029926e7509a7032 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 07:22:05 +0700 Subject: [PATCH 18/19] fix(hermes): refresh profile patch integrity pin Signed-off-by: San Dang --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 115fceea698..c849990fbc1 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -909,7 +909,7 @@ RUN install -o root -g root -m 0444 \ # Fresh named profiles do not receive config.yaml. Patch the pinned Hermes # fallback readers from the generated manifest, then validate a real profile. -ARG NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256=7468555c7596b3b95732fb98aec6152537778d8519a4409c3da8aa6a76c9a3f7 +ARG NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256=424336d2ee3a12b4fb979ed84401ef105bf9c70e36dc3aa27a70f2a46b46def9 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py \ From c9b54eb81a07378c13a6ad2cdf49f2493d287d76 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 25 Aug 2026 07:26:04 +0700 Subject: [PATCH 19/19] fix(test): repair grouped runtime imports Signed-off-by: San Dang --- ...start-extra-placeholder-breadcrumb.test.ts | 2 +- .../nemoclaw-start-gateway-health.test.ts | 244 +++++++++--------- .../nemoclaw-start-gateway-token-env.test.ts | 129 ++++----- .../gateway/gateway-serving-watchdog.test.ts | 2 +- .../messaging-build-applier-integrity.test.ts | 10 +- .../messaging/messaging-build-applier.test.ts | 56 ++-- .../sandbox-download-upload-cli.test.ts | 2 +- .../sandbox-sessions-admin-agent-cli.test.ts | 2 +- .../sandbox-sessions-export-cli.test.ts | 2 +- 9 files changed, 225 insertions(+), 224 deletions(-) diff --git a/test/agents/openclaw/runtime/nemoclaw-start-extra-placeholder-breadcrumb.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-extra-placeholder-breadcrumb.test.ts index 84180ffd2e9..60b57676db5 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-extra-placeholder-breadcrumb.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-extra-placeholder-breadcrumb.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { placeholderPlan, runRefresh, -} from "./nemoclaw-start-extra-placeholder-breadcrumb-helpers.ts"; +} from "../../../nemoclaw-start-extra-placeholder-breadcrumb-helpers.ts"; // The extra-placeholder canonicalization + accepted-keys breadcrumb contract is // asserted end-to-end only in the live messaging-providers E2E (cases X4a/X4b diff --git a/test/agents/openclaw/runtime/nemoclaw-start-gateway-health.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-gateway-health.test.ts index 8e8ff533767..6c52d1ec0c2 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-gateway-health.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-gateway-health.test.ts @@ -22,7 +22,7 @@ import { START_SCRIPT, safeTmpHelpers, writeProcStatFunction, -} from "./nemoclaw-start-gateway.test-helpers"; +} from "../../../nemoclaw-start-gateway.test-helpers"; function gatewayMarkerFunction(src: string, name: string, markerPath: string): string { return extractShellFunction(src, name).replaceAll("/tmp/nemoclaw-gateway-local", markerPath); @@ -662,25 +662,25 @@ describe("gateway launch wiring (#4710)", () => { }; } - it.each([ - "non-root", - "root", - ] as const)("%s launch clears the marker on supervisor exit after recording the gateway PID", (kind) => { - const run = runLaunchWiring(kind); - expect(run.result.status, `script failed: ${run.result.stderr}`).toBe(0); - expect(run.markerPresent).toBe(true); - // The supervisor EXIT trap clears the in-container marker when this fixture - // exits, returning healthchecks to the marker-absent branch (#4952). - expect(run.markerExists).toBe(false); - // The watchdog reads the gateway PID from the pidfile each cycle. - expect(run.gatewayPid).toBeDefined(); - expect(run.pidFileContent?.split(" ")[0]).toBe(run.gatewayPid); - // The watchdog runs and is registered for SIGTERM cleanup. - expect(run.watchdogPid).toBeDefined(); - expect(run.stdout).toContain("WATCHDOG_ALIVE=1"); - expect(run.childPids).toContain(run.watchdogPid); - expect(run.childPids).toContain(run.gatewayPid); - }); + it.each(["non-root", "root"] as const)( + "%s launch clears the marker on supervisor exit after recording the gateway PID", + (kind) => { + const run = runLaunchWiring(kind); + expect(run.result.status, `script failed: ${run.result.stderr}`).toBe(0); + expect(run.markerPresent).toBe(true); + // The supervisor EXIT trap clears the in-container marker when this fixture + // exits, returning healthchecks to the marker-absent branch (#4952). + expect(run.markerExists).toBe(false); + // The watchdog reads the gateway PID from the pidfile each cycle. + expect(run.gatewayPid).toBeDefined(); + expect(run.pidFileContent?.split(" ")[0]).toBe(run.gatewayPid); + // The watchdog runs and is registered for SIGTERM cleanup. + expect(run.watchdogPid).toBeDefined(); + expect(run.stdout).toContain("WATCHDOG_ALIVE=1"); + expect(run.childPids).toContain(run.watchdogPid); + expect(run.childPids).toContain(run.gatewayPid); + }, + ); }); // The respawn loop reassigns GATEWAY_PID when it relaunches a dead gateway; @@ -699,110 +699,110 @@ describe("respawn loop pidfile refresh (#4710)", () => { return src.slice(start, end + endToken.length); } - it.each([ - "non-root", - "root", - ] as const)("%s respawn records the relaunched gateway PID in the pidfile", (kind) => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-respawn-${kind}-`)); - const fakeBin = path.join(tmpDir, "bin"); - const openclawLog = path.join(tmpDir, "openclaw.log"); - const gatewayLog = path.join(tmpDir, "gateway.log"); - const pidFile = path.join(tmpDir, "gateway.pid"); - const initialPidFile = path.join(tmpDir, "initial.pid"); - const restoreSentinel = path.join(tmpDir, "runtime-guards-restored"); - const scriptPath = path.join(tmpDir, "run.sh"); - fs.mkdirSync(fakeBin); - fs.writeFileSync( - path.join(fakeBin, "openclaw"), - `#!/usr/bin/env bash\n[ -f ${JSON.stringify(restoreSentinel)} ] || exit 97\nprintf '%s\\n' "$*" >> ${JSON.stringify(openclawLog)}\nexec sleep 30\n`, - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(fakeBin, "setpriv"), - `#!/usr/bin/env bash\nwhile [ "$1" != "--" ]; do shift; done\nshift\nexec "$@"\n`, - { - mode: 0o755, - }, - ); - fs.writeFileSync(gatewayLog, "gateway booting\n"); + it.each(["non-root", "root"] as const)( + "%s respawn records the relaunched gateway PID in the pidfile", + (kind) => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-respawn-${kind}-`)); + const fakeBin = path.join(tmpDir, "bin"); + const openclawLog = path.join(tmpDir, "openclaw.log"); + const gatewayLog = path.join(tmpDir, "gateway.log"); + const pidFile = path.join(tmpDir, "gateway.pid"); + const initialPidFile = path.join(tmpDir, "initial.pid"); + const restoreSentinel = path.join(tmpDir, "runtime-guards-restored"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "openclaw"), + `#!/usr/bin/env bash\n[ -f ${JSON.stringify(restoreSentinel)} ] || exit 97\nprintf '%s\\n' "$*" >> ${JSON.stringify(openclawLog)}\nexec sleep 30\n`, + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(fakeBin, "setpriv"), + `#!/usr/bin/env bash\nwhile [ "$1" != "--" ]; do shift; done\nshift\nexec "$@"\n`, + { + mode: 0o755, + }, + ); + fs.writeFileSync(gatewayLog, "gateway booting\n"); - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -o pipefail", - `export PATH=${JSON.stringify(`${fakeBin}:${process.env.PATH || ""}`)}`, - `OPENCLAW=${JSON.stringify(path.join(fakeBin, "openclaw"))}`, - '_DASHBOARD_PORT="19000"', - `GATEWAY_PID_FILE=${JSON.stringify(pidFile)}`, - "STEP_DOWN_PREFIX_GATEWAY=(setpriv --reuid=gateway --regid=gateway --init-groups --)", - `prepare_openclaw_automatic_respawn() { printf restored >${JSON.stringify(restoreSentinel)}; }`, - // The loop sleeps 2s between respawns; keep the test fast. - "sleep() { command sleep 0.05; }", - safeTmpHelpers(src), - extractShellFunction(src, "record_gateway_pid"), - extractShellFunction(src, "clear_gateway_pid_record"), - rootGatewayLifecycleFunctions(src, gatewayLog), - kind === "root" ? "mark_in_container_gateway() { :; }" : "", - kind === "root" ? "GATEWAY_CONTROL_SIGNAL_PENDING=0" : "", - kind === "root" ? "handle_openclaw_gateway_control_request() { :; }" : "", - kind === "root" - ? 'openclaw_supervised_pid_is_live() { local current; gateway_control_pid_is_live "$1" || return 1; current="$(openclaw_pid_start_identity "$1")" || return 1; [ "$current" = "$2" ]; }' - : "", - kind === "root" ? "gateway_pid_is_openclaw_gateway() { return 0; }" : "", - "SANDBOX_CHILD_PIDS=()", - "SANDBOX_WAIT_PID=", - "(", - // A gateway that dies immediately with a non-zero status drives - // exactly one respawn iteration. - ' bash -c "sleep 0.1; exit 7" &', - " GATEWAY_PID=$!", - ' GATEWAY_PID_START_IDENTITY="$(openclaw_pid_start_identity "$GATEWAY_PID")"', - ' record_gateway_pid "$GATEWAY_PID" "$GATEWAY_PID_START_IDENTITY"', - ` printf '%s' "$GATEWAY_PID" > ${JSON.stringify(initialPidFile)}`, - respawnLoop(src, kind).replaceAll("/tmp/gateway.log", gatewayLog), - ") &", - "LOOP_PID=$!", - 'INITIAL=""; CURRENT=""', - "for _ in $(command seq 1 200); do", - ` INITIAL="$(cat ${JSON.stringify(initialPidFile)} 2>/dev/null || true)"`, - ` CURRENT="$(awk '{ print $1 }' ${JSON.stringify(pidFile)} 2>/dev/null || true)"`, - ' if [ -n "$INITIAL" ] && [ -n "$CURRENT" ] && [ "$CURRENT" != "$INITIAL" ]; then break; fi', - " command sleep 0.05", - "done", - // The pidfile is refreshed at spawn time; give the respawned stub a - // moment to actually execute and write its argv log before cleanup. - `for _ in $(command seq 1 100); do [ -s ${JSON.stringify(openclawLog)} ] && break; command sleep 0.05; done`, - 'printf "INITIAL=%s\\n" "$INITIAL"', - 'printf "CURRENT=%s\\n" "$CURRENT"', - 'if [ -n "$CURRENT" ] && kill -0 "$CURRENT" 2>/dev/null; then printf "RESPAWNED_ALIVE=1\\n"; fi', - "disown -a 2>/dev/null || true", - // Kill the loop before its gateway so it cannot respawn again. - 'kill -9 "$LOOP_PID" 2>/dev/null || true', - 'pkill -P "$LOOP_PID" 2>/dev/null || true', - '[ -n "$CURRENT" ] && kill -9 "$CURRENT" 2>/dev/null || true', - "exit 0", - ].join("\n"), - { mode: 0o700 }, - ); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -o pipefail", + `export PATH=${JSON.stringify(`${fakeBin}:${process.env.PATH || ""}`)}`, + `OPENCLAW=${JSON.stringify(path.join(fakeBin, "openclaw"))}`, + '_DASHBOARD_PORT="19000"', + `GATEWAY_PID_FILE=${JSON.stringify(pidFile)}`, + "STEP_DOWN_PREFIX_GATEWAY=(setpriv --reuid=gateway --regid=gateway --init-groups --)", + `prepare_openclaw_automatic_respawn() { printf restored >${JSON.stringify(restoreSentinel)}; }`, + // The loop sleeps 2s between respawns; keep the test fast. + "sleep() { command sleep 0.05; }", + safeTmpHelpers(src), + extractShellFunction(src, "record_gateway_pid"), + extractShellFunction(src, "clear_gateway_pid_record"), + rootGatewayLifecycleFunctions(src, gatewayLog), + kind === "root" ? "mark_in_container_gateway() { :; }" : "", + kind === "root" ? "GATEWAY_CONTROL_SIGNAL_PENDING=0" : "", + kind === "root" ? "handle_openclaw_gateway_control_request() { :; }" : "", + kind === "root" + ? 'openclaw_supervised_pid_is_live() { local current; gateway_control_pid_is_live "$1" || return 1; current="$(openclaw_pid_start_identity "$1")" || return 1; [ "$current" = "$2" ]; }' + : "", + kind === "root" ? "gateway_pid_is_openclaw_gateway() { return 0; }" : "", + "SANDBOX_CHILD_PIDS=()", + "SANDBOX_WAIT_PID=", + "(", + // A gateway that dies immediately with a non-zero status drives + // exactly one respawn iteration. + ' bash -c "sleep 0.1; exit 7" &', + " GATEWAY_PID=$!", + ' GATEWAY_PID_START_IDENTITY="$(openclaw_pid_start_identity "$GATEWAY_PID")"', + ' record_gateway_pid "$GATEWAY_PID" "$GATEWAY_PID_START_IDENTITY"', + ` printf '%s' "$GATEWAY_PID" > ${JSON.stringify(initialPidFile)}`, + respawnLoop(src, kind).replaceAll("/tmp/gateway.log", gatewayLog), + ") &", + "LOOP_PID=$!", + 'INITIAL=""; CURRENT=""', + "for _ in $(command seq 1 200); do", + ` INITIAL="$(cat ${JSON.stringify(initialPidFile)} 2>/dev/null || true)"`, + ` CURRENT="$(awk '{ print $1 }' ${JSON.stringify(pidFile)} 2>/dev/null || true)"`, + ' if [ -n "$INITIAL" ] && [ -n "$CURRENT" ] && [ "$CURRENT" != "$INITIAL" ]; then break; fi', + " command sleep 0.05", + "done", + // The pidfile is refreshed at spawn time; give the respawned stub a + // moment to actually execute and write its argv log before cleanup. + `for _ in $(command seq 1 100); do [ -s ${JSON.stringify(openclawLog)} ] && break; command sleep 0.05; done`, + 'printf "INITIAL=%s\\n" "$INITIAL"', + 'printf "CURRENT=%s\\n" "$CURRENT"', + 'if [ -n "$CURRENT" ] && kill -0 "$CURRENT" 2>/dev/null; then printf "RESPAWNED_ALIVE=1\\n"; fi', + "disown -a 2>/dev/null || true", + // Kill the loop before its gateway so it cannot respawn again. + 'kill -9 "$LOOP_PID" 2>/dev/null || true', + 'pkill -P "$LOOP_PID" 2>/dev/null || true', + '[ -n "$CURRENT" ] && kill -9 "$CURRENT" 2>/dev/null || true', + "exit 0", + ].join("\n"), + { mode: 0o700 }, + ); - try { - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 20_000 }); - const stdout = typeof result.stdout === "string" ? result.stdout : ""; - expect(result.status, `script failed: ${result.stderr}`).toBe(0); - const initial = stdout.match(/^INITIAL=(\d+)$/m)?.[1]; - const current = stdout.match(/^CURRENT=(\d+)$/m)?.[1]; - expect(initial, `no initial pid in: ${stdout}`).toBeDefined(); - expect(current, `no current pid in: ${stdout}`).toBeDefined(); - expect(current).not.toBe(initial); - expect(stdout).toContain("RESPAWNED_ALIVE=1"); - expect(fs.readFileSync(restoreSentinel, "utf-8")).toBe("restored"); - expect(fs.readFileSync(openclawLog, "utf-8")).toContain("gateway run --port 19000"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); + try { + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 20_000 }); + const stdout = typeof result.stdout === "string" ? result.stdout : ""; + expect(result.status, `script failed: ${result.stderr}`).toBe(0); + const initial = stdout.match(/^INITIAL=(\d+)$/m)?.[1]; + const current = stdout.match(/^CURRENT=(\d+)$/m)?.[1]; + expect(initial, `no initial pid in: ${stdout}`).toBeDefined(); + expect(current, `no current pid in: ${stdout}`).toBeDefined(); + expect(current).not.toBe(initial); + expect(stdout).toContain("RESPAWNED_ALIVE=1"); + expect(fs.readFileSync(restoreSentinel, "utf-8")).toBe("restored"); + expect(fs.readFileSync(openclawLog, "utf-8")).toContain("gateway run --port 19000"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); it("services a supervisor request that interrupts root respawn backoff before relaunch", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); diff --git a/test/agents/openclaw/runtime/nemoclaw-start-gateway-token-env.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-gateway-token-env.test.ts index 4fdd7f91ac5..82e2b7f6afa 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-gateway-token-env.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-gateway-token-env.test.ts @@ -8,85 +8,31 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { safeTmpHelpers } from "./nemoclaw-start-gateway.test-helpers"; +import { safeTmpHelpers } from "../../../nemoclaw-start-gateway.test-helpers"; import { extractShellFunctionFromSource } from "../../../support/shell-function-extractor"; const START_SCRIPT = path.resolve(import.meta.dirname, "../../../../scripts/nemoclaw-start.sh"); describe("OpenClaw gateway credential environment", () => { - it.each([ - "truncate", - "append", - ])("removes OPENCLAW_GATEWAY_TOKEN from the gateway environment without passing its value in argv when the log mode is %s (#8693)", (logMode) => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-token-env-")); - const gatewayLog = path.join(tmpDir, "gateway.log"); - const seed = "existing gateway output\n"; - const source = fs.readFileSync(START_SCRIPT, "utf8"); - const launch = extractShellFunctionFromSource( - source, - "launch_openclaw_gateway_process", - ).replaceAll("/tmp/gateway.log", gatewayLog); - fs.writeFileSync(gatewayLog, seed); - const script = [ - "set -euo pipefail", - safeTmpHelpers(source), - launch, - "export OPENCLAW_GATEWAY_TOKEN=gateway-secret", - `launch_openclaw_gateway_process ${logMode} current sh -c 'printf "ENV=%s\\nARGS=%s\\n" "\${OPENCLAW_GATEWAY_TOKEN-unset}" "$*"' sh`, - 'wait "$GATEWAY_PID"', - ].join("\n"); - - try { - const result = spawnSync("bash", ["-c", script], { - encoding: "utf8", - timeout: 5000, - }); - expect(result.status, result.stderr).toBe(0); - const expectedOutput = "ENV=unset\nARGS=\n"; - expect(fs.readFileSync(gatewayLog, "utf8")).toBe( - logMode === "append" ? `${seed}${expectedOutput}` : expectedOutput, - ); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - describe.skipIf(!fs.existsSync("/proc/self/cmdline"))("Linux process inspection", () => { - it.each([ - { logMode: "truncate", launchPath: "initial launch" }, - { logMode: "append", launchPath: "automatic respawn" }, - ])("keeps the gateway token out of process cmdline and environ during $launchPath (#8693)", ({ - logMode, - }) => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-token-proc-")); + it.each(["truncate", "append"])( + "removes OPENCLAW_GATEWAY_TOKEN from the gateway environment without passing its value in argv when the log mode is %s (#8693)", + (logMode) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-token-env-")); const gatewayLog = path.join(tmpDir, "gateway.log"); + const seed = "existing gateway output\n"; const source = fs.readFileSync(START_SCRIPT, "utf8"); const launch = extractShellFunctionFromSource( source, "launch_openclaw_gateway_process", ).replaceAll("/tmp/gateway.log", gatewayLog); - fs.writeFileSync(gatewayLog, ""); + fs.writeFileSync(gatewayLog, seed); const script = [ "set -euo pipefail", safeTmpHelpers(source), launch, "export OPENCLAW_GATEWAY_TOKEN=gateway-secret", - `launch_openclaw_gateway_process ${logMode} current node -e 'setTimeout(() => {}, 30000)' nemoclaw-proc-credential-probe`, - 'trap \'kill "$GATEWAY_PID" 2>/dev/null || true; wait "$GATEWAY_PID" 2>/dev/null || true\' EXIT', - "ready=0", - "for _ in $(seq 1 100); do", - ' [ -r "/proc/$GATEWAY_PID/cmdline" ] || { sleep 0.01; continue; }', - " process_cmdline=\"$(tr '\\0' '\\n' < \"/proc/$GATEWAY_PID/cmdline\")\"", - ' case "$process_cmdline" in *nemoclaw-proc-credential-probe*) ready=1; break ;; esac', - " sleep 0.01", - "done", - '[ "$ready" -eq 1 ] || { echo "gateway process did not become inspectable" >&2; exit 30; }', - "process_environment=\"$(tr '\\0' '\\n' < \"/proc/$GATEWAY_PID/environ\")\"", - 'case "$process_cmdline" in *"$OPENCLAW_GATEWAY_TOKEN"*) exit 31 ;; esac', - 'case "$process_environment" in *"OPENCLAW_GATEWAY_TOKEN=$OPENCLAW_GATEWAY_TOKEN"*) exit 32 ;; esac', - 'kill "$GATEWAY_PID"', - 'wait "$GATEWAY_PID" 2>/dev/null || true', - "trap - EXIT", + `launch_openclaw_gateway_process ${logMode} current sh -c 'printf "ENV=%s\\nARGS=%s\\n" "\${OPENCLAW_GATEWAY_TOKEN-unset}" "$*"' sh`, + 'wait "$GATEWAY_PID"', ].join("\n"); try { @@ -95,10 +41,65 @@ describe("OpenClaw gateway credential environment", () => { timeout: 5000, }); expect(result.status, result.stderr).toBe(0); + const expectedOutput = "ENV=unset\nARGS=\n"; + expect(fs.readFileSync(gatewayLog, "utf8")).toBe( + logMode === "append" ? `${seed}${expectedOutput}` : expectedOutput, + ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } - }); + }, + ); + + describe.skipIf(!fs.existsSync("/proc/self/cmdline"))("Linux process inspection", () => { + it.each([ + { logMode: "truncate", launchPath: "initial launch" }, + { logMode: "append", launchPath: "automatic respawn" }, + ])( + "keeps the gateway token out of process cmdline and environ during $launchPath (#8693)", + ({ logMode }) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-token-proc-")); + const gatewayLog = path.join(tmpDir, "gateway.log"); + const source = fs.readFileSync(START_SCRIPT, "utf8"); + const launch = extractShellFunctionFromSource( + source, + "launch_openclaw_gateway_process", + ).replaceAll("/tmp/gateway.log", gatewayLog); + fs.writeFileSync(gatewayLog, ""); + const script = [ + "set -euo pipefail", + safeTmpHelpers(source), + launch, + "export OPENCLAW_GATEWAY_TOKEN=gateway-secret", + `launch_openclaw_gateway_process ${logMode} current node -e 'setTimeout(() => {}, 30000)' nemoclaw-proc-credential-probe`, + 'trap \'kill "$GATEWAY_PID" 2>/dev/null || true; wait "$GATEWAY_PID" 2>/dev/null || true\' EXIT', + "ready=0", + "for _ in $(seq 1 100); do", + ' [ -r "/proc/$GATEWAY_PID/cmdline" ] || { sleep 0.01; continue; }', + " process_cmdline=\"$(tr '\\0' '\\n' < \"/proc/$GATEWAY_PID/cmdline\")\"", + ' case "$process_cmdline" in *nemoclaw-proc-credential-probe*) ready=1; break ;; esac', + " sleep 0.01", + "done", + '[ "$ready" -eq 1 ] || { echo "gateway process did not become inspectable" >&2; exit 30; }', + "process_environment=\"$(tr '\\0' '\\n' < \"/proc/$GATEWAY_PID/environ\")\"", + 'case "$process_cmdline" in *"$OPENCLAW_GATEWAY_TOKEN"*) exit 31 ;; esac', + 'case "$process_environment" in *"OPENCLAW_GATEWAY_TOKEN=$OPENCLAW_GATEWAY_TOKEN"*) exit 32 ;; esac', + 'kill "$GATEWAY_PID"', + 'wait "$GATEWAY_PID" 2>/dev/null || true', + "trap - EXIT", + ].join("\n"); + + try { + const result = spawnSync("bash", ["-c", script], { + encoding: "utf8", + timeout: 5000, + }); + expect(result.status, result.stderr).toBe(0); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); }); it("rejects an unknown gateway log mode before launch (#8693)", () => { diff --git a/test/runtime/gateway/gateway-serving-watchdog.test.ts b/test/runtime/gateway/gateway-serving-watchdog.test.ts index 9bcfc1dba9a..d702d9e8b4d 100644 --- a/test/runtime/gateway/gateway-serving-watchdog.test.ts +++ b/test/runtime/gateway/gateway-serving-watchdog.test.ts @@ -27,7 +27,7 @@ import { START_SCRIPT, safeTmpHelpers, writeProcStatFunction, -} from "./nemoclaw-start-gateway.test-helpers"; +} from "../../nemoclaw-start-gateway.test-helpers"; function watchdogFunctions(gatewayLog: string): string { const src = fs.readFileSync(START_SCRIPT, "utf-8"); diff --git a/test/runtime/messaging/messaging-build-applier-integrity.test.ts b/test/runtime/messaging/messaging-build-applier-integrity.test.ts index cd19a58c66a..4a03b8413c1 100644 --- a/test/runtime/messaging/messaging-build-applier-integrity.test.ts +++ b/test/runtime/messaging/messaging-build-applier-integrity.test.ts @@ -14,7 +14,7 @@ import { reviewedOpenClawPluginTarballUrlByPackageSpec, } from "../../../src/lib/messaging/applier/build/messaging-build-applier.mts"; import { testTimeout } from "../../helpers/timeouts"; -import { withLegacyMessagingPlanEnvDirect } from "./messaging-plan-test-helper"; +import { withLegacyMessagingPlanEnvDirect } from "../../messaging-plan-test-helper"; vi.mock("../../../scripts/lib/openclaw-npm-remediation.mts", async (importOriginal) => { const original = @@ -90,9 +90,11 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-applier-boundary-")); const messagingRoot = path.join(root, "src", "lib", "messaging"); try { - [...dockerfile.matchAll( - /^COPY (src\/lib\/messaging\/|scripts\/lib\/(?:openclaw-npm-remediation|reviewed-npm-archive)\.mts) (\/\S+)$/gm, - )].forEach((copy) => { + [ + ...dockerfile.matchAll( + /^COPY (src\/lib\/messaging\/|scripts\/lib\/(?:openclaw-npm-remediation|reviewed-npm-archive)\.mts) (\/\S+)$/gm, + ), + ].forEach((copy) => { const source = copy[1] ?? ""; const destination = copy[2] ?? ""; const sourcePath = path.join(REPO_ROOT, source); diff --git a/test/runtime/messaging/messaging-build-applier.test.ts b/test/runtime/messaging/messaging-build-applier.test.ts index 0034ee0a042..81645deaebe 100644 --- a/test/runtime/messaging/messaging-build-applier.test.ts +++ b/test/runtime/messaging/messaging-build-applier.test.ts @@ -15,7 +15,7 @@ import { readMessagingBuildPlanFromEnv, } from "../../../src/lib/messaging/applier/build/messaging-build-applier.mts"; import { execTimeout, testTimeout } from "../../helpers/timeouts"; -import { withLegacyMessagingPlanEnvDirect } from "./messaging-plan-test-helper"; +import { withLegacyMessagingPlanEnvDirect } from "../../messaging-plan-test-helper"; const { remediateReviewedArchive } = vi.hoisted(() => ({ remediateReviewedArchive: vi.fn(({ archivePath }: { archivePath: string }) => ({ @@ -880,35 +880,33 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); - it.each( + it.each([ [ - [ - "@openclaw/discord@2026.7.1", - "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz", - "discord-2026.7.1.tgz", - ], - [ - "@tencent-weixin/openclaw-weixin@2.4.3", - "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", - "openclaw-weixin-2.4.3.tgz", - ], - [ - "@openclaw/slack@2026.7.1", - "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz", - "slack-2026.7.1.tgz", - ], - [ - "@openclaw/whatsapp@2026.7.1", - "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz", - "whatsapp-2026.7.1.tgz", - ], - [ - "@openclaw/msteams@2026.7.1", - "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz", - "msteams-2026.7.1.tgz", - ], - ] as const, - )( + "@openclaw/discord@2026.7.1", + "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz", + "discord-2026.7.1.tgz", + ], + [ + "@tencent-weixin/openclaw-weixin@2.4.3", + "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", + "openclaw-weixin-2.4.3.tgz", + ], + [ + "@openclaw/slack@2026.7.1", + "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz", + "slack-2026.7.1.tgz", + ], + [ + "@openclaw/whatsapp@2026.7.1", + "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz", + "whatsapp-2026.7.1.tgz", + ], + [ + "@openclaw/msteams@2026.7.1", + "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz", + "msteams-2026.7.1.tgz", + ], + ] as const)( "runs pinned installs during agent-install without doctor env injection [case %#]", async (packageSpec, tarballUrl, archiveName) => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-message-plugins-")); diff --git a/test/runtime/sandbox/sandbox-download-upload-cli.test.ts b/test/runtime/sandbox/sandbox-download-upload-cli.test.ts index f8cced6e0e0..a2515dd2e6f 100644 --- a/test/runtime/sandbox/sandbox-download-upload-cli.test.ts +++ b/test/runtime/sandbox/sandbox-download-upload-cli.test.ts @@ -6,7 +6,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runWithEnv, writeSandboxRegistry } from "./cli/helpers"; +import { runWithEnv, writeSandboxRegistry } from "../../cli/helpers"; function buildStubOpenshell(home: string, logFile: string): string { const localBin = path.join(home, "bin"); diff --git a/test/runtime/sandbox/sandbox-sessions-admin-agent-cli.test.ts b/test/runtime/sandbox/sandbox-sessions-admin-agent-cli.test.ts index 70adc9ff2b3..57600b27197 100644 --- a/test/runtime/sandbox/sandbox-sessions-admin-agent-cli.test.ts +++ b/test/runtime/sandbox/sandbox-sessions-admin-agent-cli.test.ts @@ -6,7 +6,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runWithEnv, writeSandboxRegistry } from "./cli/helpers"; +import { runWithEnv, writeSandboxRegistry } from "../../cli/helpers"; function buildStubOpenshell(home: string, logFile: string, nativeDeleteExit = 0): string { const localBin = path.join(home, "bin"); diff --git a/test/runtime/sandbox/sandbox-sessions-export-cli.test.ts b/test/runtime/sandbox/sandbox-sessions-export-cli.test.ts index f6f1edf47ee..85ee938a318 100644 --- a/test/runtime/sandbox/sandbox-sessions-export-cli.test.ts +++ b/test/runtime/sandbox/sandbox-sessions-export-cli.test.ts @@ -6,7 +6,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runWithEnv, writeSandboxRegistry } from "./cli/helpers"; +import { runWithEnv, writeSandboxRegistry } from "../../cli/helpers"; function buildStubOpenshell( home: string,