diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index eae7b7ddf6e..6f449f50505 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -207,7 +207,7 @@ PY # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. ARG NEMOCLAW_HERMES_WRAPPER_SHA256=ec8b0e4d6254175929d5a02240acd6af25e94774a30b36ba5c6f5a69c32fb9d7 -ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=d7371a84099b204346e0bfecfacb0086fdab690e5fadcf5e12417b6038927d19 +ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=3d9c099bfd06912fa0a4688e113555654e0952224c4226ad5f8c68b59c34895b ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index d444e1e10ae..42cb99151f9 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -246,6 +246,11 @@ "test": "requires classifier review and integrity evidence when the OpenClaw build pin changes", "category": "security" }, + { + "file": "test/hermes-final-image-layout.test.ts", + "test": "keeps security entrypoint hashes synchronized with the copied files", + "category": "security" + }, { "file": "test/hermes-runtime-config-guard-topology.test.ts", "test": "restores exact locked posture after root-separated repair and later failure (#7033)", diff --git a/test/e2e/live/issue-6194-tui-expect.ts b/test/e2e/live/issue-6194-tui-expect.ts index 64391044583..f9729f73ade 100644 --- a/test/e2e/live/issue-6194-tui-expect.ts +++ b/test/e2e/live/issue-6194-tui-expect.ts @@ -177,7 +177,9 @@ send -i $termSpawn -- "\\t" after 200 expect_exact_or_exit $termSpawn $sandbox openshell_sandbox_listed 66 67 send -i $termSpawn -- "\\r" -expect_exact_or_exit $termSpawn {Name:} openshell_sandbox_detail 68 69 +# Ratatui may update the "Name:" label as separate terminal diffs. Wait for a +# stable detail-panel heading, then still bind the view to the exact sandbox. +expect_exact_or_exit $termSpawn {Filesystem Access} openshell_sandbox_detail 68 69 expect_exact_or_exit $termSpawn $sandbox openshell_sandbox_detail_name 70 71 # OpenShell documents 'r' as the Network Rules focus key in sandbox detail. send -i $termSpawn -- "r" diff --git a/test/e2e/live/mcp-bridge-onboard-env.ts b/test/e2e/live/mcp-bridge-onboard-env.ts new file mode 100644 index 00000000000..1ef3ef1e9ad --- /dev/null +++ b/test/e2e/live/mcp-bridge-onboard-env.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; + +const EXACT_MAIN_OVERLAY_KEYS = new Set([ + "PATH", + "NEMOCLAW_OPENSHELL_BIN", + "NEMOCLAW_OPENSHELL_GATEWAY_BIN", + "NEMOCLAW_OPENSHELL_SANDBOX_BIN", +]); + +export function buildMcpBridgeOnboardEnv(options: { + agent: "openclaw" | "hermes" | "langchain-deepagents-code"; + baseEnv?: NodeJS.ProcessEnv; + compatibleKey: string; + compatibleModel: string; + endpointUrl: string; + envOverlay?: NodeJS.ProcessEnv; + sandboxName: string; +}): NodeJS.ProcessEnv { + const envOverlay = options.envOverlay ?? {}; + for (const key of Object.keys(envOverlay)) { + if (!EXACT_MAIN_OVERLAY_KEYS.has(key)) { + throw new Error(`MCP exact-main onboarding does not allow env overlay key '${key}'`); + } + } + + return { + ...buildAvailabilityProbeEnv(options.baseEnv), + COMPATIBLE_API_KEY: options.compatibleKey, + NVIDIA_INFERENCE_API_KEY: options.compatibleKey, + NEMOCLAW_AGENT: options.agent, + NEMOCLAW_ENDPOINT_URL: options.endpointUrl, + NEMOCLAW_MODEL: options.compatibleModel, + NEMOCLAW_COMPAT_MODEL: options.compatibleModel, + NEMOCLAW_PREFERRED_API: "openai-completions", + NEMOCLAW_PROVIDER: "custom", + NEMOCLAW_SANDBOX_NAME: options.sandboxName, + NEMOCLAW_RECREATE_SANDBOX: "1", + ...envOverlay, + }; +} diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 7e241b387f0..2a217dc77a8 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -28,6 +28,7 @@ import { assertHermesInspectionRejectsUnmanagedFields, assertHermesRemovalSurvivesGatewayRestart, } from "./mcp-bridge-hermes-lifecycle.ts"; +import { buildMcpBridgeOnboardEnv } from "./mcp-bridge-onboard-env.ts"; import { retryAfterHermesRestartTransportFailure } from "./mcp-bridge-reliability.ts"; import { buildMcpDnsRebindingProbeScript, @@ -117,7 +118,12 @@ async function onboardAgent( host: HostCliClient, cleanup: CleanupRegistry, endpointUrl: string, - options: { agent: McpAgent; sandboxName: string; artifactName: string }, + options: { + agent: McpAgent; + sandboxName: string; + artifactName: string; + envOverlay?: NodeJS.ProcessEnv; + }, ): Promise { cleanup.trackSandbox(host, options.sandboxName, { artifactName: "cleanup-destroy-sandbox", @@ -131,19 +137,14 @@ async function onboardAgent( ["onboard", "--non-interactive", "--yes", "--yes-i-accept-third-party-software"], { artifactName: options.artifactName, - env: { - ...buildAvailabilityProbeEnv(), - COMPATIBLE_API_KEY: COMPATIBLE_KEY, - NVIDIA_INFERENCE_API_KEY: COMPATIBLE_KEY, - NEMOCLAW_AGENT: options.agent, - NEMOCLAW_ENDPOINT_URL: endpointUrl, - NEMOCLAW_MODEL: COMPATIBLE_MODEL, - NEMOCLAW_COMPAT_MODEL: COMPATIBLE_MODEL, - NEMOCLAW_PREFERRED_API: "openai-completions", - NEMOCLAW_PROVIDER: "custom", - NEMOCLAW_SANDBOX_NAME: options.sandboxName, - NEMOCLAW_RECREATE_SANDBOX: "1", - }, + env: buildMcpBridgeOnboardEnv({ + agent: options.agent, + compatibleKey: COMPATIBLE_KEY, + compatibleModel: COMPATIBLE_MODEL, + endpointUrl, + envOverlay: options.envOverlay, + sandboxName: options.sandboxName, + }), redactionValues: [COMPATIBLE_KEY], timeoutMs: 20 * 60_000, }, @@ -1374,6 +1375,7 @@ mcpBridgeShardTest("deepagents")( agent: "langchain-deepagents-code", sandboxName: DEEPAGENTS_SANDBOX_NAME, artifactName: "onboard-deepagents-mcp-bridge", + envOverlay: exactMainProof.envOverlay, }); await exactMainProof.afterOnboard(); cleanup.add("remove Deep Agents MCP bridge", () => diff --git a/test/e2e/live/openshell-exact-main-mcp-proof.ts b/test/e2e/live/openshell-exact-main-mcp-proof.ts index 2ae3c125a87..f134c0f0098 100644 --- a/test/e2e/live/openshell-exact-main-mcp-proof.ts +++ b/test/e2e/live/openshell-exact-main-mcp-proof.ts @@ -27,6 +27,7 @@ export function prepareExactMainMcpProof( const { artifacts, cleanup, host, sandbox } = fixture; const driverConfig = prepareExactMainDriverConfigProof(fixture, sandboxName); return { + envOverlay: driverConfig.envOverlay, async afterOnboard(): Promise { await driverConfig.assertAfterOnboard(); await assertExactMainOpenShellContracts(host, sandboxName); diff --git a/test/e2e/live/openshell-gateway-upgrade-helpers.ts b/test/e2e/live/openshell-gateway-upgrade-helpers.ts index afd76362105..2a48c0aa660 100644 --- a/test/e2e/live/openshell-gateway-upgrade-helpers.ts +++ b/test/e2e/live/openshell-gateway-upgrade-helpers.ts @@ -57,6 +57,18 @@ export function currentGatewayUpgradeInstallerArgs( return options.interactive ? [installer] : [installer, ...NON_INTERACTIVE_INSTALLER_ARGS]; } +export function expectedLegacyRegistryVersion(nemoclawRef: string): string | undefined { + switch (nemoclawRef) { + case "v0.0.36": + case "v0.0.55": + return undefined; + case "v0.0.74": + return "0.0.74"; + default: + throw new Error(`Unsupported gateway-upgrade registry fixture: ${nemoclawRef}`); + } +} + export function upgradeGatewayStateCleanupScript(pidFile: string): string { return `set -e volume_prefix=${GATEWAY_VOLUME_PREFIX} diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 2871916f5a3..7b5abcbea1f 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -34,6 +34,7 @@ import { REPO_ROOT } from "../fixtures/paths.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { currentGatewayUpgradeInstallerArgs, + expectedLegacyRegistryVersion, oldGatewayUpgradeInstallerArgs, upgradeGatewayCleanupScript, upgradeGatewayStateCleanupScript, @@ -111,7 +112,7 @@ function withoutEnvKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): NodeJS return Object.fromEntries(Object.entries(env).filter(([key]) => !excluded.has(key))); } -function shellLoginPrefix(hideUserLocalOpenShell = false): string { +function shellLoginPrefix(hiddenOpenShellDir?: string): string { const lines = [ "set -euo pipefail", 'if [ -f "$HOME/.bashrc" ]; then', @@ -125,11 +126,12 @@ function shellLoginPrefix(hideUserLocalOpenShell = false): string { "fi", ]; lines.push( - ...(hideUserLocalOpenShell + ...(hiddenOpenShellDir ? [ '_path_without_user_local=""', "while IFS= read -r _path_entry; do", ' [ "$_path_entry" = "$HOME/.local/bin" ] && continue', + ` [ "$_path_entry" = ${shellQuote(hiddenOpenShellDir)} ] && continue`, ' _path_without_user_local="${_path_without_user_local:+${_path_without_user_local}:}${_path_entry}"', 'done < <(tr ":" "\\n" <<<"$PATH")', 'export PATH="$_path_without_user_local"', @@ -166,13 +168,13 @@ async function bash( env?: NodeJS.ProcessEnv; timeoutMs?: number; cwd?: string; - hideUserLocalOpenShell?: boolean; + hiddenOpenShellDir?: string; redactionValues?: string[]; }, ): Promise { return host.command( "bash", - ["-lc", `${shellLoginPrefix(options.hideUserLocalOpenShell)}\n${script}`], + ["-lc", `${shellLoginPrefix(options.hiddenOpenShellDir)}\n${script}`], { cwd: options.cwd ?? REPO_ROOT, artifactName: options.artifactName, @@ -392,7 +394,7 @@ async function runInstallerPayload( logFile: string, env: NodeJS.ProcessEnv, redactionValues: string[] = [], - options: { hideUserLocalOpenShell?: boolean; interactiveInput?: string } = {}, + options: { hiddenOpenShellDir?: string; interactiveInput?: string } = {}, ): Promise { const quotedInstallerArgs = installerArgs.map(shellQuote).join(" "); const installerCommand = `bash ${quotedInstallerArgs} >${shellQuote(logFile)} 2>&1`; @@ -401,7 +403,7 @@ async function runInstallerPayload( const installerInvocation = options.interactiveInput ? `printf '%s\\n' ${shellQuote(options.interactiveInput)} | script --quiet --return --command ${shellQuote(installerCommand)} /dev/null` : installerCommand; - const hiddenOpenShellPreflight = options.hideUserLocalOpenShell + const hiddenOpenShellPreflight = options.hiddenOpenShellDir ? [ 'test -x "$HOME/.local/bin/openshell"', "if command -v openshell >/dev/null 2>&1; then", @@ -418,7 +420,7 @@ ${installerInvocation}`, { artifactName: `${label.replace(/[^a-z0-9_.-]+/gi, "-")}-installer`, env, - hideUserLocalOpenShell: options.hideUserLocalOpenShell, + hiddenOpenShellDir: options.hiddenOpenShellDir, redactionValues, timeoutMs: INSTALL_TIMEOUT_MS, }, @@ -553,10 +555,42 @@ git -C "$HOME/.nemoclaw/source" rev-parse --verify HEAD`, sandboxes?: Record; }; expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]).toBeDefined(); - expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]?.nemoclawVersion).toBeUndefined(); + expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]?.nemoclawVersion).toBe( + expectedLegacyRegistryVersion(OLD_NEMOCLAW_REF), + ); expect(oldRegistry.sandboxes?.[SURVIVOR_SANDBOX]?.fromDockerfile).toBeUndefined(); } +async function stageOldOpenShellInUserLocalBin(host: HostCliClient): Promise { + const result = await bash( + host, + `active_openshell="$(command -v openshell)" +active_dir="$(dirname "$active_openshell")" +user_local_bin="$HOME/.local/bin" +mkdir -p "$user_local_bin" +for component in openshell openshell-gateway openshell-sandbox; do + test -x "$active_dir/$component" + if [ "$active_dir" != "$user_local_bin" ]; then + install -m 755 "$active_dir/$component" "$user_local_bin/$component" + fi +done +"$user_local_bin/openshell" --version +printf '%s\n' "$active_dir"`, + { artifactName: "stage-old-openshell-user-local", timeoutMs: 30_000 }, + ); + expectExitZero(result, "stage the v0.0.55 OpenShell layout in ~/.local/bin"); + expectOutputContains( + result, + OLD_OPENSHELL_VERSION, + `staged user-local OpenShell must remain ${OLD_OPENSHELL_VERSION}`, + ); + const activeDir = result.stdout.trim().split("\n").at(-1) ?? ""; + expect(path.isAbsolute(activeDir), `old OpenShell directory must be absolute: ${activeDir}`).toBe( + true, + ); + return activeDir; +} + async function startSurvivorAgentInExistingClaw(host: HostCliClient): Promise { const markerResult = await bash( host, @@ -605,6 +639,7 @@ async function installCurrentNemoclawUpgrade( host: HostCliClient, fakeBaseUrl: string, currentInstallLog: string, + hiddenOldOpenShellDir?: string, ): Promise { const currentRef = process.env.NEMOCLAW_CURRENT_NEMOCLAW_REF ?? process.env.GITHUB_SHA ?? "HEAD"; const currentRefResult = await bash( @@ -619,6 +654,12 @@ async function installCurrentNemoclawUpgrade( const resolvedRef = currentRefResult.stdout.trim(); expect(resolvedRef.length).toBeGreaterThan(0); const exerciseOrdinaryUpgrade = OLD_NEMOCLAW_REF === "v0.0.55"; + if (exerciseOrdinaryUpgrade) { + expect( + hiddenOldOpenShellDir, + "the v0.0.55 fixture must record the original OpenShell directory before hiding it", + ).toBeTruthy(); + } const baseCurrentEnv = liveEnv({ COMPATIBLE_API_KEY: "dummy", GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "", @@ -658,7 +699,7 @@ async function installCurrentNemoclawUpgrade( currentEnv, redactionValues, { - hideUserLocalOpenShell: exerciseOrdinaryUpgrade, + hiddenOpenShellDir: exerciseOrdinaryUpgrade ? hiddenOldOpenShellDir : undefined, // One answer covers a changed usage notice, when present, and the other // confirms the legacy managed-image recovery prompt. interactiveInput: exerciseOrdinaryUpgrade ? "yes\nyes" : undefined, @@ -851,12 +892,15 @@ runLinuxOpenShellGatewayUpgrade( }); await installOldNemoclawAndClaw(host, artifacts, fake.baseUrl); + const hiddenOldOpenShellDir = + OLD_NEMOCLAW_REF === "v0.0.55" ? await stageOldOpenShellInUserLocalBin(host) : undefined; const survivorPid = await startSurvivorAgentInExistingClaw(host); expect(Number.isInteger(survivorPid) && survivorPid > 0).toBe(true); await installCurrentNemoclawUpgrade( host, fake.baseUrl, artifacts.pathFor("current-install.log"), + hiddenOldOpenShellDir, ); await assertSurvivorSandboxAfterUpgrade(host); }, diff --git a/test/e2e/support/issue-6194-tui-post-idle-contract.test.ts b/test/e2e/support/issue-6194-tui-post-idle-contract.test.ts index fedf3022eb2..9da44d259c1 100644 --- a/test/e2e/support/issue-6194-tui-post-idle-contract.test.ts +++ b/test/e2e/support/issue-6194-tui-post-idle-contract.test.ts @@ -172,8 +172,9 @@ describe("live TUI post-idle coverage contract (#6194)", () => { ); expect(script.match(/send -i \$termSpawn -- "\\t"/gu) ?? []).toHaveLength(2); expect(script).toContain( - "expect_exact_or_exit $termSpawn {Name:} openshell_sandbox_detail 68 69", + "expect_exact_or_exit $termSpawn {Filesystem Access} openshell_sandbox_detail 68 69", ); + expect(script).not.toContain("expect_exact_or_exit $termSpawn {Name:}"); expect(script).not.toContain("(Dashboard-)?Sandbox:"); expect(script).toContain('send -i $termSpawn -- "r"'); expect(script).toContain( diff --git a/test/e2e/support/mcp-bridge-onboard-env.test.ts b/test/e2e/support/mcp-bridge-onboard-env.test.ts new file mode 100644 index 00000000000..abc10080745 --- /dev/null +++ b/test/e2e/support/mcp-bridge-onboard-env.test.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { buildMcpBridgeOnboardEnv } from "../live/mcp-bridge-onboard-env.ts"; + +const ONBOARD_OPTIONS = { + agent: "langchain-deepagents-code" as const, + baseEnv: { HOME: "/tmp/home", PATH: "/usr/bin" }, + compatibleKey: "compatible-test-key", + compatibleModel: "mock/mcp-bridge", + endpointUrl: "https://inference.example.test/v1", + sandboxName: "e2e-mcp-dcode", +}; + +describe("MCP bridge onboarding environment", () => { + it("passes only exact-main OpenShell overrides after fixed onboarding values", () => { + const env = buildMcpBridgeOnboardEnv({ + ...ONBOARD_OPTIONS, + envOverlay: { + PATH: "/tmp/exact-main:/usr/bin", + NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-main/openshell", + NEMOCLAW_OPENSHELL_GATEWAY_BIN: "/usr/local/bin/openshell-gateway", + NEMOCLAW_OPENSHELL_SANDBOX_BIN: "/usr/local/bin/openshell-sandbox", + }, + }); + + expect(env).toMatchObject({ + PATH: "/tmp/exact-main:/usr/bin", + NEMOCLAW_AGENT: "langchain-deepagents-code", + NEMOCLAW_ENDPOINT_URL: "https://inference.example.test/v1", + NEMOCLAW_OPENSHELL_BIN: "/tmp/exact-main/openshell", + NEMOCLAW_OPENSHELL_GATEWAY_BIN: "/usr/local/bin/openshell-gateway", + NEMOCLAW_OPENSHELL_SANDBOX_BIN: "/usr/local/bin/openshell-sandbox", + }); + }); + + it("rejects protected onboarding key collisions", () => { + expect(() => + buildMcpBridgeOnboardEnv({ + ...ONBOARD_OPTIONS, + envOverlay: { NEMOCLAW_AGENT: "openclaw" }, + }), + ).toThrow("does not allow env overlay key 'NEMOCLAW_AGENT'"); + }); +}); diff --git a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts index 4a6e6cf18d0..894ca58f2b2 100644 --- a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts @@ -16,6 +16,7 @@ import { } from "../../../tools/e2e/workflow-boundary.mts"; import { currentGatewayUpgradeInstallerArgs, + expectedLegacyRegistryVersion, oldGatewayUpgradeInstallerArgs, upgradeGatewayCleanupScript, validateLegacyGatewayUpgradeFixture, @@ -71,6 +72,15 @@ describe("OpenShell gateway upgrade workflow boundary", () => { ); }); + it("pins the registry metadata written by each historical release fixture", () => { + expect(expectedLegacyRegistryVersion("v0.0.36")).toBeUndefined(); + expect(expectedLegacyRegistryVersion("v0.0.55")).toBeUndefined(); + expect(expectedLegacyRegistryVersion("v0.0.74")).toBe("0.0.74"); + expect(() => expectedLegacyRegistryVersion("v0.0.75")).toThrow( + /Unsupported gateway-upgrade registry fixture/, + ); + }); + it("rejects mutable or injectable historical fixture inputs before use (#6114)", () => { const fixture = { nemoclawRef: "v0.0.55", diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 866de3cd894..2a75d26f29b 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,6 +10,23 @@ import { dockerRunCommandBetween, runDockerShell } from "./helpers/hermes-docker const ROOT = path.resolve(import.meta.dirname, ".."); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); +const HERMES_INTEGRITY_FILES = [ + { + arg: "NEMOCLAW_HERMES_WRAPPER_SHA256", + source: "agents/hermes/hermes-wrapper.py", + target: "/usr/local/lib/nemoclaw/hermes-wrapper.py", + }, + { + arg: "NEMOCLAW_HERMES_VALIDATOR_SHA256", + source: "agents/hermes/validate-env-secret-boundary.py", + target: "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py", + }, + { + arg: "NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256", + source: "agents/hermes/finalize-tirith-marker.py", + target: "/usr/local/lib/nemoclaw/finalize-tirith-marker.py", + }, +] as const; type LegacyDataFixture = | "none" @@ -108,6 +126,23 @@ function runFinalLayout({ } describe("Hermes final image layout", () => { + // source-shape-contract: security -- Exact source-to-image digests keep the reviewed Hermes runtime entrypoints bound to the files copied into the sandbox image + it("keeps security entrypoint hashes synchronized with the copied files", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + + for (const entry of HERMES_INTEGRITY_FILES) { + const digest = createHash("sha256") + .update(fs.readFileSync(path.join(ROOT, entry.source))) + .digest("hex"); + const declaredDigest = dockerfile.match( + new RegExp(`^ARG ${entry.arg}=([0-9a-f]{64})$`, "mu"), + )?.[1]; + + expect(dockerfile).toContain(`COPY ${entry.source} ${entry.target}`); + expect(declaredDigest, `${entry.arg} must match ${entry.source}`).toBe(digest); + } + }); + it("rejects retired OpenClaw state represented as a directory", () => { const run = runFinalLayout({ openclaw: "directory" }); try {