diff --git a/ci/onboard-entry-composition-budget.json b/ci/onboard-entry-composition-budget.json index fbb8faae00d..6d0814d184e 100644 --- a/ci/onboard-entry-composition-budget.json +++ b/ci/onboard-entry-composition-budget.json @@ -6,21 +6,17 @@ "runOnboard": 2 }, "messaging": { - "createSandboxWithBaseImageResolution": 9, - "createSandboxWithBaseImageResolution.plan.rebindMessagingTokenDefs": 1, "getOpenShellInstallDeps.hasRequiredOpenshellMessagingFeatures": 4, "runOnboard": 1, "runOnboard.finalizationDeps.verifyDeployment.getMessagingChannels": 1 }, "policy": { "createOnboardPolicyApplication.getRecordedPolicyTier": 1, - "createSandboxWithBaseImageResolution": 7, "preflightAuthoritativeRebuildTarget": 1, "runOnboard": 6, "sandboxCreateIntentResolver.getAgentPolicyPath": 1 }, "provider": { - "createSandboxWithBaseImageResolution": 20, "handleNimLocalSelection": 36, "handleRemoteProviderSelection": 84, "handleRemoteProviderSelection.providerExistsInGateway": 1, diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index d1f54e10ecb..cf249fad708 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -49,7 +49,7 @@ "src/lib/inference/local.ts": 21, "src/lib/inference/onboard-probes.ts": 21, "src/lib/inference/vllm.ts": 21, - "src/lib/onboard.ts": 203, + "src/lib/onboard.ts": 201, "src/lib/onboard/machine/handlers/sandbox.ts": 21, "src/lib/sandbox/config.ts": 22, "src/lib/shields/index.ts": 23 diff --git a/install.sh b/install.sh index 8c4bb4263e8..1ea4b35823a 100755 --- a/install.sh +++ b/install.sh @@ -65,14 +65,18 @@ has_payload_marker() { clone_nemoclaw_ref() { local ref="$1" dest="$2" - git init --quiet "$dest" - git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git - if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then - printf "[ERROR] Requested install ref '%s' is not available from https://github.com/NVIDIA/NemoClaw.git.\n" "$ref" >&2 - printf " Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again.\n" >&2 - exit 1 - fi - git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target + ( + # Git applies the process umask when it creates the authoritative source checkout. + umask 022 + git init --quiet "$dest" + git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git + if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then + printf "[ERROR] Requested install ref '%s' is not available from https://github.com/NVIDIA/NemoClaw.git.\n" "$ref" >&2 + printf " Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again.\n" >&2 + exit 1 + fi + git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target + ) } exec_installer_from_ref() { diff --git a/scripts/dev-tier-selector.mts b/scripts/dev-tier-selector.mts index bbe55cef012..6c01af3bde3 100644 --- a/scripts/dev-tier-selector.mts +++ b/scripts/dev-tier-selector.mts @@ -61,7 +61,7 @@ runner.run = () => successfulRunResult; runner.runCapture = () => ""; registry.getSandbox = () => ({ name: "test-sb", model: null, provider: null }); -registry.registerSandbox = () => true; +registry.registerSandbox = (entry) => entry; registry.updateSandbox = () => true; // ── Run ──────────────────────────────────────────────────────────────────── diff --git a/scripts/install.sh b/scripts/install.sh index 01be65b61c2..d00729158ff 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -12,6 +12,10 @@ set -euo pipefail # are removed on any exit path (set -e, unhandled signal, unexpected error). _cleanup_pids=() _cleanup_files=() +# Bind the portable installer's temporary Docker CLI selector so only that +# exact value can be removed from the Hermes onboarding child. +unset _PORTABLE_INSTALLER_DOCKER_HOST +_PORTABLE_INSTALLER_DOCKER_HOST="" # #4414: When re-launched as a staged copy via `curl | bash`, queue the # staged tmpfile for removal on EXIT. NEMOCLAW_INSTALLER_STAGED carries # the staged path forward so both the loop guard and cleanup use one var. @@ -173,12 +177,16 @@ resolve_stamped_version() { clone_nemoclaw_ref() { local ref="$1" dest="$2" - git init --quiet "$dest" - git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git - if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then - error "Requested install ref '$ref' is not available from https://github.com/NVIDIA/NemoClaw.git. Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again." - fi - git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target + ( + # Git applies the process umask when it creates the authoritative source checkout. + umask 022 + git init --quiet "$dest" + git -C "$dest" remote add origin https://github.com/NVIDIA/NemoClaw.git + if ! git -C "$dest" fetch --quiet --depth 1 origin "+${ref}:refs/nemoclaw-install/target"; then + error "Requested install ref '$ref' is not available from https://github.com/NVIDIA/NemoClaw.git. Check NEMOCLAW_INSTALL_TAG/NEMOCLAW_INSTALL_REF and try again." + fi + git -C "$dest" -c advice.detachedHead=false checkout --quiet --detach refs/nemoclaw-install/target + ) } # --------------------------------------------------------------------------- @@ -3937,15 +3945,28 @@ run_onboard() { # forward --yes so the Ollama size-confirmation gate does not abort # the unattended download (the size is still printed to logs). onboard_cmd+=(--yes) + fi + + local invoke_bin="$cli_invoke" + local -a invoke_args=("${onboard_cmd[@]}") + if [[ "${NEMOCLAW_EXPERIMENTAL_PROFILE:-}" == "portable" && + "${NEMOCLAW_AGENT:-openclaw}" == "hermes" && + -n "$_PORTABLE_INSTALLER_DOCKER_HOST" && + "${DOCKER_HOST:-}" == "$_PORTABLE_INSTALLER_DOCKER_HOST" ]]; then + invoke_bin="/usr/bin/env" + invoke_args=(-u DOCKER_HOST "$cli_invoke" "${onboard_cmd[@]}") + fi + + if [ "${NON_INTERACTIVE:-}" = "1" ]; then NEMOCLAW_INSTALLER_AUTO_FRESH_RECEIPT_GENERATION="$installer_auto_fresh_receipt_generation" \ - "$cli_invoke" "${onboard_cmd[@]}" || status=$? + "$invoke_bin" "${invoke_args[@]}" || status=$? elif [ -t 0 ]; then NEMOCLAW_INSTALLER_AUTO_FRESH_RECEIPT_GENERATION="$installer_auto_fresh_receipt_generation" \ - "$cli_invoke" "${onboard_cmd[@]}" || status=$? + "$invoke_bin" "${invoke_args[@]}" || status=$? elif { exec 3/dev/null; then info "Installer stdin is piped; attaching onboarding to /dev/tty…" NEMOCLAW_INSTALLER_AUTO_FRESH_RECEIPT_GENERATION="$installer_auto_fresh_receipt_generation" \ - "$cli_invoke" "${onboard_cmd[@]}" <&3 || status=$? + "$invoke_bin" "${invoke_args[@]}" <&3 || status=$? exec 3<&- else error "Interactive onboarding requires a TTY. Re-run in a terminal or set NEMOCLAW_NON_INTERACTIVE=1 with --yes-i-accept-third-party-software." @@ -4164,6 +4185,7 @@ prepare_portable_experimental_runtime_override() { /*) export DOCKER_HOST="unix://${podman_socket}" ;; *) error "Podman reported an invalid rootless API socket path: ${podman_socket:-empty}" ;; esac + _PORTABLE_INSTALLER_DOCKER_HOST="$DOCKER_HOST" info "Portable profile selected rootless Podman through DOCKER_HOST=${DOCKER_HOST}." } diff --git a/src/commands/credentials.test.ts b/src/commands/credentials.test.ts index 707856865d2..80928b89731 100644 --- a/src/commands/credentials.test.ts +++ b/src/commands/credentials.test.ts @@ -14,7 +14,9 @@ const mocks = vi.hoisted(() => ({ vi.mock("../lib/credentials/store", () => ({ KNOWN_CREDENTIAL_ENV_KEYS: ["NVIDIA_INFERENCE_API_KEY"], + getCredential: vi.fn(), prompt: mocks.prompt, + saveCredential: vi.fn(), })); vi.mock("../lib/actions/global", () => ({ recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime, diff --git a/src/commands/global-oclif-command-adapters.test.ts b/src/commands/global-oclif-command-adapters.test.ts index b54252ac6ca..fafc20dd7a3 100644 --- a/src/commands/global-oclif-command-adapters.test.ts +++ b/src/commands/global-oclif-command-adapters.test.ts @@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({ runInferenceSet: vi.fn(), runOnboardAction: vi.fn(), runUpgradeSandboxesAction: vi.fn(), + assertNoHermesPortableHostAuthority: vi.fn(), + withPortableHostFence: vi.fn( + async (homeOrOperation: string | (() => unknown), operation?: () => unknown) => + (typeof homeOrOperation === "function" ? homeOrOperation : operation)?.(), + ), showStatusCommand: vi.fn(), onboardRuntimeDeps: { googlechatTunnelRuntime: {} }, })); @@ -42,6 +47,13 @@ vi.mock("../lib/actions/global", () => ({ runUpgradeSandboxesAction: mocks.runUpgradeSandboxesAction, })); +vi.mock("../lib/state/portable-uninstall-retirement", async (importOriginal) => ({ + ...(await importOriginal()), + assertNoHermesPortableHostAuthority: mocks.assertNoHermesPortableHostAuthority, + withCurrentPortableHostFence: mocks.withPortableHostFence, + withPortableHostFence: mocks.withPortableHostFence, +})); + vi.mock("../lib/cli/onboard-runtime-deps", () => ({ createOnboardActionRuntimeDeps: mocks.createOnboardActionRuntimeDeps, })); @@ -88,6 +100,7 @@ const rootDir = process.cwd(); describe("global oclif command adapters", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.assertNoHermesPortableHostAuthority.mockReset(); mocks.buildListCommandDeps.mockReturnValue({ getLiveInference: vi.fn() }); mocks.buildStatusCommandDeps.mockReturnValue({ statusDeps: true }); mocks.getSandboxInventory.mockResolvedValue({ sandboxes: [] }); @@ -111,6 +124,11 @@ describe("global oclif command adapters", () => { it("runs list through inventory helpers", async () => { await ListCommand.run([], rootDir); + expect(mocks.withPortableHostFence).toHaveBeenCalledOnce(); + expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith( + expect.any(String), + "list", + ); expect(mocks.buildListCommandDeps).toHaveBeenCalledWith(); expect(mocks.getSandboxInventory).toHaveBeenCalledWith({ getLiveInference: expect.any(Function), @@ -170,6 +188,8 @@ describe("global oclif command adapters", () => { it("runs status through status helpers", async () => { await StatusCommand.run([], rootDir); + expect(mocks.withPortableHostFence).toHaveBeenCalledOnce(); + expect(mocks.assertNoHermesPortableHostAuthority).not.toHaveBeenCalled(); expect(mocks.buildStatusCommandDeps).toHaveBeenCalledWith(rootDir); expect(mocks.showStatusCommand).toHaveBeenCalledWith({ statusDeps: true }); }); @@ -214,6 +234,40 @@ describe("global oclif command adapters", () => { }); }); + it.each([ + [ + "list", + () => ListCommand.run([], rootDir), + [mocks.buildListCommandDeps, mocks.getSandboxInventory], + ], + ["inference:get", () => InferenceGetCommand.run([], rootDir), [mocks.runInferenceGet]], + [ + "upgrade-sandboxes", + () => UpgradeSandboxesCommand.run(["--check"], rootDir), + [mocks.runUpgradeSandboxesAction], + ], + ] as const)( + "rejects %s under the host fence before any action (#9203)", + async (commandId, run, effects) => { + mocks.assertNoHermesPortableHostAuthority.mockImplementation(() => { + throw new Error( + `Command '${commandId}' is not supported while an experimental Hermes portable lifecycle receipt exists. No legacy Docker or OpenShell action was attempted.`, + ); + }); + + await expect(run()).rejects.toThrow( + `Command '${commandId}' is not supported while an experimental Hermes portable lifecycle receipt exists`, + ); + + expect(mocks.withPortableHostFence).toHaveBeenCalledOnce(); + expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith( + expect.any(String), + commandId, + ); + expect(effects.every((effect) => effect.mock.calls.length === 0)).toBe(true); + }, + ); + it("maps onboard-family flags directly into the shared typed action", async () => { await OnboardCliCommand.run(["--name", "alpha", "--resume"], rootDir); await SetupCliCommand.run(["--name", "alpha", "--resume"], rootDir); @@ -272,6 +326,11 @@ describe("global oclif command adapters", () => { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); try { await InferenceGetCommand.run(["--json"], rootDir); + expect(mocks.withPortableHostFence).toHaveBeenCalledOnce(); + expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith( + expect.any(String), + "inference:get", + ); expect(mocks.runInferenceGet).toHaveBeenCalledWith({ quiet: true }); expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toEqual({ provider: "nvidia-prod", diff --git a/src/commands/sandbox/config/rotate-token.ts b/src/commands/sandbox/config/rotate-token.ts index 3162aece6a1..c6da3707320 100644 --- a/src/commands/sandbox/config/rotate-token.ts +++ b/src/commands/sandbox/config/rotate-token.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Args, Flags } from "@oclif/core"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { + assertHermesPortableCommandUnavailable, + NemoClawCommand, + withSandboxCommandLifecycleLock, +} from "../../../lib/cli/nemoclaw-oclif-command"; import * as sandboxConfig from "../../../lib/sandbox/config"; @@ -37,9 +41,12 @@ export default class SandboxConfigRotateTokenCommand extends NemoClawCommand { public async run(): Promise { const { args, flags } = await this.parse(SandboxConfigRotateTokenCommand); try { - await sandboxConfig.configRotateToken(args.sandboxName, { - fromEnv: flags["from-env"] ?? null, - fromStdin: flags.stdin ?? false, + await withSandboxCommandLifecycleLock(args.sandboxName, () => { + assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:config:rotate-token"); + return sandboxConfig.configRotateToken(args.sandboxName, { + fromEnv: flags["from-env"] ?? null, + fromStdin: flags.stdin ?? false, + }); }); } catch (error) { if (error instanceof sandboxConfig.SandboxConfigError) { diff --git a/src/commands/sandbox/config/set.ts b/src/commands/sandbox/config/set.ts index 05c6ed053a9..8f8e3952720 100644 --- a/src/commands/sandbox/config/set.ts +++ b/src/commands/sandbox/config/set.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Args, Flags } from "@oclif/core"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { + assertHermesPortableCommandUnavailable, + NemoClawCommand, + withSandboxCommandLifecycleLock, +} from "../../../lib/cli/nemoclaw-oclif-command"; import * as sandboxConfig from "../../../lib/sandbox/config"; @@ -42,11 +46,14 @@ export default class SandboxConfigSetCommand extends NemoClawCommand { public async run(): Promise { const { args, flags } = await this.parse(SandboxConfigSetCommand); try { - await sandboxConfig.configSet(args.sandboxName, { - key: flags.key ?? null, - value: flags.value ?? null, - restart: flags.restart ?? false, - acceptNewPath: flags["config-accept-new-path"] ?? false, + await withSandboxCommandLifecycleLock(args.sandboxName, () => { + assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:config:set"); + return sandboxConfig.configSet(args.sandboxName, { + key: flags.key ?? null, + value: flags.value ?? null, + restart: flags.restart ?? false, + acceptNewPath: flags["config-accept-new-path"] ?? false, + }); }); } catch (error) { if (error instanceof sandboxConfig.SandboxConfigError) { diff --git a/src/commands/sandbox/exec.test.ts b/src/commands/sandbox/exec.test.ts index 05a0c8522c2..e1a1bb29f77 100644 --- a/src/commands/sandbox/exec.test.ts +++ b/src/commands/sandbox/exec.test.ts @@ -9,6 +9,7 @@ vi.mock("../../lib/actions/sandbox/exec", () => ({ })); import { log } from "../../lib/cli/logger"; +import * as portableAgentLifecycle from "../../lib/onboard/experimental/portable-agent-lifecycle"; import SandboxExecCommand from "./exec"; const rootDir = process.cwd(); @@ -34,6 +35,20 @@ describe("SandboxExecCommand oclif parse path", () => { ); }); + it("rejects schema-5 inside the user-facing exec lifecycle fence (#9203)", async () => { + vi.spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable").mockImplementation( + () => { + throw new Error("schema-5 rejected"); + }, + ); + + await expect(SandboxExecCommand.run(["alpha", "--", "true"], rootDir)).rejects.toThrow( + "schema-5 rejected", + ); + + expect(execSandboxMock).not.toHaveBeenCalled(); + }); + it("does not assign host meaning to logging flags after --", async () => { const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined); diff --git a/src/commands/sandbox/exec.ts b/src/commands/sandbox/exec.ts index 183062dfdb4..27f4489dea5 100644 --- a/src/commands/sandbox/exec.ts +++ b/src/commands/sandbox/exec.ts @@ -3,7 +3,11 @@ import { Args, Flags } from "@oclif/core"; import { execSandbox } from "../../lib/actions/sandbox/exec"; -import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +import { + assertHermesPortableCommandUnavailable, + NemoClawCommand, + withSandboxCommandLifecycleLock, +} from "../../lib/cli/nemoclaw-oclif-command"; export default class SandboxExecCommand extends NemoClawCommand { static id = "sandbox:exec"; @@ -51,11 +55,14 @@ export default class SandboxExecCommand extends NemoClawCommand { const cmd = ( separatorIndex === -1 ? argv.slice(1) : originalArgv.slice(separatorIndex + 1) ) as string[]; - await execSandbox(args.sandboxName, cmd, { - workdir: flags.workdir, - tty: typeof flags.tty === "boolean" ? flags.tty : null, - timeoutSeconds: flags.timeout, - stdin: flags.stdin, + await withSandboxCommandLifecycleLock(args.sandboxName, () => { + assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:exec"); + return execSandbox(args.sandboxName, cmd, { + workdir: flags.workdir, + tty: typeof flags.tty === "boolean" ? flags.tty : null, + timeoutSeconds: flags.timeout, + stdin: flags.stdin, + }); }); } } diff --git a/src/commands/sandbox/gateway/token.ts b/src/commands/sandbox/gateway/token.ts index 103abf0140c..6fa05927a2b 100644 --- a/src/commands/sandbox/gateway/token.ts +++ b/src/commands/sandbox/gateway/token.ts @@ -4,7 +4,11 @@ import { Args } from "@oclif/core"; import type { AgentDefinition } from "../../../lib/agent/defs"; import { quietFlag } from "../../../lib/cli/common-flags"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { + assertHermesPortableCommandUnavailable, + NemoClawCommand, + withSandboxCommandLifecycleLock, +} from "../../../lib/cli/nemoclaw-oclif-command"; import { GatewayTokenCommandError, @@ -129,17 +133,20 @@ export default class GatewayTokenCliCommand extends NemoClawCommand { throw err; }); - const runtime = getRuntimeBridge(); try { - runGatewayTokenCommand( - args.sandboxName, - { quiet: flags.quiet === true }, - { - fetchToken: runtime.fetchToken, - getSandboxAgent: runtime.getSandboxAgent, - agentExposesToken: runtime.agentExposesToken, - }, - ); + await withSandboxCommandLifecycleLock(args.sandboxName, () => { + assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:gateway:token"); + const runtime = getRuntimeBridge(); + runGatewayTokenCommand( + args.sandboxName, + { quiet: flags.quiet === true }, + { + fetchToken: runtime.fetchToken, + getSandboxAgent: runtime.getSandboxAgent, + agentExposesToken: runtime.agentExposesToken, + }, + ); + }); // CodeRabbit #3182: if a prior run() left process.exitCode = 1, a later // successful invocation must still report success. Always overwrite. this.setExitCode(0); diff --git a/src/commands/sandbox/oclif-command-adapters.test.ts b/src/commands/sandbox/oclif-command-adapters.test.ts index cc50de3b783..cf5ed2ad7b7 100644 --- a/src/commands/sandbox/oclif-command-adapters.test.ts +++ b/src/commands/sandbox/oclif-command-adapters.test.ts @@ -1,7 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as portableAgentLifecycle from "../../lib/onboard/experimental/portable-agent-lifecycle"; +import * as receiptAuthority from "../../lib/onboard/experimental/hermes-portable-receipt"; const mocks = vi.hoisted(() => { class SandboxConfigError extends Error { @@ -18,6 +24,8 @@ const mocks = vi.hoisted(() => { return { configGet: vi.fn(), + configRotateToken: vi.fn().mockResolvedValue(undefined), + configSet: vi.fn().mockResolvedValue(undefined), connectSandbox: vi.fn().mockResolvedValue(undefined), destroySandbox: vi.fn().mockResolvedValue(undefined), listSandboxChannels: vi.fn(), @@ -79,6 +87,8 @@ vi.mock("../../lib/actions/sandbox/host-aliases", () => ({ vi.mock("../../lib/sandbox/config", () => ({ configGet: mocks.configGet, + configRotateToken: mocks.configRotateToken, + configSet: mocks.configSet, SandboxConfigError: mocks.SandboxConfigError, })); @@ -94,7 +104,12 @@ vi.mock("../../lib/shields", () => ({ import SandboxChannelsListCommand from "./channels/list"; import SandboxConfigGetCommand from "./config/get"; +import SandboxConfigRotateTokenCommand from "./config/rotate-token"; +import SandboxConfigSetCommand from "./config/set"; import ConnectCliCommand from "./connect"; +import DashboardUrlCliCommand, { + setDashboardUrlRuntimeBridgeFactoryForTest, +} from "./dashboard-url"; import DestroyCliCommand from "./destroy"; import SandboxDoctorCliCommand from "./doctor"; import GatewayRestartCliCommand from "./gateway/restart"; @@ -113,10 +128,21 @@ import SandboxStatusCommand from "./status"; const rootDir = process.cwd(); describe("sandbox oclif command adapters", () => { + let stateDir: string; + beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-command-adapters-")); + vi.stubEnv("NEMOCLAW_TEST_STATE_DIR", stateDir); vi.clearAllMocks(); }); + afterEach(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + process.exitCode = undefined; + }); + it("maps connect and lifecycle flags to typed action options", async () => { const originalCleanupGatewayEnv = process.env.NEMOCLAW_CLEANUP_GATEWAY; delete process.env.NEMOCLAW_CLEANUP_GATEWAY; @@ -233,6 +259,65 @@ describe("sandbox oclif command adapters", () => { }); }); + it("rejects real schema-5 logs and dashboard-token routes before their actions (#9203)", async () => { + const fetchToken = vi.fn(() => "test-token"); + const getSandbox = vi.fn(() => ({ agent: "openclaw", dashboardPort: 18789 })); + const getAccessUrl = vi.fn(() => "http://127.0.0.1:18789"); + setDashboardUrlRuntimeBridgeFactoryForTest(() => ({ + fetchGatewayAuthTokenFromSandbox: fetchToken, + getSandbox, + getAccessUrl, + })); + const output = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await DashboardUrlCliCommand.run(["alpha", "--quiet"], rootDir); + expect(fetchToken).toHaveBeenCalledOnce(); + vi.clearAllMocks(); + + vi.spyOn(receiptAuthority, "inspectPortableAgentReceiptAuthority").mockReturnValue({ + kind: "hermes", + snapshot: { receipt: { phase: "active" } } as never, + }); + + await expect(SandboxLogsCommand.run(["alpha"], rootDir)).rejects.toThrow( + "not supported for an experimental Hermes portable sandbox", + ); + await expect(DashboardUrlCliCommand.run(["--quiet", "alpha"], rootDir)).rejects.toThrow( + "not supported for an experimental Hermes portable sandbox", + ); + expect(mocks.showSandboxLogs).not.toHaveBeenCalled(); + expect(fetchToken).not.toHaveBeenCalled(); + expect(getSandbox).not.toHaveBeenCalled(); + expect(getAccessUrl).not.toHaveBeenCalled(); + expect(output).not.toHaveBeenCalled(); + }); + + it("maps ordinary config mutations and rejects schema-5 before their actions (#9203)", async ({ + onTestFinished, + }) => { + await SandboxConfigSetCommand.run(["alpha", "--key", "model", "--value", "next"], rootDir); + await SandboxConfigRotateTokenCommand.run(["alpha", "--from-env", "TOKEN"], rootDir); + expect(mocks.configSet).toHaveBeenCalledOnce(); + expect(mocks.configRotateToken).toHaveBeenCalledOnce(); + vi.clearAllMocks(); + + const guard = vi + .spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable") + .mockImplementation(() => { + throw new Error("schema-5 rejected"); + }); + onTestFinished(() => guard.mockRestore()); + + await expect( + SandboxConfigSetCommand.run(["alpha", "--key", "model", "--value", "next"], rootDir), + ).rejects.toThrow("schema-5 rejected"); + await expect( + SandboxConfigRotateTokenCommand.run(["alpha", "--from-env", "TOKEN"], rootDir), + ).rejects.toThrow("schema-5 rejected"); + expect(mocks.configSet).not.toHaveBeenCalled(); + expect(mocks.configRotateToken).not.toHaveBeenCalled(); + }); + it("keeps sandbox inspection usage metadata on native oclif commands", () => { const usage = (command: { usage?: string[] }) => command.usage?.join(" ") ?? ""; @@ -320,6 +405,31 @@ describe("sandbox oclif command adapters", () => { expect(mocks.shieldsStatus).toHaveBeenCalledWith("alpha"); }); + it("rejects schema-5 shields commands inside their command lifecycle fence (#9203)", async ({ + onTestFinished, + }) => { + const guard = vi + .spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable") + .mockImplementation((_sandboxName, commandId) => { + throw new Error(`rejected ${commandId}`); + }); + onTestFinished(() => guard.mockRestore()); + + await expect(ShieldsDownCommand.run(["alpha"], rootDir)).rejects.toThrow( + "rejected sandbox:shields:down", + ); + await expect(ShieldsUpCommand.run(["alpha"], rootDir)).rejects.toThrow( + "rejected sandbox:shields:up", + ); + await expect(ShieldsStatusCommand.run(["alpha"], rootDir)).rejects.toThrow( + "rejected sandbox:shields:status", + ); + + expect(mocks.shieldsDown).not.toHaveBeenCalled(); + expect(mocks.shieldsUp).not.toHaveBeenCalled(); + expect(mocks.shieldsStatus).not.toHaveBeenCalled(); + }); + it("translates shields exit sentinels into exit codes without a traceback (#7382)", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); const previousExitCode = process.exitCode; diff --git a/src/commands/sandbox/shields/down.ts b/src/commands/sandbox/shields/down.ts index 6a29085fd04..1e9a551fafc 100644 --- a/src/commands/sandbox/shields/down.ts +++ b/src/commands/sandbox/shields/down.ts @@ -3,10 +3,13 @@ import { Flags } from "@oclif/core"; import { shieldsTimeoutDurationFlag } from "../../../lib/cli/duration-flags"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { + assertHermesPortableCommandUnavailable, + NemoClawCommand, + withSandboxCommandLifecycleLock, +} from "../../../lib/cli/nemoclaw-oclif-command"; import { sandboxNameArg } from "../../../lib/sandbox/command-support"; import * as shields from "../../../lib/shields/index"; -import { withSandboxMutationLock } from "../../../lib/state/mcp-lifecycle-lock"; export default class ShieldsDownCommand extends NemoClawCommand { static id = "sandbox:shields:down"; @@ -24,13 +27,14 @@ export default class ShieldsDownCommand extends NemoClawCommand { public async run(): Promise { const { args, flags } = await this.parse(ShieldsDownCommand); - await withSandboxMutationLock(args.sandboxName, () => - shields.shieldsDown(args.sandboxName, { + await withSandboxCommandLifecycleLock(args.sandboxName, () => { + assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:shields:down"); + return shields.shieldsDown(args.sandboxName, { timeout: flags.timeout ?? null, reason: flags.reason ?? null, policy: flags.policy ?? "permissive", throwOnError: true, - }), - ); + }); + }); } } diff --git a/src/commands/sandbox/shields/status.ts b/src/commands/sandbox/shields/status.ts index 0d6fe51261e..98f3d010379 100644 --- a/src/commands/sandbox/shields/status.ts +++ b/src/commands/sandbox/shields/status.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { + assertHermesPortableCommandUnavailable, + NemoClawCommand, + withSandboxCommandLifecycleLock, +} from "../../../lib/cli/nemoclaw-oclif-command"; import { sandboxNameArg } from "../../../lib/sandbox/command-support"; import * as shields from "../../../lib/shields/index"; @@ -17,6 +21,9 @@ export default class ShieldsStatusCommand extends NemoClawCommand { public async run(): Promise { const { args } = await this.parse(ShieldsStatusCommand); - shields.shieldsStatus(args.sandboxName); + await withSandboxCommandLifecycleLock(args.sandboxName, () => { + assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:shields:status"); + shields.shieldsStatus(args.sandboxName); + }); } } diff --git a/src/commands/sandbox/shields/up.ts b/src/commands/sandbox/shields/up.ts index 166927dd296..eab9e0e6570 100644 --- a/src/commands/sandbox/shields/up.ts +++ b/src/commands/sandbox/shields/up.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { + assertHermesPortableCommandUnavailable, + NemoClawCommand, + withSandboxCommandLifecycleLock, +} from "../../../lib/cli/nemoclaw-oclif-command"; import { sandboxNameArg } from "../../../lib/sandbox/command-support"; import * as shields from "../../../lib/shields/index"; @@ -17,6 +21,9 @@ export default class ShieldsUpCommand extends NemoClawCommand { public async run(): Promise { const { args } = await this.parse(ShieldsUpCommand); - shields.shieldsUp(args.sandboxName, { throwOnError: true }); + await withSandboxCommandLifecycleLock(args.sandboxName, () => { + assertHermesPortableCommandUnavailable(args.sandboxName, "sandbox:shields:up"); + return shields.shieldsUp(args.sandboxName, { throwOnError: true }); + }); } } diff --git a/src/commands/sandbox/status.ts b/src/commands/sandbox/status.ts index f38b5bc68a4..0b5c3f62abf 100644 --- a/src/commands/sandbox/status.ts +++ b/src/commands/sandbox/status.ts @@ -34,11 +34,13 @@ export default class SandboxStatusCommand extends NemoClawCommand { const report = await getSandboxStatusReport(args.sandboxName); if ( !report.found || - report.gatewayState !== "present" || - report.rpcIssue || - report.failureLayer || - isInferenceHealthFailing(report.inferenceHealth) || - report.terminalRuntimeHealth?.kind === "degraded" + ("portableLifecyclePhase" in report + ? report.portableLifecyclePhase !== "active" + : report.gatewayState !== "present" || + report.rpcIssue || + report.failureLayer || + isInferenceHealthFailing(report.inferenceHealth) || + report.terminalRuntimeHealth?.kind === "degraded") ) { process.exitCode = 1; } diff --git a/src/commands/simple-global-oclif-adapters.test.ts b/src/commands/simple-global-oclif-adapters.test.ts index a061bcb3aa8..5ee38aaf899 100644 --- a/src/commands/simple-global-oclif-adapters.test.ts +++ b/src/commands/simple-global-oclif-adapters.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { testTimeoutOptions } from "../../test/helpers/timeouts"; const mocks = vi.hoisted(() => { class GatewayTokenCommandError extends Error { @@ -43,6 +44,10 @@ const mocks = vi.hoisted(() => { runStartCommand: vi.fn().mockResolvedValue(undefined), runStopCommand: vi.fn(), runUninstallCommand: vi.fn(), + assertHermesPortableCommandUnavailable: vi.fn(), + withMcpLifecycleLock: vi.fn(async (_sandboxName: string, operation: () => unknown) => + operation(), + ), showRootHelp: vi.fn(), showVersion: vi.fn(), spawnSync: vi.fn(), @@ -86,6 +91,14 @@ vi.mock("../lib/uninstall-command", () => ({ runUninstallCommand: mocks.runUninstallCommand, })); vi.mock("../lib/core/version", () => ({ getVersion: mocks.getVersion })); +vi.mock("../lib/onboard/experimental/portable-agent-lifecycle", async (importOriginal) => ({ + ...(await importOriginal()), + assertHermesPortableCommandUnavailable: mocks.assertHermesPortableCommandUnavailable, +})); +vi.mock("../lib/state/mcp-lifecycle-lock-acquisition", async (importOriginal) => ({ + ...(await importOriginal()), + withMcpLifecycleLock: mocks.withMcpLifecycleLock, +})); import { log } from "../lib/cli/logger"; import DebugCliCommand from "./debug"; @@ -106,7 +119,7 @@ import UninstallCliCommand from "./uninstall"; const rootDir = process.cwd(); -describe("simple global oclif adapters", () => { +describe("simple global oclif adapters", testTimeoutOptions(30_000), () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -178,6 +191,26 @@ describe("simple global oclif adapters", () => { { quiet: true }, { fetchToken, getSandboxAgent, agentExposesToken }, ); + expect(mocks.withMcpLifecycleLock).toHaveBeenCalledWith("alpha", expect.any(Function)); + }); + + it("rejects schema-5 gateway-token before fetching or printing credentials (#9203)", async () => { + const fetchToken = vi.fn(() => "must-not-print"); + setGatewayTokenRuntimeBridgeFactoryForTest(() => ({ + fetchToken, + getSandboxAgent: () => "hermes", + agentExposesToken: () => true, + })); + mocks.assertHermesPortableCommandUnavailable.mockImplementationOnce(() => { + throw new Error("schema-5 token rejected"); + }); + + await expect(GatewayTokenCliCommand.run(["alpha", "--quiet"], rootDir)).rejects.toThrow( + "schema-5 token rejected", + ); + + expect(fetchToken).not.toHaveBeenCalled(); + expect(mocks.runGatewayTokenCommand).not.toHaveBeenCalled(); }); it("maps dashboard-url flags to the dashboard URL action", async () => { diff --git a/src/lib/actions/inference-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts index 44994cba26d..db042888174 100644 --- a/src/lib/actions/inference-set-hermes-run.test.ts +++ b/src/lib/actions/inference-set-hermes-run.test.ts @@ -1,13 +1,94 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const portableMocks = vi.hoisted(() => ({ + assertUnavailable: vi.fn(), +})); + +vi.mock("../onboard/experimental/portable-agent-lifecycle", async (importOriginal) => ({ + ...(await importOriginal()), + assertHermesPortableCommandUnavailable: portableMocks.assertUnavailable, +})); import { HERMES_PROXY_REWRITE_SENTINEL } from "../hermes-managed-route"; import type { ConfigObject } from "../security/credential-filter"; import { runInferenceSet } from "./inference-set"; import { baseSession, createDeps, HERMES_TARGET } from "./inference-set.test-support"; describe("runInferenceSet Hermes routing", () => { + beforeEach(() => { + portableMocks.assertUnavailable.mockReset(); + }); + + it("rejects schema-5 before OpenShell or registry mutation (#9203)", async () => { + portableMocks.assertUnavailable.mockImplementation(() => { + throw new Error("schema-5 rejected"); + }); + const deps = createDeps({ + config: {}, + entry: { + name: "hermes", + agent: "hermes", + provider: "hermes-provider", + model: "moonshotai/kimi-k2.6", + }, + defaultSandbox: "hermes", + target: HERMES_TARGET, + }); + + await expect( + runInferenceSet( + { + provider: "hermes-provider", + model: "openai/gpt-5.4-mini", + sandboxName: "hermes", + noVerify: true, + }, + deps, + ), + ).rejects.toThrow("schema-5 rejected"); + + expect(deps.calls.prepareRunOpenshell).not.toHaveBeenCalled(); + expect(deps.calls.captureOpenshell).not.toHaveBeenCalled(); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + }); + + it("rechecks schema-5 after the lifecycle lock is acquired (#9203)", async () => { + portableMocks.assertUnavailable + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("schema-5 appeared"); + }); + const deps = createDeps({ + config: {}, + entry: { + name: "hermes", + agent: "hermes", + provider: "hermes-provider", + model: "moonshotai/kimi-k2.6", + }, + defaultSandbox: "hermes", + target: HERMES_TARGET, + }); + + await expect( + runInferenceSet( + { + provider: "hermes-provider", + model: "openai/gpt-5.4-mini", + sandboxName: "hermes", + noVerify: true, + }, + deps, + ), + ).rejects.toThrow("schema-5 appeared"); + + expect(deps.calls.prepareRunOpenshell).toHaveBeenCalledOnce(); + expect(deps.calls.captureOpenshell).not.toHaveBeenCalled(); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + }); + it("updates OpenShell, Hermes config.yaml, registry, and the matching onboard session", async () => { const config: ConfigObject = { model: { diff --git a/src/lib/actions/inference-set-provider.ts b/src/lib/actions/inference-set-provider.ts index e4ac70a4396..75092eb33b1 100644 --- a/src/lib/actions/inference-set-provider.ts +++ b/src/lib/actions/inference-set-provider.ts @@ -7,6 +7,7 @@ import { matchesGatewayProviderBinding, parseGatewayProviderMetadata, } from "../onboard/gateway-provider-metadata"; +import { assertHermesPortableCommandUnavailable } from "../onboard/experimental/portable-agent-lifecycle"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES, type RuntimeProviderBundleRegistry, @@ -93,6 +94,10 @@ export function requireInferenceSetRuntimeAuthority( requireRuntimeProviderMutationAuthority(runtimeProvider, "inference-set"); } +export function assertInferenceSetCommandAvailable(sandboxName: string): void { + assertHermesPortableCommandUnavailable(sandboxName, "inference:set"); +} + type CaptureProviderCommand = ( args: string[], options: { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index d5b1ca84569..cbcc30ee79f 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -72,6 +72,7 @@ import { } from "./inference-set-gateway-restart"; import { type InferenceSetSandboxRouteProbe, + assertInferenceSetCommandAvailable, prepareInferenceSetProviderBinding, probeInferenceSetSandboxRoute, probeInferenceSetSandboxRouteUntilConverged, @@ -1484,8 +1485,10 @@ export async function runInferenceSet( // an async lock. The inner resolution still validates the live registry entry. const selected = resolveTargetSandbox(options.sandboxName, deps); assertInferenceSetRuntimeAuthority(selected.entry, deps.runtimeProviders); + assertInferenceSetCommandAvailable(selected.sandboxName); deps.prepareRunOpenshell(); return withSandboxMutationLock(selected.sandboxName, async () => { + assertInferenceSetCommandAvailable(selected.sandboxName); const lockedSelection = resolveTargetSandbox(selected.sandboxName, deps); assertInferenceSetRuntimeAuthority(lockedSelection.entry, deps.runtimeProviders); const gatewayName = resolveSandboxGatewayName(lockedSelection.entry); diff --git a/src/lib/actions/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index b18a5d85eb8..1925f37d973 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -20,6 +20,9 @@ const mocks = vi.hoisted(() => ({ openBackupShieldsWindow: vi.fn(), relockBackupShieldsWindow: vi.fn(), withSandboxMutationLock: vi.fn(), + assertNoHermesPortableHostAuthority: vi.fn(), + defaultPortableStateDir: vi.fn(), + withPortableHostFence: vi.fn(), })); async function runSandboxMutationAction( @@ -43,6 +46,11 @@ vi.mock("../state/sandbox", () => ({ vi.mock("../state/mcp-lifecycle-lock", () => ({ withSandboxMutationLock: mocks.withSandboxMutationLock, })); +vi.mock("../state/portable-uninstall-retirement", () => ({ + assertNoHermesPortableHostAuthority: mocks.assertNoHermesPortableHostAuthority, + defaultPortableStateDir: mocks.defaultPortableStateDir, + withPortableHostFence: mocks.withPortableHostFence, +})); vi.mock("./sandbox/snapshot/backup-authority", () => ({ backupSandboxStateWithManagedAuthority: (name: string) => mocks.backupSandboxState(name), })); @@ -118,11 +126,34 @@ describe("backupAll", () => { })); mocks.relockBackupShieldsWindow.mockReturnValue(true); mocks.withSandboxMutationLock.mockImplementation(runSandboxMutationAction); + mocks.assertNoHermesPortableHostAuthority.mockReset(); + mocks.defaultPortableStateDir.mockImplementation( + (env: NodeJS.ProcessEnv) => env.NEMOCLAW_TEST_STATE_DIR ?? `${env.HOME}/.nemoclaw`, + ); + mocks.withPortableHostFence.mockImplementation(async (_home, operation) => operation()); + }); + + it("rejects schema-5 authority before OpenShell or backup effects (#9203)", async () => { + const stateDir = "/private/nemoclaw-test-state"; + vi.stubEnv("VITEST", "true"); + vi.stubEnv("NEMOCLAW_TEST_BASE_HOME", process.env.HOME ?? ""); + vi.stubEnv("NEMOCLAW_TEST_STATE_DIR", stateDir); + mocks.assertNoHermesPortableHostAuthority.mockImplementation(() => { + throw new Error("Command 'backup-all' is not supported"); + }); + + await expect(backupAll()).rejects.toThrow("Command 'backup-all' is not supported"); + expect(mocks.listSandboxes).not.toHaveBeenCalled(); + expect(mocks.captureSandboxListWithGatewayPreflightOrExit).not.toHaveBeenCalled(); + expect(mocks.withSandboxMutationLock).not.toHaveBeenCalled(); + expect(mocks.backupSandboxState).not.toHaveBeenCalled(); + expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith(stateDir, "backup-all"); }); afterEach(() => { delete process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS; delete process.env.NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP; + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1119,44 +1150,49 @@ describe("backupAll", () => { it.each([ ["standalone backup", "", true], ["installer-strict backup", "1", false], - ])("emits mode-appropriate unreachable guidance for %s (#6114)", async (_mode, requireAll, expectSkipGuidance) => { - mocks.listSandboxes.mockReturnValue({ - sandboxes: [{ name: "sb-bad" }], - defaultSandbox: null, - }); - mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); - mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ - status: 0, - output: "sb-bad\n", - }); - mocks.backupSandboxState.mockImplementation(() => ({ - success: false, - unreachable: true, - backedUpDirs: [], - failedDirs: ["memories"], - backedUpFiles: [], - failedFiles: [], - })); - - delete process.env.NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP; - process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = requireAll; - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`exit:${code}`); - }) as never); - - await expect(backupAll()).rejects.toThrow("exit:1"); - - const errorOutput = errorSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(errorOutput.includes("NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP=1")).toBe( - expectSkipGuidance, - ); - expect(errorOutput.includes("Strict pre-upgrade backup cannot skip")).toBe(!expectSkipGuidance); - expect(errorOutput).not.toContain("prepare the upgrade manually"); - - errorSpy.mockRestore(); - exitSpy.mockRestore(); - }); + ])( + "emits mode-appropriate unreachable guidance for %s (#6114)", + async (_mode, requireAll, expectSkipGuidance) => { + mocks.listSandboxes.mockReturnValue({ + sandboxes: [{ name: "sb-bad" }], + defaultSandbox: null, + }); + mocks.parseReadySandboxNames.mockReturnValue(new Set(["sb-bad"])); + mocks.captureSandboxListWithGatewayPreflightOrExit.mockResolvedValue({ + status: 0, + output: "sb-bad\n", + }); + mocks.backupSandboxState.mockImplementation(() => ({ + success: false, + unreachable: true, + backedUpDirs: [], + failedDirs: ["memories"], + backedUpFiles: [], + failedFiles: [], + })); + + delete process.env.NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP; + process.env.NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS = requireAll; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + + await expect(backupAll()).rejects.toThrow("exit:1"); + + const errorOutput = errorSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(errorOutput.includes("NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP=1")).toBe( + expectSkipGuidance, + ); + expect(errorOutput.includes("Strict pre-upgrade backup cannot skip")).toBe( + !expectSkipGuidance, + ); + expect(errorOutput).not.toContain("prepare the upgrade manually"); + + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }, + ); it("skips a stranded orphan sandbox without failing strict backup (#6520)", async () => { // Uninstall + reinstall strands a sandbox: gateway registration and @@ -1355,6 +1391,29 @@ describe("shouldSkipUnreachableSandboxBackup", () => { describe("garbageCollectImages", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.assertNoHermesPortableHostAuthority.mockReset(); + mocks.withPortableHostFence.mockImplementation(async (_home, operation) => operation()); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("rejects schema-5 authority before scanning Docker images (#9203)", async () => { + const stateDir = "/private/nemoclaw-test-state"; + vi.stubEnv("VITEST", "true"); + vi.stubEnv("NEMOCLAW_TEST_BASE_HOME", process.env.HOME ?? ""); + vi.stubEnv("NEMOCLAW_TEST_STATE_DIR", stateDir); + mocks.assertNoHermesPortableHostAuthority.mockImplementation(() => { + throw new Error("Command 'gc' is not supported"); + }); + + await expect(garbageCollectImages({ dryRun: true })).rejects.toThrow( + "Command 'gc' is not supported", + ); + expect(mocks.dockerListImagesFormat).not.toHaveBeenCalled(); + expect(mocks.dockerRmi).not.toHaveBeenCalled(); + expect(mocks.assertNoHermesPortableHostAuthority).toHaveBeenCalledWith(stateDir, "gc"); }); it("surfaces a local-repo orphan while preserving a registered local image (#6301)", async () => { diff --git a/src/lib/actions/maintenance.ts b/src/lib/actions/maintenance.ts index 9019950ed1e..0a706670e76 100644 --- a/src/lib/actions/maintenance.ts +++ b/src/lib/actions/maintenance.ts @@ -26,6 +26,11 @@ import { withSandboxMutationLock } from "../state/mcp-lifecycle-lock"; import * as registry from "../state/registry"; import * as sandboxState from "../state/sandbox"; import { nemoclawStateRoot, resolveHome } from "../state/state-root"; +import { + assertNoHermesPortableHostAuthority, + defaultPortableStateDir, + withPortableHostFence, +} from "../state/portable-uninstall-retirement"; import { type BackupShieldsWindowOptions, openBackupShieldsWindow, @@ -57,6 +62,17 @@ export function rebuildBackupsDirectory(home: string, gatewayPort: number): stri return path.join(nemoclawStateRoot(home, gatewayPort), "rebuild-backups"); } +async function withHermesPortableMaintenanceAdmission( + commandId: "backup-all" | "gc", + operation: () => Promise, +): Promise { + const home = resolveHome(); + return withPortableHostFence(home, async () => { + assertNoHermesPortableHostAuthority(defaultPortableStateDir(process.env), commandId); + return operation(); + }); +} + function notRunningBackupSkipMessage(name: string): string { return `Skipping '${name}' (not running; start the sandbox/container and rerun '${CLI_NAME} backup-all' so NemoClaw can capture a fresh snapshot)`; } @@ -263,6 +279,10 @@ async function backupSandboxWithinShieldsWindow( } export async function backupAll(): Promise { + return withHermesPortableMaintenanceAdmission("backup-all", backupAllWithoutPortableAuthority); +} + +async function backupAllWithoutPortableAuthority(): Promise { const sandboxes = registry .listSandboxes() .sandboxes.filter((sandbox) => !registry.isRouteOnlySandboxReservation(sandbox)); @@ -485,6 +505,14 @@ export async function backupAll(): Promise { export async function garbageCollectImages( options: string[] | GarbageCollectImagesOptions = {}, +): Promise { + return withHermesPortableMaintenanceAdmission("gc", () => + garbageCollectImagesWithoutPortableAuthority(options), + ); +} + +async function garbageCollectImagesWithoutPortableAuthority( + options: string[] | GarbageCollectImagesOptions = {}, ): Promise { const normalized = normalizeGarbageCollectImagesOptions(options); const dryRun = normalized.dryRun === true; diff --git a/src/lib/actions/sandbox/connect-flow.test.ts b/src/lib/actions/sandbox/connect-flow.test.ts index a2fcad6ce22..e0febdfe01c 100644 --- a/src/lib/actions/sandbox/connect-flow.test.ts +++ b/src/lib/actions/sandbox/connect-flow.test.ts @@ -9,6 +9,46 @@ import { requireDist, } from "../../../../test/support/connect-flow-test-harness"; +function captureInferenceRouteThenDrift( + harness: ReturnType, +): (args: unknown) => { status: number; output: string; stderr?: string } { + return (args: unknown) => { + const argv = Array.isArray(args) ? args : []; + switch (argv.slice(0, 2).join("\0")) { + case "inference\0get": + harness.registryEntries[0]!.model = "changed-model"; + return { + status: 0, + output: "Gateway inference:\n Provider: ollama-local\n Model: qwen3-vl:4b\n", + }; + case "sandbox\0exec": + return { status: 0, output: "OK 200", stderr: "" }; + default: + return { status: 0, output: "alpha Ready" }; + } + }; +} + +function captureInferenceRouteThenDriftLiveIdentity( + harness: ReturnType, +): (args: unknown) => { status: number; output: string; stderr?: string } { + return (args: unknown) => { + const argv = Array.isArray(args) ? args : []; + switch (argv.slice(0, 2).join("\0")) { + case "inference\0get": + harness.registryEntries[0]!.lifecycleLiveIdentityFingerprint = "0".repeat(64); + return { + status: 0, + output: "Gateway inference:\n Provider: ollama-local\n Model: qwen3-vl:4b\n", + }; + case "sandbox\0exec": + return { status: 0, output: "OK 200", stderr: "" }; + default: + return { status: 0, output: "alpha Ready" }; + } + }; +} + describe("connectSandbox flow", () => { let exitSpy: MockInstance; const originalStdinIsTty = process.stdin.isTTY; @@ -73,9 +113,9 @@ describe("connectSandbox flow", () => { expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 1_000); const watcherTimer = setIntervalSpy.mock.results[setIntervalSpy.mock.results.length - 1]?.value; expect(clearIntervalSpy).toHaveBeenCalledWith(watcherTimer); - expect( - harness.runSandboxExecChildSpy.mock.invocationCallOrder[0]!, - ).toBeLessThan(exitSpy.mock.invocationCallOrder[0]!); + expect(harness.runSandboxExecChildSpy.mock.invocationCallOrder[0]!).toBeLessThan( + exitSpy.mock.invocationCallOrder[0]!, + ); const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(output).toContain("existing SSH sessions"); expect(output).toContain("Connecting to sandbox 'alpha'"); @@ -139,11 +179,11 @@ describe("connectSandbox flow", () => { expect(output).toContain("Portable onboarding for 'alpha' is incomplete"); expect(output).toContain("Resume or rerun onboarding"); expect(harness.runAutoPairSpy).not.toHaveBeenCalled(); - expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( - "openshell", - ["sandbox", "connect", "alpha"], - expect.anything(), - ); + expect( + harness.spawnSyncSpy.mock.calls.some( + ([, args]) => Array.isArray(args) && args[0] === "sandbox" && args[1] === "connect", + ), + ).toBe(false); }); it("restores the terminal and prints reconnect guidance when SSH disconnects", async () => { @@ -945,6 +985,357 @@ describe("connectSandbox flow", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + it("keeps active Hermes probe on receipt-owned recovery with every Docker path poisoned (#9203)", async () => { + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry: { + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + }, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined(); + + expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled(); + expect(harness.ensureLiveSandboxSpy).not.toHaveBeenCalled(); + expect(harness.captureOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.captureResolvedOpenshellSpy).toHaveBeenCalledWith( + ["sandbox", "list", "-g", "nemoclaw"], + expect.objectContaining({ + env: expect.objectContaining({ HOME: "/home/test" }), + openshellBinary: "/usr/bin/openshell", + replaceEnv: true, + }), + ); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + expect(harness.dockerStartSpy).not.toHaveBeenCalled(); + expect(harness.runAutoPairSpy).not.toHaveBeenCalled(); + expect( + harness.runOpenshellSpy.mock.calls.some(([args]) => + Array.isArray(args) ? args[0] === "inference" && args[1] === "set" : false, + ), + ).toBe(false); + expect(harness.recoverPortableDemoLifecycleSpy).toHaveBeenCalled(); + }); + + it.each([ + ["runtime driver", { openshellDriver: "podman" }], + ["live identity", { lifecycleLiveIdentityFingerprint: "0".repeat(64) }], + ] as const)( + "rejects initial Hermes registry %s drift before probe mutation (#9203)", + async (_label, registryEntry) => { + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow( + "process.exit(1)", + ); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( + "receipt and registry authority disagree", + ); + expect(harness.recoverPortableDemoLifecycleSpy).not.toHaveBeenCalled(); + expect(harness.captureResolvedOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["absent", { registryEntry: { provider: null, model: null } }], + [ + "mismatched", + { + inferenceGetOutput: + "Gateway inference:\n Provider: nvidia-prod\n Model: nvidia/other-model\n", + }, + ], + ["unreachable", { inferenceProbeResponses: ["BROKEN 503"] }], + ] as const)( + "rejects a %s schema-5 inference route without mutation (#9203)", + async (_label, routeOptions) => { + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry: { + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + ...("registryEntry" in routeOptions ? routeOptions.registryEntry : {}), + }, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + ...("inferenceGetOutput" in routeOptions + ? { inferenceGetOutput: routeOptions.inferenceGetOutput } + : {}), + ...("inferenceProbeResponses" in routeOptions + ? { inferenceProbeResponses: [...routeOptions.inferenceProbeResponses] } + : {}), + }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow( + "process.exit(1)", + ); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( + "Hermes portable inference authority", + ); + expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + }, + ); + + it("rejects schema-5 inference authority that changes during read-only verification (#9203)", async () => { + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry: { + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + }, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + }); + harness.captureResolvedOpenshellSpy.mockImplementation(captureInferenceRouteThenDrift(harness)); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow( + "process.exit(1)", + ); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain("changed during verification"); + expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); + }); + + it("rejects live-identity drift during schema-5 route verification (#9203)", async () => { + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + }); + harness.captureResolvedOpenshellSpy.mockImplementation( + captureInferenceRouteThenDriftLiveIdentity(harness), + ); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow( + "process.exit(1)", + ); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain("changed during verification"); + expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + }); + + it("requalifies accepted probe evidence against active schema-5 authority (#9203)", async () => { + const entry = { + name: "alpha", + agent: "hermes", + provider: "ollama-local", + model: "qwen3-vl:4b", + policies: [], + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + } as never; + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry: entry, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + readinessDecision: { + kind: "accepted", + category: "accepted", + agent: { name: "hermes" }, + sb: entry, + }, + }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined(); + + expect(harness.recoverPortableDemoLifecycleSpy).toHaveBeenCalledOnce(); + expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + expect(harness.dockerStartSpy).not.toHaveBeenCalled(); + expect(harness.publishLaunchReadinessSpy).not.toHaveBeenCalled(); + }); + + it("rejects accepted probe evidence when schema-5 authority disappears (#9203)", async () => { + const entry = { + name: "alpha", + agent: "hermes", + provider: null, + model: null, + policies: [], + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + } as never; + const harness = createConnectHarness({ + agentName: "hermes", + registryEntry: entry, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "not-installed" }, + readinessDecision: { + kind: "accepted", + category: "accepted", + agent: { name: "hermes" }, + sb: entry, + }, + }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow( + "authority disappeared during probe", + ); + expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled(); + expect(harness.runSandboxExecChildSpy).not.toHaveBeenCalled(); + }); + + it("keeps active Hermes interactive setup inside receipt-owned recovery (#9203)", async () => { + vi.stubEnv("NVIDIA_INFERENCE_API_KEY", "do-not-forward"); + vi.stubEnv("GITHUB_TOKEN", "do-not-forward"); + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "do-not-forward"); + vi.stubEnv("DOCKER_HOST", "unix:///run/docker.sock"); + vi.stubEnv("KUBECONFIG", "/home/test/.kube/config"); + vi.stubEnv("SSH_AUTH_SOCK", "/run/user/1000/ssh-agent.sock"); + vi.stubEnv("HTTPS_PROXY", "https://user:token@proxy.example"); + vi.stubEnv("OPENSHELL_GATEWAY", "ambient"); + const sandboxVersion = requireDist("../../src/lib/sandbox/version.js"); + const broker = requireDist("../../src/lib/hermes-tool-gateway-broker.js"); + const brokerSpy = vi + .spyOn(broker, "ensureHermesToolGatewayBrokerForSandboxEntry") + .mockImplementation(() => undefined); + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry: { + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + hermesToolGateways: ["tool-gateway"], + }, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + }); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(0)"); + + expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + expect(harness.dockerStartSpy).not.toHaveBeenCalled(); + expect(harness.runAutoPairSpy).not.toHaveBeenCalled(); + expect(harness.preflightVllmSpy).not.toHaveBeenCalled(); + expect(harness.readSandboxConfigSpy).not.toHaveBeenCalled(); + expect(harness.writeSandboxConfigSpy).not.toHaveBeenCalled(); + expect(sandboxVersion.checkAgentVersion).not.toHaveBeenCalled(); + expect(brokerSpy).not.toHaveBeenCalled(); + const connectCall = harness.runSandboxExecChildSpy.mock.calls.find( + ([command, args]) => + command === "/usr/bin/openshell" && + Array.isArray(args) && + args.join("\0") === ["sandbox", "connect", "-g", "nemoclaw", "alpha"].join("\0"), + ); + expect(connectCall?.[2]).toMatchObject({ + hostEnv: expect.not.objectContaining({ + NVIDIA_INFERENCE_API_KEY: expect.anything(), + GITHUB_TOKEN: expect.anything(), + AWS_SECRET_ACCESS_KEY: expect.anything(), + DOCKER_HOST: expect.anything(), + KUBECONFIG: expect.anything(), + SSH_AUTH_SOCK: expect.anything(), + HTTPS_PROXY: expect.anything(), + OPENSHELL_GATEWAY: expect.anything(), + }), + }); + expect(harness.recoverPortableDemoLifecycleSpy).toHaveBeenCalledTimes(5); + }); + + it("rejects external Hermes authority drift at the final connect boundary (#9203)", async () => { + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry: { + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + }, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + }); + harness.recoverPortableDemoLifecycleSpy.mockImplementation(() => + harness.recoverPortableDemoLifecycleSpy.mock.calls.length >= 5 + ? { kind: "not-installed" } + : { kind: "already-running" }, + ); + + await expect(harness.connectSandbox("alpha")).rejects.toThrow( + "lifecycle authority disappeared before interactive connect", + ); + expect( + harness.runSandboxExecChildSpy.mock.calls.some( + ([, args]) => Array.isArray(args) && args[0] === "sandbox" && args[1] === "connect", + ), + ).toBe(false); + }); + + it("fails before Docker when Hermes receipt authority disappears during probe (#9203)", async () => { + const harness = createConnectHarness({ + agentName: "hermes", + sessionAgent: { name: "hermes" }, + registryEntry: { + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + }, + portableReceiptDisposition: { kind: "hermes", phase: "active" }, + portableRecoveryResult: { kind: "already-running" }, + }); + harness.inspectPortableReceiptDispositionSpy + .mockReturnValueOnce({ + kind: "hermes", + phase: "active", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + liveIdentityFingerprint: "f".repeat(64), + }) + .mockReturnValue({ kind: "absent" }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow( + "process.exit(1)", + ); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( + "Hermes portable lifecycle authority is missing or incomplete", + ); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + expect(harness.dockerStartSpy).not.toHaveBeenCalled(); + expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled(); + }); + + it.each(["pending", "configuring"] as const)( + "rejects incomplete Hermes %s receipt before connect mutation (#9203)", + async (phase) => { + const harness = createConnectHarness({ + agentName: "hermes", + registryEntry: { openshellDriver: "docker" }, + portableReceiptDisposition: { kind: "hermes", phase }, + }); + harness.recoverPortableDemoLifecycleSpy.mockImplementation(() => { + throw new Error(`phase '${phase}' is incomplete`); + }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow( + "process.exit(1)", + ); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + expect(harness.dockerStartSpy).not.toHaveBeenCalled(); + expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled(); + }, + ); it("does not suggest a manual forward when gateway recovery fails before forward start", async () => { const harness = createConnectHarness({ processCheck: { diff --git a/src/lib/actions/sandbox/connect-hermes-light-theme.test.ts b/src/lib/actions/sandbox/connect-hermes-light-theme.test.ts index 1935df885c8..9b6cab66e56 100644 --- a/src/lib/actions/sandbox/connect-hermes-light-theme.test.ts +++ b/src/lib/actions/sandbox/connect-hermes-light-theme.test.ts @@ -223,7 +223,7 @@ describe("Hermes sandbox connect light terminal skin", () => { agentName: "hermes", registryEntries: [ { name: "alpha", agent: "hermes" }, - { name: "beta", agent: "hermes" }, + { name: "beta", agent: "hermes", provider: "ollama-local", model: "qwen3-vl:4b" }, ], sessionAgent: { name: "hermes", diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 878c2b7e643..79ed0635014 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell, + captureResolvedOpenshell, getOpenshellBinary, runOpenshell, } from "../../adapters/openshell/runtime"; @@ -78,9 +79,15 @@ import { preflightVllmModelEnvOrExit } from "./connect-vllm-preflight"; import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-failure-classifier"; import { ensureLiveSandboxOrExit, + assertHermesPortableLifecycleForConnect, + buildHermesPortableCommandAuthority, + type HermesPortableActiveLifecycleAuthority, printGatewayLifecycleHint, + qualifyPortableAgentLifecycleAuthority, recoverPortableDemoSandboxLifecycleForConnect, + requireHermesPortableActiveLifecycleAuthority, startStoppedSandboxContainerForProbeRecovery, + withConnectSandboxLifecycleLock, } from "./gateway-state"; import { getSandboxTargetGatewayName } from "./gateway-target"; import { printGatewayWedgeDiagnostics } from "./gateway-wedge-diagnostics"; @@ -293,9 +300,19 @@ async function settlePortablePairingOrExit(sandboxName: string): Promise { +async function runSandboxConnectProbe( + sandboxName: string, + { hermesPortable = false }: { hermesPortable?: boolean } = {}, +): Promise { const agent = agentRuntime.getSessionAgent(sandboxName); const agentName = agentRuntime.getAgentDisplayName(agent); + if (hermesPortable) { + verifyHermesPortableInferenceRouteOrExit(sandboxName, agent); + console.log( + ` Probe complete: ${agentName} passed receipt-owned authenticated health in '${sandboxName}'.`, + ); + return; + } if (agent && !agentRuntime.hasGatewayRuntime(agent)) { const routeResult = await ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true }); runTerminalAgentConnectProbe({ @@ -400,6 +417,110 @@ async function runSandboxConnectProbe(sandboxName: string): Promise { process.exit(1); } +function failHermesPortableInferenceRoute(sandboxName: string, reason: string): never { + console.error( + ` Error: Hermes portable inference authority for '${sandboxName}' is ${reason}. Resume the existing portable onboarding transaction or run \`${CLI_NAME} ${sandboxName} doctor\` before retrying.`, + ); + process.exit(1); +} + +function captureHermesPortableOpenShell( + sandboxName: string, + args: string[], + options: { readonly includeStreams?: boolean; readonly timeout: number }, +) { + const commandAuthority = buildHermesPortableCommandAuthority(sandboxName); + return captureResolvedOpenshell(args, { + env: commandAuthority.env, + openshellBinary: commandAuthority.executablePath, + replaceEnv: true, + ignoreError: true, + ...options, + }); +} + +function portableAgentLifecycleAuthorityDeps() { + return { readRegistry: registry.getSandbox }; +} + +/** Verify the recorded schema-5 route without invoking any inference repair. */ +function verifyHermesPortableInferenceRouteOrExit( + sandboxName: string, + agent: InferenceRouteProbeAgent, + expectedAuthority?: HermesPortableActiveLifecycleAuthority, +): SandboxEntry { + let authority: ReturnType; + try { + authority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + expectedAuthority, + portableAgentLifecycleAuthorityDeps(), + ); + } catch { + failHermesPortableInferenceRoute(sandboxName, "missing or incomplete"); + } + const sandbox = authority.entry; + const inference = registry.getSandboxEntryInference(sandbox); + if (inference.kind !== "configured") { + failHermesPortableInferenceRoute(sandboxName, "not configured"); + } + assertNoOpenShellGatewayEndpointOverride(); + const routeAuthority = { + gatewayName: sandbox.gatewayName, + lifecycleGeneration: sandbox.lifecycleGeneration, + provider: inference.provider, + model: inference.model, + } as const; + const liveResult = captureHermesPortableOpenShell( + sandboxName, + buildGatewayInferenceGetArgs(authority.gatewayName), + { timeout: OPENSHELL_PROBE_TIMEOUT_MS }, + ); + if (liveResult.status !== 0 || liveResult.error) { + failHermesPortableInferenceRoute(sandboxName, "unreachable"); + } + const live = parseGatewayInference(liveResult.output); + if (planInferenceRouteReconcile(live, inference).kind !== "aligned") { + failHermesPortableInferenceRoute(sandboxName, "different from its recorded provider or model"); + } + const probe = parseSandboxInferenceRouteProbeResult( + captureHermesPortableOpenShell( + sandboxName, + buildSandboxInferenceRouteProbeArgs(sandboxName, agent, authority.gatewayName), + { + includeStreams: true, + timeout: OPENSHELL_INFERENCE_ROUTE_PROBE_TIMEOUT_MS, + }, + ), + ); + if ( + !probe.healthy || + (inference.provider === "ollama-local" && + (probe.httpStatus === undefined || probe.httpStatus < 200 || probe.httpStatus >= 300)) + ) { + failHermesPortableInferenceRoute(sandboxName, "unreachable"); + } + let finalAuthority: ReturnType; + try { + finalAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + authority, + portableAgentLifecycleAuthorityDeps(), + ); + } catch { + failHermesPortableInferenceRoute(sandboxName, "changed during verification"); + } + if ( + finalAuthority.entry.gatewayName !== routeAuthority.gatewayName || + finalAuthority.entry.lifecycleGeneration !== routeAuthority.lifecycleGeneration || + finalAuthority.entry.provider !== routeAuthority.provider || + finalAuthority.entry.model !== routeAuthority.model + ) { + failHermesPortableInferenceRoute(sandboxName, "changed during verification"); + } + return finalAuthority.entry; +} + const GATEWAY_UNAVAILABLE_RE = /No gateway configured|No active gateway|Connection refused|client error \(Connect\)|tcp connect error|Status:\s*Disconnected/i; @@ -1042,6 +1163,11 @@ function exitWithConnectSpawnResult(sandboxName: string, result: SpawnLikeResult } type WaitForSandboxReadyOptions = { + allowDockerRuntimeInspection?: boolean; + captureSandboxList?: ( + args: string[], + options: { readonly ignoreError: true; readonly timeout: number }, + ) => ReturnType; defaultTimeoutSec?: number; retryCommand?: string; successLogs?: readonly string[]; @@ -1056,6 +1182,8 @@ export const SANDBOX_REPAIR_READY_TIMEOUT_SEC = 300; export function waitForSandboxReadyOrExit( sandboxName: string, { + allowDockerRuntimeInspection = true, + captureSandboxList = captureOpenshell, defaultTimeoutSec = 120, retryCommand = "connect", successLogs = [], @@ -1083,7 +1211,7 @@ export function waitForSandboxReadyOrExit( // Gateway selection is process-global and another CLI can change it while // this command waits. Pin each poll to the registry-recorded owner so a // same-named sandbox on a sibling gateway cannot satisfy readiness. - const result = captureOpenshell(["sandbox", "list", "-g", gatewayName], { + const result = captureSandboxList(["sandbox", "list", "-g", gatewayName], { ignoreError: true, timeout: remainingMs(), }); @@ -1109,7 +1237,7 @@ export function waitForSandboxReadyOrExit( console.error(` Run: ${CLI_NAME} ${sandboxName} status`); process.exit(1); } - if (isDockerRuntimeDown(sandboxName)) { + if (allowDockerRuntimeInspection && isDockerRuntimeDown(sandboxName)) { failConnectReadinessDockerRuntimeDown(sandboxName); } @@ -1144,7 +1272,7 @@ export function waitForSandboxReadyOrExit( console.error(` Run: ${CLI_NAME} ${sandboxName} status`); process.exit(1); } - if (isDockerRuntimeDown(sandboxName)) { + if (allowDockerRuntimeInspection && isDockerRuntimeDown(sandboxName)) { failConnectReadinessDockerRuntimeDown(sandboxName); } if (!everSeen && elapsed >= 30) { @@ -1177,54 +1305,121 @@ export function waitForSandboxReadyOrExit( */ async function runConnectEntryPreflight( sandboxName: string, - { probeOnly }: { probeOnly: boolean }, + { + probeOnly, + withinLifecycleFence, + }: { + probeOnly: boolean; + withinLifecycleFence?: (route: { + readonly hermesPortable: boolean; + readonly requalify: () => void; + }) => Promise; + }, ): Promise { - try { - assertNoOpenShellGatewayEndpointOverride(); - const registered = registry.getSandbox(sandboxName); - if (registered?.pendingRouteReservation === true) { - throw new Error( - `Sandbox '${sandboxName}' is still being created by onboarding. Wait for onboarding to finish or remove the incomplete sandbox before connecting.`, + await withConnectSandboxLifecycleLock(sandboxName, async () => { + let hermesPortable = false; + let requalify = () => undefined; + try { + assertNoOpenShellGatewayEndpointOverride(); + const authority = qualifyPortableAgentLifecycleAuthority( + sandboxName, + portableAgentLifecycleAuthorityDeps(), ); - } - if (registered) { - const gatewayName = resolveSandboxGatewayName(registered); - if (registry.getSandboxEntryInference(registered).kind === "configured") { + hermesPortable = authority.kind === "hermes"; + let hermesAuthority = hermesPortable + ? requireHermesPortableActiveLifecycleAuthority( + sandboxName, + undefined, + portableAgentLifecycleAuthorityDeps(), + ) + : null; + const registered = hermesAuthority?.entry ?? registry.getSandbox(sandboxName); + if (registered?.pendingRouteReservation === true) { + throw new Error( + `Sandbox '${sandboxName}' is still being created by onboarding. Wait for onboarding to finish or remove the incomplete sandbox before connecting.`, + ); + } + const gatewayName = registered + ? resolveSandboxGatewayName(registered) + : getSandboxTargetGatewayName(sandboxName); + if (registered && registry.getSandboxEntryInference(registered).kind === "configured") { assertSandboxGatewayRouteCompatible(sandboxName, registered, gatewayName); } - recoverPortableDemoSandboxLifecycleForConnect(sandboxName, registered, gatewayName); + const initialRecovery = recoverPortableDemoSandboxLifecycleForConnect( + sandboxName, + registered, + gatewayName, + ); + if (hermesPortable && initialRecovery.kind === "not-installed") { + throw new Error("Hermes portable lifecycle authority disappeared during connect"); + } + if (hermesAuthority) { + hermesAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + hermesAuthority, + portableAgentLifecycleAuthorityDeps(), + ); + } + requalify = () => { + if (!hermesAuthority) { + if ( + qualifyPortableAgentLifecycleAuthority( + sandboxName, + portableAgentLifecycleAuthorityDeps(), + ).kind === authority.kind + ) { + return; + } + throw new Error("portable lifecycle receipt authority changed during connect"); + } + hermesAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + hermesAuthority, + portableAgentLifecycleAuthorityDeps(), + ); + const currentGateway = resolveSandboxGatewayName(hermesAuthority.entry); + const recovery = recoverPortableDemoSandboxLifecycleForConnect( + sandboxName, + hermesAuthority.entry, + currentGateway, + ); + if (recovery.kind === "not-installed") { + throw new Error("Hermes portable lifecycle authority disappeared during connect"); + } + hermesAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + hermesAuthority, + portableAgentLifecycleAuthorityDeps(), + ); + }; + } catch (error) { + console.error(` Error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + // probe-only / recover can restart receipt-owned local inference, but they + // never select, install, or pull a model. Skip the express-vLLM model + // preflight because it only steers installation and can reject recovery on + // a stale NEMOCLAW_VLLM_MODEL. + if (!probeOnly && !hermesPortable) preflightVllmModelEnvOrExit(); + if (!hermesPortable) { + const live = await ensureLiveSandboxOrExit(sandboxName, { + allowNonReadyPhase: true, + gatewayRecovery: probeOnly ? "observe" : "recover", + }); + const livePhase = parseSandboxPhase(live.output || ""); + if ( + livePhase && + livePhase !== "Ready" && + livePhase !== "Running" && + !isTerminalSandboxPhase(livePhase) && + isDockerRuntimeDown(sandboxName) + ) { + failConnectReadinessDockerRuntimeDown(sandboxName); + } } - } catch (error) { - console.error(` Error: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - } - // probe-only / recover can restart receipt-owned local inference, but they - // never select, install, or pull a model. Skip the express-vLLM model - // preflight because it only steers installation and can reject recovery on - // a stale NEMOCLAW_VLLM_MODEL. - if (!probeOnly) preflightVllmModelEnvOrExit(); - const live = await ensureLiveSandboxOrExit(sandboxName, { - allowNonReadyPhase: true, - gatewayRecovery: probeOnly ? "observe" : "recover", + await withinLifecycleFence?.({ hermesPortable, requalify }); + requalify(); }); - - // Fast-fail on a Docker daemon outage before the probe-only health check and - // the session/recovery probes below (each can spawn 15s `openshell sandbox - // exec`/`ssh-config` calls) and before the readiness wait loop. When Docker - // is down and the sandbox is not yet ready, connect cannot make progress; - // surface the outage immediately so the user is not left waiting tens of - // seconds (or killed by an external `timeout`). Terminal phases keep their - // normal handling below (#4428). - const livePhase = parseSandboxPhase(live.output || ""); - if ( - livePhase && - livePhase !== "Ready" && - livePhase !== "Running" && - !isTerminalSandboxPhase(livePhase) && - isDockerRuntimeDown(sandboxName) - ) { - failConnectReadinessDockerRuntimeDown(sandboxName); - } } /** Print version and active-session hints on both interactive launch paths. */ @@ -1288,62 +1483,128 @@ export function completeReadinessQualifiedInteractiveSessionSetup( * process recovery, readiness polling, inference-route repair, and session * setup. Any `process.exit(...)` ends the process as it does on `connect`. */ -export async function prepareInteractiveSession( - sandboxName: string, -): Promise<{ agent: AgentDefinition | null; sb: SandboxEntry | null }> { - await runConnectEntryPreflight(sandboxName, { probeOnly: false }); - printInteractiveSessionHints(sandboxName); - - const processCheck = checkAndRecoverSandboxProcesses(sandboxName); - if ("secretBoundaryRefused" in processCheck && processCheck.secretBoundaryRefused) { - const agentName = agentRuntime.getAgentDisplayName(agentRuntime.getSessionAgent(sandboxName)); - exitOnSecretBoundaryRefusal(sandboxName, agentName, processCheck, "Connect"); - } - if ("mcpReconciliationRefused" in processCheck && processCheck.mcpReconciliationRefused) { - const agentName = agentRuntime.getAgentDisplayName(agentRuntime.getSessionAgent(sandboxName)); - exitOnMcpReconciliationRefusal(sandboxName, agentName, processCheck, "Connect"); - } - const recoveryFailureDetail = - "recoveryFailureDetail" in processCheck && processCheck.recoveryFailureDetail - ? String(processCheck.recoveryFailureDetail) - : processCheck.checked && processCheck.wasRunning === false && processCheck.recovered === false - ? "the gateway recovery attempt did not complete" - : null; - if (recoveryFailureDetail) { - const agentName = agentRuntime.getAgentDisplayName(agentRuntime.getSessionAgent(sandboxName)); - exitOnGatewayRecoveryFailure(sandboxName, agentName, recoveryFailureDetail, "Recovery"); - } - // Ensure Ollama auth proxy is running (recovers from host reboots) - ensureOllamaAuthProxy(); - - let sb: SandboxEntry | null = null; - - waitForSandboxReadyOrExit(sandboxName, { - successLogs: [" Sandbox is ready. Connecting..."], +export async function prepareInteractiveSession(sandboxName: string): Promise<{ + agent: AgentDefinition | null; + sb: SandboxEntry | null; + hermesPortable: boolean; +}> { + const prepared: { + value: { + agent: AgentDefinition | null; + sb: SandboxEntry | null; + hermesPortable: boolean; + } | null; + } = { value: null }; + await runConnectEntryPreflight(sandboxName, { + probeOnly: false, + withinLifecycleFence: async ({ hermesPortable, requalify }) => { + if (!hermesPortable) { + printInteractiveSessionHints(sandboxName); + const processCheck = checkAndRecoverSandboxProcesses(sandboxName); + if ("secretBoundaryRefused" in processCheck && processCheck.secretBoundaryRefused) { + const agentName = agentRuntime.getAgentDisplayName( + agentRuntime.getSessionAgent(sandboxName), + ); + exitOnSecretBoundaryRefusal(sandboxName, agentName, processCheck, "Connect"); + } + if ("mcpReconciliationRefused" in processCheck && processCheck.mcpReconciliationRefused) { + const agentName = agentRuntime.getAgentDisplayName( + agentRuntime.getSessionAgent(sandboxName), + ); + exitOnMcpReconciliationRefusal(sandboxName, agentName, processCheck, "Connect"); + } + const recoveryFailureDetail = + "recoveryFailureDetail" in processCheck && processCheck.recoveryFailureDetail + ? String(processCheck.recoveryFailureDetail) + : processCheck.checked && + processCheck.wasRunning === false && + processCheck.recovered === false + ? "the gateway recovery attempt did not complete" + : null; + if (recoveryFailureDetail) { + const agentName = agentRuntime.getAgentDisplayName( + agentRuntime.getSessionAgent(sandboxName), + ); + exitOnGatewayRecoveryFailure(sandboxName, agentName, recoveryFailureDetail, "Recovery"); + } + } + // Ensure Ollama auth proxy is running (recovers from host reboots) + if (!hermesPortable) ensureOllamaAuthProxy(); + waitForSandboxReadyOrExit(sandboxName, { + allowDockerRuntimeInspection: !hermesPortable, + captureSandboxList: hermesPortable + ? (args, captureOptions) => + captureHermesPortableOpenShell(sandboxName, args, captureOptions) + : undefined, + successLogs: [" Sandbox is ready. Connecting..."], + }); + requalify(); + // ── Inference route swap (#1248, #3390) ─────────────────────── + const agent = agentRuntime.getSessionAgent(sandboxName); + const sb = hermesPortable + ? verifyHermesPortableInferenceRouteOrExit(sandboxName, agent) + : await ensureSandboxInferenceRouteOrExit(sandboxName, agent); + requalify(); + if (!hermesPortable && !(await settlePortablePairingOrExit(sandboxName))) { + completeInteractiveSessionSetup(sandboxName, sb); + } + prepared.value = { agent, sb, hermesPortable }; + }, }); - - // ── Inference route swap (#1248, #3390) ─────────────────────────── - // When the user has multiple sandboxes with different providers, the - // cluster-wide inference.local route may still point at the other provider. - // After the sandbox is Ready, verify and recover the route before SSH. - const agent = agentRuntime.getSessionAgent(sandboxName); - sb = await ensureSandboxInferenceRouteOrExit(sandboxName, agent); - if (!(await settlePortablePairingOrExit(sandboxName))) { - completeInteractiveSessionSetup(sandboxName, sb); - } - - return { agent, sb }; + if (!prepared.value) throw new Error("interactive connect lifecycle did not complete"); + return prepared.value; } export async function connectSandbox( sandboxName: string, - { probeOnly = false, requireLaunchReadinessPublication = true }: SandboxConnectOptions = {}, + options: SandboxConnectOptions = {}, +): Promise { + return withConnectSandboxLifecycleLock(sandboxName, async () => { + await connectSandboxWithinLifecycleFence(sandboxName, options); + }); +} + +async function connectSandboxWithinLifecycleFence( + sandboxName: string, + { probeOnly = false, requireLaunchReadinessPublication = true }: SandboxConnectOptions, ): Promise { if (probeOnly) { let readiness = await inspectLaunchReadiness(sandboxName); let publication: Awaited>; while (true) { if (readiness.kind === "accepted") { + const authority = qualifyPortableAgentLifecycleAuthority( + sandboxName, + portableAgentLifecycleAuthorityDeps(), + ); + if (authority.kind === "hermes") { + let activeAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + undefined, + portableAgentLifecycleAuthorityDeps(), + ); + const registered = activeAuthority.entry; + const gatewayName = resolveSandboxGatewayName(registered); + const recovery = recoverPortableDemoSandboxLifecycleForConnect( + sandboxName, + registered, + gatewayName, + ); + if (recovery.kind === "not-installed") { + throw new Error("Hermes portable lifecycle authority disappeared during probe"); + } + activeAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + activeAuthority, + portableAgentLifecycleAuthorityDeps(), + ); + const verified = verifyHermesPortableInferenceRouteOrExit( + sandboxName, + readiness.agent, + activeAuthority, + ); + assertHermesPortableLifecycleForConnect(sandboxName, verified, gatewayName); + } console.log(` Probe complete: launch readiness is healthy for '${sandboxName}'.`); return; } @@ -1365,20 +1626,33 @@ export async function connectSandbox( } const publicationRequest = publicationFromDecision(sandboxName, readiness); const gated = await withLaunchReadinessMutationGate(publicationRequest, async () => { - await runConnectEntryPreflight(sandboxName, { probeOnly: true }); - // Restart a stopped container before the readiness wait. Without this step, - // OpenShell keeps reporting the stopped sandbox until the wait expires (#8967). - startStoppedSandboxContainerForProbeRecovery(sandboxName); - waitForSandboxReadyOrExit(sandboxName, { - defaultTimeoutSec: SANDBOX_REPAIR_READY_TIMEOUT_SEC, - retryCommand: "connect --probe-only", + await runConnectEntryPreflight(sandboxName, { + probeOnly: true, + withinLifecycleFence: async ({ hermesPortable, requalify }) => { + // Restart a stopped container before the readiness wait. Without this step, + // OpenShell keeps reporting the stopped sandbox until the wait expires (#8967). + if (!hermesPortable) startStoppedSandboxContainerForProbeRecovery(sandboxName); + waitForSandboxReadyOrExit(sandboxName, { + allowDockerRuntimeInspection: !hermesPortable, + captureSandboxList: hermesPortable + ? (args, captureOptions) => + captureHermesPortableOpenShell(sandboxName, args, captureOptions) + : undefined, + defaultTimeoutSec: SANDBOX_REPAIR_READY_TIMEOUT_SEC, + retryCommand: "connect --probe-only", + }); + // Re-pin and re-observe the owning gateway after a potentially long wait + // before any in-sandbox process or host-forward mutation. The readiness + // polls are already scoped to the owning gateway; this also catches + // registry changes. + if (!hermesPortable) { + await ensureLiveSandboxOrExit(sandboxName, { gatewayRecovery: "observe" }); + } + requalify(); + await runSandboxConnectProbe(sandboxName, { hermesPortable }); + requalify(); + }, }); - // Re-pin and re-observe the owning gateway after a potentially long wait - // before any in-sandbox process or host-forward mutation. The readiness - // polls are already scoped to the owning gateway; this also catches - // registry changes. - await ensureLiveSandboxOrExit(sandboxName, { gatewayRecovery: "observe" }); - await runSandboxConnectProbe(sandboxName); return publishLaunchReadiness(publicationRequest); }); if (gated.kind === "changed") { @@ -1421,7 +1695,7 @@ export async function connectSandbox( return; } - const { agent, sb } = await prepareInteractiveSession(sandboxName); + const { agent, sb, hermesPortable } = await prepareInteractiveSession(sandboxName); // Print a one-shot hint before dropping the user into the sandbox // shell so a fresh user knows the first thing to type. Without this, @@ -1451,11 +1725,70 @@ export async function connectSandbox( // OPENSHELL_SANDBOX) and covers every other interactive entry path too. console.log(""); } - prepareHermesLightTerminalSkin(sandboxName, agent, process.env); + let hermesAuthority = hermesPortable + ? requireHermesPortableActiveLifecycleAuthority( + sandboxName, + undefined, + portableAgentLifecycleAuthorityDeps(), + ) + : null; + const requalifyPortableDisposition = () => { + if (hermesAuthority) { + hermesAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + hermesAuthority, + portableAgentLifecycleAuthorityDeps(), + ); + return; + } + if ( + qualifyPortableAgentLifecycleAuthority(sandboxName, portableAgentLifecycleAuthorityDeps()) + .kind === "hermes" + ) { + throw new Error("Hermes portable lifecycle authority appeared during interactive connect"); + } + }; + const requalifyHermesPortableForConnect = (): { + readonly env: NodeJS.ProcessEnv; + readonly executablePath: string; + readonly gatewayName: string; + } => { + requalifyPortableDisposition(); + const qualified = hermesAuthority; + if (!qualified) { + throw new Error("Hermes portable registry authority changed before interactive connect"); + } + const gatewayName = resolveSandboxGatewayName(qualified.entry); + const recovery = recoverPortableDemoSandboxLifecycleForConnect( + sandboxName, + qualified.entry, + gatewayName, + ); + if (recovery.kind === "not-installed") { + throw new Error("Hermes portable lifecycle authority disappeared before interactive connect"); + } + hermesAuthority = requireHermesPortableActiveLifecycleAuthority( + sandboxName, + qualified, + portableAgentLifecycleAuthorityDeps(), + ); + return { ...buildHermesPortableCommandAuthority(sandboxName), gatewayName }; + }; + requalifyPortableDisposition(); + if (!hermesPortable) prepareHermesLightTerminalSkin(sandboxName, agent, process.env); + requalifyPortableDisposition(); + const portableAuthority = hermesPortable ? requalifyHermesPortableForConnect() : null; + const connectArgs = portableAuthority + ? ["sandbox", "connect", "-g", portableAuthority.gatewayName, sandboxName] + : ["sandbox", "connect", sandboxName]; const result = await runConnectChildWithShieldsRelockNotice( - getOpenshellBinary(), - ["sandbox", "connect", sandboxName], - { hostCwd: ROOT, stdin: true }, + portableAuthority?.executablePath ?? getOpenshellBinary(), + connectArgs, + { + hostCwd: ROOT, + stdin: true, + ...(portableAuthority ? { hostEnv: portableAuthority.env } : {}), + }, sandboxName, agent?.name === "openclaw" || sb?.agent === "openclaw", ); diff --git a/src/lib/actions/sandbox/destroy-confirmation.ts b/src/lib/actions/sandbox/destroy-confirmation.ts index 08e5f96114d..29a111663ad 100644 --- a/src/lib/actions/sandbox/destroy-confirmation.ts +++ b/src/lib/actions/sandbox/destroy-confirmation.ts @@ -5,6 +5,7 @@ import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { R, YW } from "../../cli/terminal-style"; import { prompt as askPrompt } from "../../credentials/store"; import type { DestroySandboxOptions } from "../../domain/lifecycle/options"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, @@ -21,6 +22,10 @@ function countActiveSandboxSessions(sandboxName: string): number { } } +export function assertSandboxDestroyCommandAvailable(sandboxName: string): void { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:destroy"); +} + export async function confirmSandboxDestroy( sandboxName: string, options: DestroySandboxOptions, diff --git a/src/lib/actions/sandbox/destroy-flow.test.ts b/src/lib/actions/sandbox/destroy-flow.test.ts index 0358da0c4db..07c9c798470 100644 --- a/src/lib/actions/sandbox/destroy-flow.test.ts +++ b/src/lib/actions/sandbox/destroy-flow.test.ts @@ -81,6 +81,24 @@ describe("destroySandbox flow", () => { ); }); + it("rejects schema-5 inside the destroy lifecycle fence before Docker or OpenShell (#9203)", async () => { + const harness = createDestroyHarness({ portableCommandError: "schema-5 rejected" }); + + await expect(harness.destroySandbox("alpha", { yes: true })).rejects.toThrow( + "schema-5 rejected", + ); + + expect(harness.assertHermesPortableCommandUnavailableSpy).toHaveBeenCalledWith( + "alpha", + "sandbox:destroy", + ); + expect(harness.dockerRunSpy).not.toHaveBeenCalled(); + expect(harness.dockerCaptureSpy).not.toHaveBeenCalled(); + expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); + expect(harness.removeSandboxSpy).not.toHaveBeenCalled(); + expect(harness.retirePortableLifecycleReceiptSpy).not.toHaveBeenCalled(); + }); + it( "removes the owned managed Hermes state volume after confirmed sandbox deletion", { timeout: 30_000 }, diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index ac6e88da7a9..298cea9d63b 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -40,7 +40,10 @@ import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; import * as onboardSession from "../../state/onboard-session"; import { resolveNemoclawStateDir } from "../../state/paths"; import * as registry from "../../state/registry"; -import { confirmSandboxDestroy } from "./destroy-confirmation"; +import { + assertSandboxDestroyCommandAvailable, + confirmSandboxDestroy, +} from "./destroy-confirmation"; import { executeSandboxDestroy, redactDestroyError, @@ -464,7 +467,10 @@ export async function destroySandbox( sandboxName: string, options: string[] | DestroySandboxOptions = {}, ): Promise { - return withMcpLifecycleLock(sandboxName, () => destroySandboxUnlocked(sandboxName, options)); + return withMcpLifecycleLock(sandboxName, () => { + assertSandboxDestroyCommandAvailable(sandboxName); + return destroySandboxUnlocked(sandboxName, options); + }); } async function destroySandboxUnlocked( diff --git a/src/lib/actions/sandbox/doctor-flow.test.ts b/src/lib/actions/sandbox/doctor-flow.test.ts index ce61d293ae2..c3d299f7090 100644 --- a/src/lib/actions/sandbox/doctor-flow.test.ts +++ b/src/lib/actions/sandbox/doctor-flow.test.ts @@ -8,12 +8,41 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; -type RunSandboxDoctor = typeof import("./doctor")["runSandboxDoctor"]; +type RunSandboxDoctor = (typeof import("./doctor"))["runSandboxDoctor"]; +type PortableAgentReceiptDisposition = ReturnType< + (typeof import("../../onboard/experimental/portable-agent-lifecycle"))["inspectPortableAgentReceiptDisposition"] +>; +type WithMcpLifecycleLock = + (typeof import("../../state/mcp-lifecycle-lock-acquisition"))["withMcpLifecycleLock"]; + +function hermesPortableDisposition(phase: "pending" | "configuring" | "active") { + return { + kind: "hermes" as const, + phase, + gatewayName: "nemoclaw-19080", + lifecycleGeneration: "generation-1", + liveIdentityFingerprint: phase === "pending" ? null : "fingerprint-1", + }; +} + +type DoctorHarnessOptions = { + portableDisposition?: + | PortableAgentReceiptDisposition + | Error + | (() => PortableAgentReceiptDisposition | Error); + registryEntry?: "present" | "missing"; + registryAgent?: "openclaw" | "hermes"; + registryOverrides?: Record; + withMcpLifecycleLock?: WithMcpLifecycleLock; +}; const requireDist = createRequire(import.meta.url); const doctorModulePath = "./doctor.js"; -function createDoctorHarness(provider = "ollama-local"): { +function createDoctorHarness( + provider = "ollama-local", + options: DoctorHarnessOptions = {}, +): { buildToolScopeChecksSpy: MockInstance; captureOpenShellSpy: MockInstance; captureHostCommandSpy: MockInstance; @@ -34,6 +63,7 @@ function createDoctorHarness(provider = "ollama-local"): { resolveOpenShellSpy: MockInstance; resolveSandboxGatewayNameSpy: MockInstance; runSandboxDoctor: RunSandboxDoctor; + withMcpLifecycleLockSpy: MockInstance; } { delete require.cache[requireDist.resolve(doctorModulePath)]; @@ -58,10 +88,41 @@ function createDoctorHarness(provider = "ollama-local"): { const doctorHostCommand = requireDist("./doctor-host-command.js"); const doctorToolScope = requireDist("./doctor-tool-scope.js"); const inferenceRouteHealth = requireDist("./inference-route-health.js"); + const portableAgentLifecycle = requireDist( + "../../onboard/experimental/portable-agent-lifecycle.js", + ); + const doctorSystemChecks = requireDist("./doctor-system-checks.js"); + + const qualifyPortableAgentLifecycleAuthority = + portableAgentLifecycle.qualifyPortableAgentLifecycleAuthority; + vi.spyOn(doctorSystemChecks, "inspectSandboxDoctorPortableAuthority").mockImplementation((( + sandboxName: string, + ) => { + const disposition = + typeof options.portableDisposition === "function" + ? options.portableDisposition() + : options.portableDisposition; + switch (disposition instanceof Error) { + case true: + throw disposition; + default: + return qualifyPortableAgentLifecycleAuthority(sandboxName, { + inspectReceiptDisposition: () => disposition ?? { kind: "absent" }, + readRegistry: () => + options.registryEntry === "missing" ? null : (registryEntry as never), + }); + } + }) as never); + const withMcpLifecycleLockSpy = vi + .spyOn(doctorSystemChecks, "withSandboxDoctorLifecycleLock") + .mockImplementation( + (options.withMcpLifecycleLock ?? + (async (_sandboxName: string, operation: () => unknown) => await operation())) as never, + ); - const getSandboxSpy = vi.spyOn(registry, "getSandbox").mockReturnValue({ + const registryEntry = { name: "alpha", - agent: "openclaw", + agent: options.registryAgent ?? "openclaw", model: "registry-model", provider, openshellDriver: "docker", @@ -72,8 +133,14 @@ function createDoctorHarness(provider = "ollama-local"): { imageTag: "nemoclaw-openclaw:test", gatewayName: "nemoclaw-19080", gatewayPort: 19080, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: "fingerprint-1", messaging: undefined, - }); + ...options.registryOverrides, + }; + const getSandboxSpy = vi + .spyOn(registry, "getSandbox") + .mockReturnValue(options.registryEntry === "missing" ? null : registryEntry); const configuredMessagingChannelsSpy = vi .spyOn(registry, "getConfiguredMessagingChannelsFromEntry") .mockReturnValue([]); @@ -230,6 +297,7 @@ function createDoctorHarness(provider = "ollama-local"): { resolveOpenShellSpy, resolveSandboxGatewayNameSpy, runSandboxDoctor, + withMcpLifecycleLockSpy, }; } @@ -247,6 +315,147 @@ describe("runSandboxDoctor flow", () => { delete require.cache[requireDist.resolve(doctorModulePath)]; }); + it.each(["pending", "configuring", "active"] as const)( + "reports Hermes portable receipt phase %s without Docker or OpenClaw doctor work (#9203)", + testTimeoutOptions(30_000), + async (phase) => { + const harness = createDoctorHarness("ollama-local", { + portableDisposition: hermesPortableDisposition(phase), + registryEntry: phase === "pending" ? "missing" : "present", + registryAgent: "hermes", + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report).toMatchObject({ + sandbox: "alpha", + status: phase === "active" ? "ok" : "warn", + checks: [ + { + label: "Portable lifecycle", + detail: `agent=Hermes; phase=${phase}`, + }, + ], + }); + expect(harness.captureOpenShellSpy).not.toHaveBeenCalled(); + expect(harness.captureHostCommandSpy).not.toHaveBeenCalled(); + expect(harness.recoverNamedGatewayRuntimeSpy).not.toHaveBeenCalled(); + expect(harness.executeSandboxCommandForVerificationSpy).not.toHaveBeenCalled(); + expect(harness.withMcpLifecycleLockSpy).toHaveBeenCalledWith("alpha", expect.any(Function)); + }, + ); + + it("renders plain Hermes portable doctor output without recovery (#9203)", async () => { + const harness = createDoctorHarness("ollama-local", { + portableDisposition: hermesPortableDisposition("active"), + registryAgent: "hermes", + }); + + await expect(harness.runSandboxDoctor("alpha")).resolves.toBeUndefined(); + + expect(harness.logSpy.mock.calls.flat().join("\n")).toContain( + "Portable lifecycle: agent=Hermes; phase=active", + ); + expect(harness.captureHostCommandSpy).not.toHaveBeenCalled(); + expect(harness.recoverNamedGatewayRuntimeSpy).not.toHaveBeenCalled(); + }); + + it("releases the lifecycle lock before a failing doctor report exits (#9203)", async () => { + const events: string[] = []; + const harness = createDoctorHarness("ollama-local", { + withMcpLifecycleLock: async (_sandboxName, operation) => { + events.push("lock-enter"); + try { + return await operation(); + } finally { + events.push("lock-exit"); + } + }, + }); + exitSpy.mockImplementationOnce(((code?: number) => { + events.push(`exit-${String(code)}`); + throw new Error(`process.exit(${String(code)})`); + }) as never); + + await expect(harness.runSandboxDoctor("alpha")).rejects.toThrow("process.exit(1)"); + expect(events).toEqual(["lock-enter", "lock-exit", "exit-1"]); + }); + + it("rejects malformed portable receipt authority before doctor probes (#9203)", async () => { + const harness = createDoctorHarness("ollama-local", { + portableDisposition: new Error("invalid portable lifecycle receipt"), + }); + + await expect( + harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }), + ).rejects.toThrow("invalid portable lifecycle receipt"); + expect(harness.captureOpenShellSpy).not.toHaveBeenCalled(); + expect(harness.captureHostCommandSpy).not.toHaveBeenCalled(); + }); + + it.each([ + { field: "gatewayName", value: "other-gateway" }, + { field: "lifecycleGeneration", value: "other-generation" }, + { field: "lifecycleLiveIdentityFingerprint", value: "other-fingerprint" }, + ] as const)("rejects Hermes portable registry disagreement in $field (#9203)", async (drift) => { + const harness = createDoctorHarness("ollama-local", { + portableDisposition: hermesPortableDisposition("active"), + registryAgent: "hermes", + registryOverrides: { [drift.field]: drift.value }, + }); + + await expect( + harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }), + ).rejects.toThrow("receipt and registry authority disagree"); + expect(harness.captureOpenShellSpy).not.toHaveBeenCalled(); + expect(harness.captureHostCommandSpy).not.toHaveBeenCalled(); + }); + + it("rejects an active Hermes receipt with no registry row (#9203)", async () => { + const harness = createDoctorHarness("ollama-local", { + portableDisposition: hermesPortableDisposition("active"), + registryEntry: "missing", + }); + + await expect( + harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }), + ).rejects.toThrow("missing its registry authority"); + expect(harness.captureOpenShellSpy).not.toHaveBeenCalled(); + expect(harness.captureHostCommandSpy).not.toHaveBeenCalled(); + }); + + it("preserves schema-4 OpenClaw doctor behavior under the lifecycle fence (#9203)", async () => { + const harness = createDoctorHarness("ollama-local", { + portableDisposition: { kind: "openclaw" }, + }); + + await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(harness.captureOpenShellSpy).toHaveBeenCalled(); + expect(harness.captureHostCommandSpy).toHaveBeenCalled(); + expect(harness.withMcpLifecycleLockSpy).toHaveBeenCalledWith("alpha", expect.any(Function)); + }); + + it("classifies publication while waiting for the doctor lifecycle fence (#9203)", async () => { + let disposition: PortableAgentReceiptDisposition = { kind: "absent" }; + const harness = createDoctorHarness("ollama-local", { + portableDisposition: () => disposition, + registryAgent: "hermes", + withMcpLifecycleLock: async (_sandboxName, operation) => { + disposition = hermesPortableDisposition("active"); + return await operation(); + }, + }); + + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + + expect(report?.checks).toEqual([ + expect.objectContaining({ detail: "agent=Hermes; phase=active" }), + ]); + expect(harness.captureOpenShellSpy).not.toHaveBeenCalled(); + expect(harness.captureHostCommandSpy).not.toHaveBeenCalled(); + }); + it( "builds a JSON report with host, gateway, sandbox, inference, messaging, and local-service checks", testTimeoutOptions(30_000), @@ -325,34 +534,37 @@ describe("runSandboxDoctor flow", () => { it.each([ ["high", "high"], [null, "endpoint-default"], - ] as const)("reports effective reasoning effort in doctor JSON (%s) (#7659)", async (stored, expected) => { - const harness = createDoctorHarness("compatible-endpoint"); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent: "openclaw", - model: "registry-model", - provider: "compatible-endpoint", - preferredInferenceApi: "openai-completions", - compatibleEndpointReasoningEffort: stored, - openshellDriver: "docker", - openshellVersion: "0.0.72", - nemoclawVersion: "0.0.83", - fromDockerfile: null, - dashboardPort: 18789, - imageTag: "nemoclaw-openclaw:test", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - }); + ] as const)( + "reports effective reasoning effort in doctor JSON (%s) (#7659)", + async (stored, expected) => { + const harness = createDoctorHarness("compatible-endpoint"); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent: "openclaw", + model: "registry-model", + provider: "compatible-endpoint", + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoningEffort: stored, + openshellDriver: "docker", + openshellVersion: "0.0.72", + nemoclawVersion: "0.0.83", + fromDockerfile: null, + dashboardPort: 18789, + imageTag: "nemoclaw-openclaw:test", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + }); - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - expect(report?.checks).toContainEqual({ - group: "Inference", - label: "Reasoning effort", - status: "info", - detail: expected, - }); - }); + expect(report?.checks).toContainEqual({ + group: "Inference", + label: "Reasoning effort", + status: "info", + detail: expected, + }); + }, + ); it( "reports baseline exclusions and flags content drift since approval (#7194)", @@ -500,47 +712,47 @@ describe("runSandboxDoctor flow", () => { ); }); - it.each([ - "openclaw", - "hermes", - ] as const)("keeps serving-process health explicitly unchecked for the %s gateway (#7003)", async (agent) => { - const harness = createDoctorHarness(); - harness.loadAgentSpy.mockReturnValue({ - name: agent, - runtime: { kind: "gateway" }, - configPaths: { - dir: "/sandbox/.agent", - configFile: "config.json", - format: "json", - }, - }); - harness.getSandboxSpy.mockReturnValue({ - name: "alpha", - agent, - model: "registry-model", - provider: "ollama-local", - openshellDriver: "docker", - openshellVersion: "0.0.72", - nemoclawVersion: "0.0.83", - fromDockerfile: null, - dashboardPort: 18789, - imageTag: "nemoclaw-openclaw:test", - gatewayName: "nemoclaw-19080", - gatewayPort: 19080, - }); + it.each(["openclaw", "hermes"] as const)( + "keeps serving-process health explicitly unchecked for the %s gateway (#7003)", + async (agent) => { + const harness = createDoctorHarness(); + harness.loadAgentSpy.mockReturnValue({ + name: agent, + runtime: { kind: "gateway" }, + configPaths: { + dir: "/sandbox/.agent", + configFile: "config.json", + format: "json", + }, + }); + harness.getSandboxSpy.mockReturnValue({ + name: "alpha", + agent, + model: "registry-model", + provider: "ollama-local", + openshellDriver: "docker", + openshellVersion: "0.0.72", + nemoclawVersion: "0.0.83", + fromDockerfile: null, + dashboardPort: 18789, + imageTag: "nemoclaw-openclaw:test", + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + }); - const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); + const report = await harness.runSandboxDoctor("alpha", ["--json"], { quietJson: true }); - expect(harness.loadAgentSpy).toHaveBeenCalledWith(agent); - expect(report?.checks).toContainEqual( - expect.objectContaining({ - group: "Inference", - label: "Serving process", - status: "info", - detail: "not checked — serving-process probing is not implemented", - }), - ); - }); + expect(harness.loadAgentSpy).toHaveBeenCalledWith(agent); + expect(report?.checks).toContainEqual( + expect.objectContaining({ + group: "Inference", + label: "Serving process", + status: "info", + detail: "not checked — serving-process probing is not implemented", + }), + ); + }, + ); it("rejects mutating --fix when JSON output was requested", async () => { const harness = createDoctorHarness(); diff --git a/src/lib/actions/sandbox/doctor-system-checks.ts b/src/lib/actions/sandbox/doctor-system-checks.ts index 814c90fedd0..3ad56d794d7 100644 --- a/src/lib/actions/sandbox/doctor-system-checks.ts +++ b/src/lib/actions/sandbox/doctor-system-checks.ts @@ -12,6 +12,8 @@ import { resolveCurrentRuntimeProviderBundle, resolveRuntimeProviderBundle, } from "../../onboard/runtime-provider/access"; +import { qualifyPortableAgentLifecycleAuthority } from "../../onboard/experimental/portable-agent-lifecycle"; +import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; import type { SandboxEntry } from "../../state/registry"; import { readCloudflaredState } from "../../tunnel/services"; import { @@ -21,6 +23,15 @@ import { import { captureHostCommand } from "./doctor-host-command"; import type { DoctorCheck } from "./doctor-report"; +export const withSandboxDoctorLifecycleLock = withMcpLifecycleLock; + +export function inspectSandboxDoctorPortableAuthority( + sandboxName: string, + readRegistry: (sandboxName: string) => SandboxEntry | null, +) { + return qualifyPortableAgentLifecycleAuthority(sandboxName, { readRegistry }); +} + export function oneLine(value = ""): string { return String(value).replace(/\s+/g, " ").trim(); } diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index a3d4d1f3eba..64b281d7320 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -66,9 +66,11 @@ import { dockerInspectGateway, findSandboxListLine, inferSandboxReadyFromLine, + inspectSandboxDoctorPortableAuthority, ollamaDoctorCheck, oneLine, shouldInspectLegacyGatewayContainer, + withSandboxDoctorLifecycleLock, } from "./doctor-system-checks"; import { buildToolScopeChecks } from "./doctor-tool-scope"; @@ -93,6 +95,22 @@ type SandboxProbe = { reachable: boolean; }; +function hermesPortableDoctorReport( + sandboxName: string, + phase: "pending" | "configuring" | "active", +): DoctorReport { + const active = phase === "active"; + return buildDoctorReport(sandboxName, [ + { + group: "Sandbox", + label: "Portable lifecycle", + status: active ? "ok" : "warn", + detail: `agent=Hermes; phase=${phase}`, + ...(active ? {} : { hint: "resume the existing Hermes portable onboarding transaction" }), + }, + ]); +} + function parseDoctorIntent(sandboxName: string, args: string[]): DoctorIntent | null { const asJson = args.includes("--json"); const wantsFix = args.includes("--fix"); @@ -598,6 +616,15 @@ async function collectDoctorChecks( ]; } +function resolveDoctorGatewayName(sb: SandboxEntry | null | undefined): string | null { + if (!sb) return resolveGatewayName(GATEWAY_PORT); + try { + return resolveSandboxGatewayName(sb); + } catch { + return null; + } +} + export async function runSandboxDoctor( sandboxName: string, args: string[] = [], @@ -606,20 +633,24 @@ export async function runSandboxDoctor( const intent = parseDoctorIntent(sandboxName, args); if (!intent) return undefined; - const sb = registry.getSandbox(sandboxName); - let gatewayName: string | null = resolveGatewayName(GATEWAY_PORT); - if (sb) { - try { - gatewayName = resolveSandboxGatewayName(sb); - } catch { - gatewayName = null; + const outcome = await withSandboxDoctorLifecycleLock(sandboxName, async () => { + const portable = inspectSandboxDoctorPortableAuthority(sandboxName, registry.getSandbox); + if (portable.kind === "hermes") { + const report = hermesPortableDoctorReport(sandboxName, portable.phase); + if (intent.asJson && options.quietJson) return { report }; + const exitCode = renderDoctorReport(report, intent.asJson); + return { exitCode }; } - } - const checks = await collectDoctorChecks(sandboxName, sb, gatewayName, intent); - const report = buildDoctorReport(sandboxName, checks); - if (intent.asJson && options.quietJson) return report; - const exitCode = renderDoctorReport(report, intent.asJson); - if (exitCode !== 0) process.exit(exitCode); - return undefined; + const sb = registry.getSandbox(sandboxName); + const gatewayName = resolveDoctorGatewayName(sb); + const checks = await collectDoctorChecks(sandboxName, sb, gatewayName, intent); + const report = buildDoctorReport(sandboxName, checks); + if (intent.asJson && options.quietJson) return { report }; + + const exitCode = renderDoctorReport(report, intent.asJson); + return { exitCode }; + }); + if (outcome.exitCode && outcome.exitCode !== 0) process.exit(outcome.exitCode); + return outcome.report; } diff --git a/src/lib/actions/sandbox/download.ts b/src/lib/actions/sandbox/download.ts index c43e84ec6f3..eec3a3235b7 100644 --- a/src/lib/actions/sandbox/download.ts +++ b/src/lib/actions/sandbox/download.ts @@ -7,6 +7,8 @@ import path from "node:path"; import { captureOpenshell, runOpenshell } from "../../adapters/openshell/runtime"; import { CLI_NAME } from "../../cli/branding"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; +import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; import { ensureLiveSandboxOrExit } from "./gateway-state"; import { resolveHostPathFromCwd } from "./host-path"; import { @@ -59,6 +61,15 @@ export interface SandboxDownloadResult { export async function downloadFromSandbox( opts: SandboxDownloadOptions, +): Promise { + return withMcpLifecycleLock(opts.sandboxName, () => { + assertHermesPortableCommandUnavailable(opts.sandboxName, "sandbox:download"); + return downloadFromSandboxUnlocked(opts); + }); +} + +async function downloadFromSandboxUnlocked( + opts: SandboxDownloadOptions, ): Promise { const sandboxPath = (opts.sandboxPath ?? "").trim(); if (!sandboxPath) { diff --git a/src/lib/actions/sandbox/exec.test.ts b/src/lib/actions/sandbox/exec.test.ts index 3f1a38a2b83..ff66ea75ae1 100644 --- a/src/lib/actions/sandbox/exec.test.ts +++ b/src/lib/actions/sandbox/exec.test.ts @@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock, +})); + // Multi-line command argv dispatch and field-specific rejection coverage lives // in exec.multiline-argv.test.ts so this file stays focused on argv construction // and the workdir probe. @@ -12,11 +19,86 @@ import { computeExitCode, evaluateWorkdirProbe, execSandbox, + runSandboxExecChild, + type SandboxExecChild, type SandboxExecCleanupDeps, + type SandboxExecSignalSource, validateWorkdirOrFail, workdirMissingMessage, } from "./exec"; +function completedSpawnChild(status = 0): SandboxExecChild { + const child = { + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + once: vi.fn((event: "error" | "close", listener: (...args: unknown[]) => void) => { + const notify = { + error: () => undefined, + close: () => queueMicrotask(() => listener(status, null)), + }[event]; + notify(); + return child; + }), + }; + return child as unknown as SandboxExecChild; +} + +const signalSource: SandboxExecSignalSource = { + add: vi.fn(), + remove: vi.fn(), +}; + +describe("runSandboxExecChild spawn options", () => { + afterEach(() => { + spawnMock.mockReset(); + vi.mocked(signalSource.add).mockClear(); + vi.mocked(signalSource.remove).mockClear(); + }); + + it("forwards a supplied subprocess environment to spawn unchanged", async () => { + const subprocessEnv = { HOME: "/home/test", PATH: "/usr/bin" }; + spawnMock.mockReturnValueOnce(completedSpawnChild()); + + const result = await runSandboxExecChild( + "/usr/bin/openshell", + ["sandbox", "list"], + { stdin: false, subprocessEnv }, + undefined, + signalSource, + ); + result.releaseSignals?.(); + + expect(spawnMock).toHaveBeenCalledWith( + "/usr/bin/openshell", + ["sandbox", "list"], + expect.objectContaining({ + env: subprocessEnv, + stdio: ["ignore", "inherit", "inherit"], + }), + ); + expect(spawnMock.mock.calls[0]?.[2]?.env).toBe(subprocessEnv); + }); + + it("preserves the existing spawn options when subprocessEnv is absent", async () => { + spawnMock.mockReturnValueOnce(completedSpawnChild()); + + const result = await runSandboxExecChild( + "/usr/bin/openshell", + ["sandbox", "list"], + { stdin: false }, + undefined, + signalSource, + ); + result.releaseSignals?.(); + + expect(spawnMock).toHaveBeenCalledWith("/usr/bin/openshell", ["sandbox", "list"], { + stdio: ["ignore", "inherit", "inherit"], + }); + expect(spawnMock.mock.calls[0]?.[2]).not.toHaveProperty("env"); + }); +}); + describe("buildOpenshellExecArgs", () => { it("targets the sandbox by name and forwards the user command after --", () => { expect( @@ -262,10 +344,10 @@ describe("execSandbox policy-denial hint wiring (#5978)", () => { ); const enableAudit = vi.fn(() => {}); let exitCode = Number.NaN; - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + const exit = ((code?: number) => { exitCode = code ?? 0; throw new Error("__exec_exit__"); - }) as never); + }) as (code: number) => never; const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); await execSandbox( "wire-sbx", @@ -273,11 +355,13 @@ describe("execSandbox policy-denial hint wiring (#5978)", () => { {}, { resolveBinary: () => "openshell", + selectGateway: () => ({ outcome: "unregistered", gatewayName: null }), run: async () => { options.onRun?.(); return { status, ...(options.error ? { error: options.error } : {}) }; }, cleanupDeps: options.cleanupDeps ?? cleanupSkipped, + exit, policyHint: { now: options.now ?? (() => START_MS), env: {}, @@ -289,7 +373,6 @@ describe("execSandbox policy-denial hint wiring (#5978)", () => { }, }, ).catch(() => {}); - exitSpy.mockRestore(); errSpy.mockRestore(); return { enableAudit, exitCode, probeLogs, stderr }; }; diff --git a/src/lib/actions/sandbox/exec.ts b/src/lib/actions/sandbox/exec.ts index 66c460dc363..f5a0a2e7d7a 100644 --- a/src/lib/actions/sandbox/exec.ts +++ b/src/lib/actions/sandbox/exec.ts @@ -25,6 +25,7 @@ export type SandboxExecOptions = { tty?: boolean | null; timeoutSeconds?: number; stdin?: boolean; + subprocessEnv?: NodeJS.ProcessEnv; }; export type SandboxExecChildOptions = SandboxExecOptions & { @@ -246,7 +247,9 @@ const defaultSandboxExecSpawner: SandboxExecSpawner = (binary, args, options) => spawn(binary, [...args], { stdio: buildSandboxExecStdio(options), ...(options.hostCwd ? { cwd: options.hostCwd } : {}), - ...(options.hostEnv ? { env: options.hostEnv } : {}), + ...(options.hostEnv || options.subprocessEnv + ? { env: options.hostEnv ?? options.subprocessEnv } + : {}), }); const defaultSandboxExecSignalSource: SandboxExecSignalSource = { @@ -345,21 +348,24 @@ export function validateWorkdirOrFail( workdir: string, run: WorkdirProbeRunner = defaultWorkdirProbeRunner, gatewayName?: string, + exit: (code: number) => never = process.exit, ): void { const outcome = evaluateWorkdirProbe( run(binary, buildWorkdirProbeArgs(sandboxName, workdir, gatewayName)), ); if (outcome === "missing") { console.error(workdirMissingMessage(workdir)); - process.exit(1); + exit(1); } } -function defaultResolveBinary(): string { +export function resolveSandboxExecBinary(): string { const { getOpenshellBinary } = require("../../adapters/openshell/runtime"); return getOpenshellBinary(); } +const defaultResolveBinary = resolveSandboxExecBinary; + function defaultSelectGateway(sandboxName: string): GatewaySelectResult { return ( require("./gateway-select") as typeof import("./gateway-select") @@ -383,6 +389,8 @@ export type ExecSandboxDeps = { resolveSandboxAgent?: SandboxExecAgentResolver; /** Select the sandbox's owning gateway before the exec talks to OpenShell. */ selectGateway?: (sandboxName: string) => GatewaySelectResult; + /** Defer terminal process exit until an outer lifecycle lock is released. */ + exit?: (code: number) => never; }; export function isGoogleChatPairingApproval(command: readonly string[]): boolean { @@ -432,22 +440,23 @@ export async function execSandbox( deps: ExecSandboxDeps = {}, ): Promise { const { CLI_NAME } = require("../../cli/branding"); + const exit = deps.exit ?? process.exit; if (command.length === 0) { console.error( ` Usage: ${CLI_NAME} ${sandboxName} exec [--workdir ] [--tty|--no-tty] [--timeout ] [--stdin|--no-stdin] -- [args...]`, ); - process.exit(2); + exit(2); } const inputError = execInputError(command, options.workdir); if (inputError) { console.error(inputError); - process.exit(2); + exit(2); } try { assertNoOpenShellGatewayEndpointOverride(); } catch (error) { console.error(` Error: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); + exit(1); } const binary = (deps.resolveBinary ?? defaultResolveBinary)(); const gatewaySelection = (deps.selectGateway ?? defaultSelectGateway)(sandboxName); @@ -455,12 +464,19 @@ export async function execSandbox( console.error( ` Failed to select gateway '${gatewaySelection.gatewayName}' for sandbox '${sandboxName}'.`, ); - process.exit(1); + exit(1); } const gatewayName = gatewaySelection.outcome === "selected" ? gatewaySelection.gatewayName : undefined; if (options.workdir) { - validateWorkdirOrFail(binary, sandboxName, options.workdir, deps.probeWorkdir, gatewayName); + validateWorkdirOrFail( + binary, + sandboxName, + options.workdir, + deps.probeWorkdir, + gatewayName, + exit, + ); } const emitPolicyDenialHint = preparePolicyHint( CLI_NAME, @@ -507,12 +523,12 @@ export async function execSandbox( ); } if (exitCode === 0 && managedGoogleChatApproval) { - let recordedAgent: string | null; + let recordedAgent: string | null = null; try { recordedAgent = (deps.resolveSandboxAgent ?? defaultResolveSandboxAgent)(sandboxName); } catch { console.error(googleChatPairingActivationFailureMessage(CLI_NAME, sandboxName)); - process.exit(1); + exit(1); } if (recordedAgent === "openclaw") { let restartSucceeded = false; @@ -528,5 +544,5 @@ export async function execSandbox( } } } - process.exit(exitCode); + exit(exitCode); } diff --git a/src/lib/actions/sandbox/gateway-restart.test.ts b/src/lib/actions/sandbox/gateway-restart.test.ts index 8e4285639e7..d1058a7957a 100644 --- a/src/lib/actions/sandbox/gateway-restart.test.ts +++ b/src/lib/actions/sandbox/gateway-restart.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_RESTART_MARKERS as MARKERS } from "../../agent/gateway-restart-markers"; import * as agentRuntime from "../../agent/runtime"; +import * as portableAgentLifecycle from "../../onboard/experimental/portable-agent-lifecycle"; import * as registry from "../../state/registry"; import { classifyGatewayRestartFailure } from "./gateway-restart"; import { restartSandboxGateway } from "./process-recovery"; @@ -144,6 +145,22 @@ describe("restartSandboxGateway — host-mediated gateway restart", () => { }; } + it("rejects schema-5 inside the gateway restart lifecycle fence (#9203)", () => { + vi.spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable").mockImplementation( + () => { + throw new Error("schema-5 rejected"); + }, + ); + const deps = baseDeps(); + + expect(() => restartSandboxGateway("alpha", { quiet: true, deps })).toThrow( + "schema-5 rejected", + ); + + expect(deps.requestGatewaySupervisorAction).not.toHaveBeenCalled(); + expect(deps.executeSandboxExecCommand).not.toHaveBeenCalled(); + }); + it("refuses supervisor output without a completion marker", () => { const deps = baseDeps({ getSandbox: () => ({ name: "openclaw-box", agent: "openclaw" }), diff --git a/src/lib/actions/sandbox/gateway-restart.ts b/src/lib/actions/sandbox/gateway-restart.ts index ae4ec480355..2e703a08639 100644 --- a/src/lib/actions/sandbox/gateway-restart.ts +++ b/src/lib/actions/sandbox/gateway-restart.ts @@ -8,6 +8,18 @@ import { redactFull, redactUrl } from "../../security/redact"; import { URL_TOKEN_PATTERN } from "../../security/redact-url"; import { hermesMcpReconciliationRemediationLines } from "./mcp-bridge-hermes-reconciliation"; import { inspectHermesMcpReconciliationRefusal } from "./mcp-bridge-recovery"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; +import { withMcpLifecycleLockSync } from "../../state/mcp-lifecycle-lock-acquisition"; + +export function withUnsupportedHermesPortableGatewayRestartFence( + sandboxName: string, + operation: () => T, +): T { + return withMcpLifecycleLockSync(sandboxName, () => { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:gateway:restart"); + return operation(); + }); +} export type GatewayRestartCommandResult = { status: number; diff --git a/src/lib/actions/sandbox/gateway-state-observe-mode.test.ts b/src/lib/actions/sandbox/gateway-state-observe-mode.test.ts index 297218e42fa..c4721cb30b0 100644 --- a/src/lib/actions/sandbox/gateway-state-observe-mode.test.ts +++ b/src/lib/actions/sandbox/gateway-state-observe-mode.test.ts @@ -97,4 +97,18 @@ describe("getReconciledSandboxGatewayState observe mode", () => { expect(recover).not.toHaveBeenCalled(); expect(result).toMatchObject({ state: "present" }); }); + + it("keeps receipt-owned observation scoped without changing global gateway selection (#9203)", async () => { + const getState = vi.fn().mockResolvedValue({ state: "present", output: "Phase: Ready" }); + + const result = await getReconciledSandboxGatewayState("beta", { + getState, + gatewayRecovery: "observe", + selectOwningGateway: false, + }); + + expect(gatewaySelect.selectSandboxOwningGateway).not.toHaveBeenCalled(); + expect(getState).toHaveBeenCalledWith("beta", "nemoclaw-8091"); + expect(result).toMatchObject({ state: "present" }); + }); }); diff --git a/src/lib/actions/sandbox/gateway-state.ts b/src/lib/actions/sandbox/gateway-state.ts index 00b0936d257..d10dfa1e679 100644 --- a/src/lib/actions/sandbox/gateway-state.ts +++ b/src/lib/actions/sandbox/gateway-state.ts @@ -13,6 +13,10 @@ import { import { gatewayStartGuidance } from "../../gateway-start-guidance"; import { assertNoOpenShellGatewayEndpointOverride } from "../../openshell-gateway-endpoint-guard"; import { isTerminalSandboxPhase, parseSandboxPhase } from "../../state/gateway"; +import { + withMcpLifecycleLock, + withMcpLifecycleLockSync, +} from "../../state/mcp-lifecycle-lock-acquisition"; import { selectSandboxOwningGateway } from "./gateway-select"; import { gatewayNamePattern, @@ -50,9 +54,15 @@ import { recoverDockerDriverSandbox, } from "../../onboard/docker-driver-sandbox-recovery"; import { - type PortableDemoLifecycleRecoveryResult, - recoverPortableDemoSandboxLifecycle, -} from "../../onboard/experimental/portable-demo-lifecycle"; + assertHermesPortableAgentLifecycleAuthority, + buildHermesPortableCommandEnvironment, + buildHermesPortableCommandAuthority, + inspectPortableAgentReceiptDisposition, + qualifyPortableAgentLifecycleAuthority, + recoverPortableAgentSandboxLifecycle, + requireHermesPortableActiveLifecycleAuthority, +} from "../../onboard/experimental/portable-agent-lifecycle"; +import type { PortableDemoLifecycleRecoveryResult } from "../../onboard/experimental/portable-demo-lifecycle"; import { compareAndSetLegacySandboxLifecycleGeneration } from "../../state/registry/lifecycle-generation"; import type { SandboxEntry } from "../../state/registry/types"; import { getSandboxDockerRuntime } from "./docker-health"; @@ -81,6 +91,22 @@ export type SandboxGatewayState = { recoverySandboxVia?: string | null; }; +export type { + HermesPortableActiveLifecycleAuthority, + HermesPortableAgentLifecycleAuthority, + PortableAgentReceiptDisposition, +} from "../../onboard/experimental/portable-agent-lifecycle"; +export { + buildHermesPortableCommandAuthority, + buildHermesPortableCommandEnvironment, + inspectPortableAgentReceiptDisposition, + qualifyPortableAgentLifecycleAuthority, + requireHermesPortableActiveLifecycleAuthority, +}; +export const withSandboxLifecycleLock = withMcpLifecycleLock; +export const withSandboxLifecycleLockSync = withMcpLifecycleLockSync; +export const withConnectSandboxLifecycleLock = withMcpLifecycleLock; + type SandboxGatewayStateLookup = ( sandboxName: string, gatewayName?: string, @@ -97,19 +123,22 @@ export function recoverPortableDemoSandboxLifecycleForConnect( sandbox: SandboxEntry | null, gatewayName: string, ): PortableDemoLifecycleRecoveryResult { - if (!sandbox || sandbox.openshellDriver !== "docker") return { kind: "not-installed" }; - return recoverPortableDemoSandboxLifecycle( + return recoverPortableAgentSandboxLifecycle( sandboxName, { - agent: sandbox.agent, + agent: sandbox?.agent, gatewayName, - lifecycleGeneration: sandbox.lifecycleGeneration, - openshellDriver: sandbox.openshellDriver, - provider: sandbox.provider, + lifecycleGeneration: sandbox?.lifecycleGeneration, + openshellDriver: sandbox?.openshellDriver, + provider: sandbox?.provider, }, { - backfillRegistryGeneration: (generation) => - compareAndSetLegacySandboxLifecycleGeneration(sandbox, generation), + ...(sandbox + ? { + backfillRegistryGeneration: (generation: string) => + compareAndSetLegacySandboxLifecycleGeneration(sandbox, generation), + } + : {}), openshellBinary: getOpenshellBinary(), captureOpenshell: (args, timeoutMs) => { const result = captureOpenshell([...args], { @@ -124,10 +153,30 @@ export function recoverPortableDemoSandboxLifecycleForConnect( error: result.error, }; }, + readRegistry: (name) => (sandbox?.name === name ? sandbox : null), }, ); } +/** Requalify Hermes receipt authority without starting or mutating its sandbox. */ +export function assertHermesPortableLifecycleForConnect( + sandboxName: string, + sandbox: SandboxEntry, + gatewayName: string, +): void { + assertHermesPortableAgentLifecycleAuthority( + sandboxName, + { + agent: sandbox.agent, + gatewayName, + lifecycleGeneration: sandbox.lifecycleGeneration, + openshellDriver: sandbox.openshellDriver, + provider: sandbox.provider, + }, + { readRegistry: (name: string) => (name === sandboxName ? sandbox : null) }, + ); +} + function gatewayEndpointOverrideState(): SandboxGatewayState | null { try { assertNoOpenShellGatewayEndpointOverride(); @@ -528,14 +577,18 @@ export type GatewayRecoveryMode = "observe" | "recover"; export async function getReconciledSandboxGatewayState( sandboxName: string, - opts: { getState?: SandboxGatewayStateLookup; gatewayRecovery?: GatewayRecoveryMode } = {}, + opts: { + getState?: SandboxGatewayStateLookup; + gatewayRecovery?: GatewayRecoveryMode; + selectOwningGateway?: boolean; + } = {}, ): Promise { const getState = opts.getState ?? getSandboxGatewayState; const gatewayRecovery: GatewayRecoveryMode = opts.gatewayRecovery ?? "recover"; let targetGatewayName = getKnownSandboxTargetGatewayName(sandboxName) ?? undefined; const endpointOverride = gatewayEndpointOverrideState(); if (endpointOverride) return endpointOverride; - if (targetGatewayName) { + if (targetGatewayName && opts.selectOwningGateway !== false) { // Keep OpenShell's active selection aligned for downstream operations, but // never trust that process-global state for this lookup: another CLI can // change it immediately after selection. The explicit gateway argument @@ -654,9 +707,19 @@ export async function ensureLiveSandboxOrExit( { allowNonReadyPhase = false, gatewayRecovery = "recover", - }: { allowNonReadyPhase?: boolean; gatewayRecovery?: GatewayRecoveryMode } = {}, + selectOwningGateway = true, + exit = process.exit, + }: { + allowNonReadyPhase?: boolean; + gatewayRecovery?: GatewayRecoveryMode; + selectOwningGateway?: boolean; + exit?: (code: number) => never; + } = {}, ): Promise { - const lookup = await getReconciledSandboxGatewayState(sandboxName, { gatewayRecovery }); + const lookup = await getReconciledSandboxGatewayState(sandboxName, { + gatewayRecovery, + selectOwningGateway, + }); if (lookup.state === "present") { const phase = parseSandboxPhase(lookup.output || ""); if (!allowNonReadyPhase && phase && phase !== "Ready" && phase !== "Running") { @@ -666,14 +729,14 @@ export async function ensureLiveSandboxOrExit( // keep the rebuild guidance so a genuine failure is never masked. if (!isTerminalSandboxPhase(phase) && isDockerRuntimeDown(sandboxName)) { printDockerRuntimeDownGuidance(sandboxName); - process.exit(1); + exit(1); } const dockerRuntime = getSandboxDockerRuntime(sandboxName); if (dockerRuntime.containerName && !dockerRuntime.running && !dockerRuntime.paused) { console.error(` Sandbox '${sandboxName}' is stopped.`); console.error(" Workspace state is preserved."); console.error(` Start it again with \`${CLI_NAME} ${sandboxName} start\`.`); - process.exit(1); + exit(1); } if (phase === "Error" && dockerRuntime.paused && dockerRuntime.containerName) { console.error(` Sandbox '${sandboxName}' is stuck in '${phase}' phase.`); @@ -686,7 +749,7 @@ export async function ensureLiveSandboxOrExit( ); console.error(" Resume it to restore the running phase:"); console.error(` ${D}docker unpause ${dockerRuntime.containerName}${R}`); - process.exit(1); + exit(1); } console.error(` Sandbox '${sandboxName}' is stuck in '${phase}' phase.`); console.error( @@ -705,13 +768,13 @@ export async function ensureLiveSandboxOrExit( ` Run \`${CLI_NAME} ${sandboxName} rebuild --yes\` to recreate the sandbox (--yes skips the confirmation prompt; workspace state will be preserved).`, ); } - process.exit(1); + exit(1); } return lookup; } if (lookup.state === "gateway_schema_mismatch") { console.error(lookup.output); - process.exit(1); + exit(1); } if (lookup.state === "missing") { const targetGatewayName = getSandboxTargetGatewayName(sandboxName); @@ -722,7 +785,7 @@ export async function ensureLiveSandboxOrExit( } else { printGatewayLifecycleHint(guard.status || "", sandboxName, console.error); } - process.exit(1); + exit(1); } // The sandbox is absent from a healthy NemoClaw gateway, but the local // registry entry still holds the metadata that `rebuild` / `onboard @@ -742,11 +805,11 @@ export async function ensureLiveSandboxOrExit( console.error( ` If the sandbox was intentionally deleted, run \`${CLI_NAME} ${sandboxName} destroy\` to remove the stale local entry, or \`${CLI_NAME} onboard\` to create a new one.`, ); - process.exit(1); + exit(1); } if (lookup.state === "wrong_gateway_active") { printWrongGatewayActiveGuidance(sandboxName, lookup.activeGateway, console.error); - process.exit(1); + exit(1); } if (lookup.state === "identity_drift") { console.error(" Gateway SSH identity changed after restart — clearing stale host keys..."); @@ -758,7 +821,10 @@ export async function ensureLiveSandboxOrExit( } catch { /* best-effort cleanup */ } - const retry = await getReconciledSandboxGatewayState(sandboxName, { gatewayRecovery }); + const retry = await getReconciledSandboxGatewayState(sandboxName, { + gatewayRecovery, + selectOwningGateway, + }); if (retry.state === "present") { console.error(" ✓ Reconnected after clearing stale SSH host keys."); return retry; @@ -772,7 +838,7 @@ export async function ensureLiveSandboxOrExit( console.error( ` Recreate this sandbox with \`${CLI_NAME} onboard\` once the gateway runtime is stable.`, ); - process.exit(1); + exit(1); } if (lookup.state === "gateway_unreachable_after_restart") { console.error( @@ -787,7 +853,7 @@ export async function ensureLiveSandboxOrExit( console.error( " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", ); - process.exit(1); + exit(1); } if (lookup.state === "gateway_error" && gatewayRecovery === "observe") { console.error( @@ -800,7 +866,7 @@ export async function ensureLiveSandboxOrExit( console.error( ` This sandbox-scoped command will not restart the shared host gateway. ${gatewayStartGuidance(getSandboxTargetGatewayName(sandboxName))} Then retry this command.`, ); - process.exit(1); + exit(1); } if (lookup.state === "gateway_missing_after_restart") { console.error( @@ -813,7 +879,7 @@ export async function ensureLiveSandboxOrExit( console.error( " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", ); - process.exit(1); + exit(1); } console.error(` Unable to verify sandbox '${sandboxName}' against the live OpenShell gateway.`); if (lookup.output) { @@ -821,5 +887,5 @@ export async function ensureLiveSandboxOrExit( } printGatewayLifecycleHint(lookup.output, sandboxName); console.error(" Check `openshell status` and the active gateway, then retry."); - process.exit(1); + return exit(1); } diff --git a/src/lib/actions/sandbox/launch.test.ts b/src/lib/actions/sandbox/launch.test.ts index 787462eff1d..96193943c70 100644 --- a/src/lib/actions/sandbox/launch.test.ts +++ b/src/lib/actions/sandbox/launch.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentDefinition } from "../../agent/defs"; import * as agentDefinitions from "../../agent/defs"; @@ -15,10 +15,14 @@ const mocks = vi.hoisted(() => ({ completeInteractiveSessionSetup: vi.fn(), completeReadinessQualifiedInteractiveSessionSetup: vi.fn(), execSandbox: vi.fn(), + runSandboxExecChild: vi.fn(), + releaseSandboxExecSignals: vi.fn(), prepareHermesLightTerminalSkin: vi.fn(), inspectLaunchReadiness: vi.fn(), publishLaunchReadiness: vi.fn(), withLaunchReadinessMutationGate: vi.fn(), + inspectPortableReceiptDisposition: vi.fn(), + recoverPortableLifecycle: vi.fn(), })); vi.mock("./connect", () => ({ @@ -30,6 +34,27 @@ vi.mock("./connect", () => ({ })); vi.mock("./exec", () => ({ execSandbox: mocks.execSandbox, + resolveSandboxExecBinary: () => "openshell", + runSandboxExecChild: mocks.runSandboxExecChild, + buildOpenshellExecArgs: ( + sandboxName: string, + command: readonly string[], + options: { tty?: boolean; stdin?: boolean; timeoutSeconds?: number }, + gatewayName?: string, + ) => [ + "sandbox", + "exec", + "--name", + sandboxName, + ...(gatewayName ? ["-g", gatewayName] : []), + ...(options.tty ? ["--tty"] : []), + ...(typeof options.timeoutSeconds === "number" + ? ["--timeout", String(options.timeoutSeconds)] + : []), + "--", + ...command, + ], + wrapExecCommandWithRuntimeEnv: (command: readonly string[]) => command, })); vi.mock("./connect-hermes-light-skin", () => ({ prepareHermesLightTerminalSkin: mocks.prepareHermesLightTerminalSkin, @@ -45,6 +70,42 @@ vi.mock("./launch-readiness", () => ({ epochId: decision.fence?.epochId ?? null, }), })); +vi.mock("./gateway-state", async () => { + const lifecycle = await vi.importActual< + typeof import("../../onboard/experimental/hermes-portable-lifecycle") + >("../../onboard/experimental/hermes-portable-lifecycle"); + return { + buildHermesPortableCommandAuthority: () => ({ + env: lifecycle.hermesPortableLifecycleInternals.buildHermesPortableOpenShellEnv( + { + ...process.env, + HOME: "/home/test", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + }, + { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid: process.getuid!(), + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: "/run/user/1000", + socketPath: "/run/user/1000/podman/podman.sock", + }, + ), + executablePath: "/usr/bin/openshell", + }), + buildHermesPortableCommandEnvironment: () => ({ + HOME: "/home/test", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + }), + inspectPortableAgentReceiptDisposition: mocks.inspectPortableReceiptDisposition, + recoverPortableDemoSandboxLifecycleForConnect: mocks.recoverPortableLifecycle, + withSandboxLifecycleLock: async (_sandboxName: string, operation: () => unknown) => operation(), + }; +}); import { launchSandbox } from "./launch"; @@ -52,6 +113,8 @@ function sandboxEntry(agentName: string | null): SandboxEntry { return { name: "alpha", agent: agentName, + gatewayName: "gateway-alpha", + lifecycleGeneration: "generation-alpha", provider: null, model: null, gpuEnabled: false, @@ -59,10 +122,24 @@ function sandboxEntry(agentName: string | null): SandboxEntry { } as SandboxEntry; } -function prepareSession(agentName: string, agent: AgentDefinition | null): void { +function activeHermesDisposition() { + return { + kind: "hermes" as const, + phase: "active" as const, + gatewayName: "gateway-alpha", + lifecycleGeneration: "generation-alpha", + liveIdentityFingerprint: "f".repeat(64), + }; +} + +function prepareSession( + agentName: string, + agent: AgentDefinition | null, + hermesPortable = false, +): void { mocks.prepareInteractiveSession.mockImplementation(async () => { mocks.calls.push("prepareInteractiveSession"); - return { agent, sb: sandboxEntry(agentName) }; + return { agent, sb: sandboxEntry(agentName), hermesPortable }; }); } @@ -98,12 +175,20 @@ function createSerialTestLock(events: string[], label: string): AsyncTestLock { } describe("launchSandbox", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + beforeEach(() => { vi.clearAllMocks(); mocks.calls.length = 0; mocks.execSandbox.mockImplementation(async () => { mocks.calls.push("execSandbox"); }); + mocks.runSandboxExecChild.mockImplementation(async () => { + mocks.calls.push("runSandboxExecChild"); + return { status: 0, releaseSignals: mocks.releaseSandboxExecSignals }; + }); mocks.prepareHermesLightTerminalSkin.mockImplementation(() => { mocks.calls.push("prepareHermesLightTerminalSkin"); }); @@ -121,6 +206,8 @@ describe("launchSandbox", () => { kind: "entered", value: await operation(), })); + mocks.inspectPortableReceiptDisposition.mockReturnValue({ kind: "absent" }); + mocks.recoverPortableLifecycle.mockReturnValue({ kind: "already-running" }); // Production keeps OpenClaw null in getSessionAgent so its recovery path // continues to use the legacy defaults. The launch resolver must still // load OpenClaw's trusted manifest before choosing the interactive command. @@ -161,6 +248,144 @@ describe("launchSandbox", () => { expect(launchedCommand()).toEqual(["bash", "-lc", "hermes"]); }); + it("holds schema-5 authority through the exact interactive child execution (#9203)", async () => { + vi.stubEnv("NVIDIA_INFERENCE_API_KEY", "do-not-forward"); + vi.stubEnv("GITHUB_TOKEN", "do-not-forward"); + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "do-not-forward"); + const hermes = loadAgent("hermes"); + const entry = sandboxEntry("hermes"); + prepareSession("hermes", hermes, true); + mocks.inspectPortableReceiptDisposition.mockReturnValue(activeHermesDisposition()); + const events: string[] = []; + const childStarted = deferred(); + const releaseChild = deferred(); + const withSandboxMutationLock = createSerialTestLock(events, "sandbox"); + mocks.runSandboxExecChild.mockImplementationOnce(async () => { + events.push("child"); + childStarted.resolve(); + await releaseChild.promise; + return { status: 0, releaseSignals: mocks.releaseSandboxExecSignals }; + }); + + const launch = launchSandbox("alpha", { + getSandbox: () => entry, + resolveSandboxGatewayName: () => "gateway-alpha", + withSandboxMutationLock, + }); + await childStarted.promise; + const contender = withSandboxMutationLock("alpha", () => events.push("contender")); + await Promise.resolve(); + + expect(mocks.recoverPortableLifecycle).toHaveBeenCalledTimes(2); + expect(mocks.recoverPortableLifecycle).toHaveBeenCalledWith("alpha", entry, "gateway-alpha"); + expect(events).toEqual(["sandbox:acquired", "child"]); + expect(mocks.execSandbox).not.toHaveBeenCalled(); + expect(mocks.prepareHermesLightTerminalSkin).not.toHaveBeenCalled(); + expect(mocks.runSandboxExecChild.mock.calls[0]?.slice(0, 2)).toEqual([ + "/usr/bin/openshell", + [ + "sandbox", + "exec", + "--name", + "alpha", + "-g", + "gateway-alpha", + "--tty", + "--timeout", + "0", + "--", + "bash", + "-lc", + "hermes", + ], + ]); + expect(mocks.runSandboxExecChild.mock.calls[0]?.[2]).toMatchObject({ + subprocessEnv: expect.not.objectContaining({ + NVIDIA_INFERENCE_API_KEY: expect.anything(), + GITHUB_TOKEN: expect.anything(), + AWS_SECRET_ACCESS_KEY: expect.anything(), + }), + }); + + releaseChild.resolve(); + await launch; + await contender; + expect(events).toEqual([ + "sandbox:acquired", + "child", + "sandbox:released", + "sandbox:acquired", + "contender", + "sandbox:released", + ]); + }); + + it("rejects ordinary-to-schema-5 publication inside the launch lifecycle fence (#9203)", async () => { + const entry = sandboxEntry("hermes"); + prepareSession("hermes", loadAgent("hermes")); + mocks.inspectPortableReceiptDisposition.mockReturnValue({ kind: "absent" }); + + await expect( + launchSandbox("alpha", { + getSandbox: () => entry, + resolveSandboxGatewayName: () => "gateway-alpha", + withSandboxMutationLock: async (_sandboxName, operation) => { + mocks.inspectPortableReceiptDisposition.mockReturnValue(activeHermesDisposition()); + return await operation(); + }, + }), + ).rejects.toThrow("lifecycle authority changed"); + + expect(mocks.recoverPortableLifecycle).not.toHaveBeenCalled(); + expect(mocks.execSandbox).not.toHaveBeenCalled(); + expect(mocks.runSandboxExecChild).not.toHaveBeenCalled(); + }); + + it("does not run accepted ordinary setup when schema-5 publishes before the launch fence (#9203)", async () => { + const hermes = loadAgent("hermes"); + const entry = sandboxEntry("hermes"); + mocks.inspectLaunchReadiness.mockResolvedValue({ + kind: "accepted", + category: "accepted", + agent: hermes, + sb: entry, + }); + mocks.inspectPortableReceiptDisposition.mockReturnValue({ kind: "absent" }); + + await expect( + launchSandbox("alpha", { + getSandbox: () => entry, + resolveSandboxGatewayName: () => "gateway-alpha", + withSandboxMutationLock: async (_sandboxName, operation) => { + mocks.inspectPortableReceiptDisposition.mockReturnValue(activeHermesDisposition()); + return await operation(); + }, + }), + ).rejects.toThrow("lifecycle authority changed"); + + expect(mocks.printInteractiveSessionHints).not.toHaveBeenCalled(); + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).not.toHaveBeenCalled(); + expect(mocks.execSandbox).not.toHaveBeenCalled(); + expect(mocks.runSandboxExecChild).not.toHaveBeenCalled(); + }); + + it("rejects schema-5 retirement inside the launch lifecycle fence (#9203)", async () => { + prepareSession("hermes", loadAgent("hermes"), true); + mocks.inspectPortableReceiptDisposition.mockReturnValue(activeHermesDisposition()); + + await expect( + launchSandbox("alpha", { + withSandboxMutationLock: async (_sandboxName, operation) => { + mocks.inspectPortableReceiptDisposition.mockReturnValue({ kind: "absent" }); + return await operation(); + }, + }), + ).rejects.toThrow("lifecycle authority changed"); + + expect(mocks.recoverPortableLifecycle).not.toHaveBeenCalled(); + expect(mocks.execSandbox).not.toHaveBeenCalled(); + }); + it("holds CUA mutation authority through the exact interactive child execution (#7755)", async () => { const nemocua = { ...loadAgent("hermes"), @@ -350,6 +575,31 @@ describe("launchSandbox", () => { expect(launchedCommand()).toEqual(["bash", "-lc", "openclaw tui"]); }); + it("does not run ordinary pairing or session setup for accepted schema-5 readiness (#9203)", async () => { + const hermes = loadAgent("hermes"); + const entry = sandboxEntry("hermes"); + mocks.inspectLaunchReadiness.mockResolvedValue({ + kind: "accepted", + category: "accepted", + agent: hermes, + sb: entry, + }); + mocks.inspectPortableReceiptDisposition.mockReturnValue(activeHermesDisposition()); + + await launchSandbox("alpha", { + getSandbox: () => entry, + resolveSandboxGatewayName: () => "gateway-alpha", + withSandboxMutationLock: async (_name, operation) => await operation(), + }); + + expect(mocks.printInteractiveSessionHints).not.toHaveBeenCalled(); + expect(mocks.completeReadinessQualifiedInteractiveSessionSetup).not.toHaveBeenCalled(); + expect(mocks.completeInteractiveSessionSetup).not.toHaveBeenCalled(); + expect(mocks.prepareHermesLightTerminalSkin).not.toHaveBeenCalled(); + expect(mocks.recoverPortableLifecycle).toHaveBeenCalledTimes(2); + expect(mocks.runSandboxExecChild).toHaveBeenCalledOnce(); + }); + it("passes the qualified OpenClaw identity for legacy registry state (#9023)", async () => { const openclaw = loadAgent("openclaw"); const sb = sandboxEntry(null); diff --git a/src/lib/actions/sandbox/launch.ts b/src/lib/actions/sandbox/launch.ts index b18ba171ef2..fdf8444a1dd 100644 --- a/src/lib/actions/sandbox/launch.ts +++ b/src/lib/actions/sandbox/launch.ts @@ -2,17 +2,30 @@ // SPDX-License-Identifier: Apache-2.0 import * as agentRuntime from "../../agent/runtime"; +import type { AgentDefinition } from "../../agent/definition-types"; +import { spawnExitCode } from "../../core/process-exit"; import { requireCuaLifecycleReadiness } from "../../cua/lifecycle-readiness"; import { resolveSandboxGatewayName } from "../../gateway-runtime-action"; import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock"; -import { withMcpLifecycleLock as withSandboxMutationLock } from "../../state/mcp-lifecycle-lock-acquisition"; +import type { SandboxEntry } from "../../state/registry"; import { completeReadinessQualifiedInteractiveSessionSetup, prepareInteractiveSession, printInteractiveSessionHints, } from "./connect"; import { prepareHermesLightTerminalSkin } from "./connect-hermes-light-skin"; -import { execSandbox } from "./exec"; +import { + buildOpenshellExecArgs, + execSandbox, + runSandboxExecChild, + wrapExecCommandWithRuntimeEnv, +} from "./exec"; +import { + buildHermesPortableCommandAuthority, + inspectPortableAgentReceiptDisposition, + recoverPortableDemoSandboxLifecycleForConnect, + withSandboxLifecycleLock as withSandboxMutationLock, +} from "./gateway-state"; import { getKnownSandboxTarget } from "./gateway-target"; import { inspectLaunchReadiness, @@ -46,12 +59,17 @@ interface LaunchSandboxDeps { async function launchCuaUnderMutationLocks( sandboxName: string, deps: LaunchSandboxDeps, + beforeOrdinaryLaunch?: () => void, ): Promise { const lockSandbox = deps.withSandboxMutationLock ?? withSandboxMutationLock; const lockGateway = deps.withGatewayRouteMutationLock ?? withGatewayRouteMutationLock; const getSandbox = deps.getSandbox ?? getKnownSandboxTarget; const resolveGateway = deps.resolveSandboxGatewayName ?? resolveSandboxGatewayName; await lockSandbox(sandboxName, async () => { + if (inspectPortableAgentReceiptDisposition(sandboxName).kind === "hermes") { + throw new Error("Hermes portable lifecycle authority changed before agent launch."); + } + beforeOrdinaryLaunch?.(); const lockedEntry = getSandbox(sandboxName); if (!lockedEntry || lockedEntry.agent !== "nemocua") { throw new Error( @@ -71,6 +89,98 @@ async function launchCuaUnderMutationLocks( }); } +async function launchAgentWithPortableAuthority( + sandboxName: string, + agent: AgentDefinition | null, + entry: SandboxEntry | null, + hermesPortableSnapshot: boolean, + command: readonly string[], + deps: LaunchSandboxDeps, + beforeOrdinaryLaunch?: () => void, +): Promise { + const runOrdinaryAgent = async (): Promise => { + prepareHermesLightTerminalSkin(sandboxName, agent, process.env); + await execSandbox(sandboxName, command, { + tty: true, + stdin: true, + timeoutSeconds: 0, + }); + }; + const runHermesPortableAgent = async (gatewayName: string): Promise => { + const commandAuthority = buildHermesPortableCommandAuthority(sandboxName); + const options = { + tty: true, + stdin: true, + timeoutSeconds: 0, + subprocessEnv: commandAuthority.env, + } as const; + const result = await runSandboxExecChild( + commandAuthority.executablePath, + buildOpenshellExecArgs( + sandboxName, + wrapExecCommandWithRuntimeEnv(command), + options, + gatewayName, + ), + options, + ); + try { + if (result.error) throw result.error; + const exitCode = spawnExitCode(result); + if (exitCode !== 0) process.exit(exitCode); + } finally { + result.releaseSignals?.(); + } + }; + const lockSandbox = deps.withSandboxMutationLock ?? withSandboxMutationLock; + await lockSandbox(sandboxName, async () => { + const current = inspectPortableAgentReceiptDisposition(sandboxName); + if ((current.kind === "hermes") !== hermesPortableSnapshot) { + throw new Error("Hermes portable lifecycle authority changed before agent launch."); + } + if (current.kind !== "hermes") { + beforeOrdinaryLaunch?.(); + await runOrdinaryAgent(); + return; + } + if (current.phase !== "active") { + throw new Error("Hermes portable lifecycle authority changed before agent launch."); + } + const getSandbox = deps.getSandbox ?? getKnownSandboxTarget; + const registered = getSandbox(sandboxName); + if ( + agent?.name !== "hermes" || + entry?.agent !== "hermes" || + !registered || + registered.agent !== "hermes" || + registered.gatewayName !== entry.gatewayName || + registered.lifecycleGeneration !== entry.lifecycleGeneration || + current.gatewayName !== entry.gatewayName || + current.lifecycleGeneration !== entry.lifecycleGeneration + ) { + throw new Error("Hermes portable registry authority changed before agent launch."); + } + const gatewayName = (deps.resolveSandboxGatewayName ?? resolveSandboxGatewayName)(registered); + const recovery = recoverPortableDemoSandboxLifecycleForConnect( + sandboxName, + registered, + gatewayName, + ); + if (recovery.kind === "not-installed") { + throw new Error("Hermes portable lifecycle authority disappeared before agent launch."); + } + const finalRecovery = recoverPortableDemoSandboxLifecycleForConnect( + sandboxName, + registered, + gatewayName, + ); + if (finalRecovery.kind === "not-installed") { + throw new Error("Hermes portable lifecycle authority disappeared at agent launch."); + } + await runHermesPortableAgent(gatewayName); + }); +} + export async function launchSandbox( sandboxName: string, deps: LaunchSandboxDeps = {}, @@ -79,11 +189,25 @@ export async function launchSandbox( const enterMutationGate = deps.withLaunchReadinessMutationGate ?? withLaunchReadinessMutationGate; let decision = await inspect(sandboxName); let session: Awaited>; + let acceptedReadinessSetup: (() => void) | undefined; while (true) { if (decision.kind === "accepted") { - printInteractiveSessionHints(sandboxName); - completeReadinessQualifiedInteractiveSessionSetup(sandboxName, decision.agent, decision.sb); - session = { agent: decision.agent, sb: decision.sb }; + const acceptedDecision = decision; + const disposition = inspectPortableAgentReceiptDisposition(sandboxName); + const hermesPortable = disposition.kind === "hermes"; + acceptedReadinessSetup = () => { + printInteractiveSessionHints(sandboxName); + completeReadinessQualifiedInteractiveSessionSetup( + sandboxName, + acceptedDecision.agent, + acceptedDecision.sb, + ); + }; + session = { + agent: acceptedDecision.agent, + sb: acceptedDecision.sb, + hermesPortable, + }; break; } if ( @@ -116,7 +240,7 @@ export async function launchSandbox( session = gated.value.prepared; break; } - const { agent, sb } = session; + const { agent, sb, hermesPortable = false } = session; const isCua = sb?.agent === "nemocua"; const agentCommand = isCua ? agentRuntime.getTerminalCommand(agent, "interactive") @@ -132,8 +256,6 @@ export async function launchSandbox( // part of prepareInteractiveSession, so `launch` must call it too: without it // a Hermes TUI on a light-background terminal keeps the default dark skin, // and a switch back to a dark terminal never removes the managed skin. - prepareHermesLightTerminalSkin(sandboxName, agent, process.env); - // Run the agent through a login shell. execSandbox wraps every command in // wrapExecCommandWithRuntimeEnv (runtime-env.ts), which sources // /tmp/nemoclaw-proxy-env.sh and then unsets OPENCLAW_GATEWAY_TOKEN so @@ -143,14 +265,20 @@ export async function launchSandbox( // agent under a different auth mode than `connect` gives it, so `-l` is // load-bearing: do not flatten this to `bash -c` or to the split command. if (isCua) { - await launchCuaUnderMutationLocks(sandboxName, deps); + await launchCuaUnderMutationLocks(sandboxName, deps, () => { + acceptedReadinessSetup?.(); + prepareHermesLightTerminalSkin(sandboxName, agent, process.env); + }); return; } const command = ["bash", "-lc", agentCommand]; - await execSandbox(sandboxName, command, { - tty: true, - stdin: true, - // 0 means no timeout. Any other value kills a long interactive session. - timeoutSeconds: 0, - }); + await launchAgentWithPortableAuthority( + sandboxName, + agent, + sb, + hermesPortable, + command, + deps, + acceptedReadinessSetup, + ); } diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index cb7e698a89a..0580040f338 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -11,6 +11,7 @@ import { replayTrustedPrivateEndpoint, } from "../../security/trusted-private-endpoint"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; import type { McpBridgeEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { @@ -136,7 +137,10 @@ export async function addMcpBridge( sandboxName: string, options: McpBridgeAddOptions, ): Promise { - return withMcpLifecycleLock(sandboxName, () => addMcpBridgeUnlocked(sandboxName, options)); + return withMcpLifecycleLock(sandboxName, () => { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:mcp:add"); + return addMcpBridgeUnlocked(sandboxName, options); + }); } async function addMcpBridgeUnlocked( diff --git a/src/lib/actions/sandbox/mcp-bridge-input-runtime.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-runtime.test.ts index b3be6aa02f0..7bd46cf7d5d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-runtime.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-runtime.test.ts @@ -2,16 +2,49 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; +import * as portableAgentLifecycle from "../../onboard/experimental/portable-agent-lifecycle"; import { addMcpBridge, buildMcpBridgeProviderArgs, dispatchMcpBridgeCommand, redactCredentialValuesForDisplay, + removeMcpBridge, + restartMcpBridge, resolveCredentialEnv, } from "./mcp-bridge"; describe("MCP input runtime boundaries", () => { + it("rejects schema-5 MCP mutations inside their lifecycle fences (#9203)", async ({ + onTestFinished, + }) => { + const guard = vi + .spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable") + .mockImplementation(() => { + throw new Error("schema-5 rejected"); + }); + onTestFinished(() => guard.mockRestore()); + + await expect( + addMcpBridge("missing-sandbox", { + server: "github", + url: "https://mcp.example.test/mcp", + env: [{ name: "TOKEN" }], + }), + ).rejects.toThrow("schema-5 rejected"); + await expect(removeMcpBridge("missing-sandbox", "github")).rejects.toThrow( + "schema-5 rejected", + ); + await expect(restartMcpBridge("missing-sandbox", "github")).rejects.toThrow( + "schema-5 rejected", + ); + expect(guard.mock.calls.map((call) => call[1])).toEqual([ + "sandbox:mcp:add", + "sandbox:mcp:remove", + "sandbox:mcp:restart", + ]); + }); + it("rejects unauthenticated direct add callers before sandbox or network side effects", async () => { await expect( addMcpBridge("missing-sandbox", { diff --git a/src/lib/actions/sandbox/mcp-bridge-remove.ts b/src/lib/actions/sandbox/mcp-bridge-remove.ts index 1a4c48f315a..b00ef51d95c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-remove.ts +++ b/src/lib/actions/sandbox/mcp-bridge-remove.ts @@ -3,6 +3,7 @@ import type { AgentMcpAdapter } from "../../agent/defs"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; import type { McpBridgeEntry } from "../../state/registry"; import { assertAgentMcpConfigMutationAllowed, @@ -116,6 +117,7 @@ export async function removeMcpBridge( options: { force?: boolean; allowResidual?: boolean } = {}, ): Promise { return withMcpLifecycleLock(sandboxName, async () => { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:mcp:remove"); // #6376: capture the recoverable prepared-destroy phase BEFORE the removal. const before = getSandboxOrThrow(sandboxName).mcp; const recoverPreparedDestroy = diff --git a/src/lib/actions/sandbox/mcp-bridge-restart.ts b/src/lib/actions/sandbox/mcp-bridge-restart.ts index 131c5acaac5..cee2cac6ac0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-restart.ts @@ -3,6 +3,7 @@ import type { AgentMcpAdapter } from "../../agent/defs"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; import type { McpBridgeEntry } from "../../state/registry"; import { registerAgentAdapter } from "./mcp-bridge-adapters"; import { McpBridgeError } from "./mcp-bridge-contracts"; @@ -58,7 +59,10 @@ function resolvedTargetPins( } export async function restartMcpBridge(sandboxName: string, server?: string): Promise { - return withMcpLifecycleLock(sandboxName, () => restartMcpBridgeUnlocked(sandboxName, server)); + return withMcpLifecycleLock(sandboxName, () => { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:mcp:restart"); + return restartMcpBridgeUnlocked(sandboxName, server); + }); } async function restartMcpBridgeUnlocked(sandboxName: string, server?: string): Promise { diff --git a/src/lib/actions/sandbox/process-recovery.ts b/src/lib/actions/sandbox/process-recovery.ts index 0e3832d488d..1371c011ab9 100644 --- a/src/lib/actions/sandbox/process-recovery.ts +++ b/src/lib/actions/sandbox/process-recovery.ts @@ -56,6 +56,7 @@ import { type RestartSandboxGatewayOptions, restartSandboxGatewayWithDeps, sandboxAgentName, + withUnsupportedHermesPortableGatewayRestartFence, } from "./gateway-restart"; import { printGatewayWedgeDiagnostics } from "./gateway-wedge-diagnostics"; import { enforceHermesSecretBoundaryOnRunningGateway } from "./hermes-secret-boundary-recovery"; @@ -811,8 +812,9 @@ export function restartSandboxGateway( sandboxName: string, { quiet = false, deps = {} }: RestartSandboxGatewayOptions = {}, ): GatewayRestartResult { - return withTimerBoundShieldsMutationLock(sandboxName, "gateway restart", () => - restartSandboxGatewayWithDeps(sandboxName, { + return withUnsupportedHermesPortableGatewayRestartFence(sandboxName, () => { + return withTimerBoundShieldsMutationLock(sandboxName, "gateway restart", () => + restartSandboxGatewayWithDeps(sandboxName, { quiet, deps: { getSessionAgent: agentRuntime.getSessionAgent, @@ -839,8 +841,9 @@ export function restartSandboxGateway( inspectHermesMcpReconciliationRefusal, ...deps, }, - }), - ); + }), + ); + }); } function readNonNegativeNumberEnv(name: string, fallback: number): number { diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index 1548db51edf..44b9b95776a 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -7,6 +7,7 @@ import { createRebuildFlowHarness, installRebuildFlowTestHooks, originalSandboxName, + portableAgentLifecycle, snapshotEnv, } from "../../../../test/helpers/rebuild-flow-generic-harness"; import { makePreparedRecoveryManifest } from "./rebuild-flow-test-fixtures"; @@ -14,6 +15,26 @@ import { makePreparedRecoveryManifest } from "./rebuild-flow-test-fixtures"; describe("rebuildSandbox flow: lifecycle", () => { installRebuildFlowTestHooks(); + it("rejects schema-5 before rebuild effects and rechecks under the lifecycle lock (#9203)", async () => { + const guard = vi + .spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable") + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("schema-5 appeared"); + }); + const harness = createRebuildFlowHarness(); + + await expect( + harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), + ).rejects.toThrow("schema-5 appeared"); + + expect(guard).toHaveBeenNthCalledWith(1, "alpha", "sandbox:rebuild"); + expect(guard).toHaveBeenNthCalledWith(2, "alpha", "sandbox:rebuild"); + expect(harness.backupSandboxStateSpy).not.toHaveBeenCalled(); + expect(harness.onboardSpy).not.toHaveBeenCalled(); + expectNoSandboxDelete(harness.runOpenshellSpy); + }); + it("rejects a multi-agent sandbox before backup, onboard, or deletion", async () => { const harness = createRebuildFlowHarness({ sandboxEntry: { agents: [{ name: "openclaw" }, { name: "hermes" }] }, diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index dd9b8664ad0..2b54541b335 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -33,6 +33,7 @@ import { import { printRebuildPreflightFailure } from "./rebuild-preflight-error"; import { blockRebuildOnPendingBaselineTransition, + assertSandboxRebuildCommandAvailable, revalidateManagedWorkloadRebuildBeforeDelete, revalidateRebuildRouteBeforeDelete, } from "./rebuild-preflight-guards"; @@ -85,6 +86,7 @@ export async function rebuildSandbox( opts: RebuildSandboxExecutionOptions = {}, ): Promise { const homeDir = process.env.HOME || os.homedir(); + assertSandboxRebuildCommandAvailable(sandboxName); return withPortableOnboardRetirementBoundary( { homeDir, @@ -93,6 +95,7 @@ export async function rebuildSandbox( stateDir: path.dirname(onboardSession.SESSION_FILE), }, () => withMcpLifecycleLock(sandboxName, async () => { + assertSandboxRebuildCommandAvailable(sandboxName); const scopedEnvKeys = [ BRAVE_API_KEY_ENV, TAVILY_API_KEY_ENV, diff --git a/src/lib/actions/sandbox/rebuild-preflight-guards.ts b/src/lib/actions/sandbox/rebuild-preflight-guards.ts index 659b0a11463..34970d08afc 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-guards.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-guards.ts @@ -15,6 +15,7 @@ import { import { normalizeInferenceSelection } from "../../inference/selection"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { requireRuntimeProviderBundleForSandbox } from "../../onboard/runtime-provider/access"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES } from "../../onboard/runtime-provider/current"; import { type ManagedWorkloadRebuildHandoff, @@ -54,6 +55,10 @@ const defaultRouteDependencies: RebuildRouteRegistryDependencies = { save, }; +export function assertSandboxRebuildCommandAvailable(sandboxName: string): void { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:rebuild"); +} + function normalizedRoute(entry: Partial): GatewayInferenceRoute { const route = normalizeInferenceSelection(entry); return { diff --git a/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.test.ts b/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.test.ts index 8fc2ea1e0ad..230e7899ed4 100644 --- a/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.test.ts +++ b/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ connectSandbox: vi.fn(), getSessionAgent: vi.fn(), + inspectPortableAgentReceiptDisposition: vi.fn(), prepareHermesCronRestoreRecovery: vi.fn(), recoverHermesCronRestore: vi.fn(), withMcpLifecycleLock: vi.fn( @@ -22,6 +23,10 @@ vi.mock("../../../state/mcp-lifecycle-lock", () => ({ withMcpLifecycleLock: mocks.withMcpLifecycleLock, })); +vi.mock("../../../onboard/experimental/portable-agent-lifecycle", () => ({ + inspectPortableAgentReceiptDisposition: mocks.inspectPortableAgentReceiptDisposition, +})); + vi.mock("../connect", () => ({ connectSandbox: mocks.connectSandbox, })); @@ -37,6 +42,7 @@ describe("sandbox recovery with a Hermes cron restore gate", () => { beforeEach(() => { vi.clearAllMocks(); mocks.connectSandbox.mockResolvedValue(undefined); + mocks.inspectPortableAgentReceiptDisposition.mockReturnValue({ kind: "absent" }); mocks.prepareHermesCronRestoreRecovery.mockReturnValue("not-required"); mocks.recoverHermesCronRestore.mockReturnValue("not-required"); }); @@ -70,6 +76,23 @@ describe("sandbox recovery with a Hermes cron restore gate", () => { expect(mocks.recoverHermesCronRestore).toHaveBeenCalledWith("alpha"); }); + it("routes schema-5 recovery directly to receipt-owned probe without cron mutation (#9203)", async () => { + mocks.inspectPortableAgentReceiptDisposition.mockReturnValue({ + kind: "hermes", + phase: "active", + }); + mocks.getSessionAgent.mockReturnValue({ name: "hermes" }); + + await recoverSandboxWithHermesCronRestore("alpha"); + + expect(mocks.connectSandbox).toHaveBeenCalledWith("alpha", { + probeOnly: true, + requireLaunchReadinessPublication: false, + }); + expect(mocks.prepareHermesCronRestoreRecovery).not.toHaveBeenCalled(); + expect(mocks.recoverHermesCronRestore).not.toHaveBeenCalled(); + }); + it("does not repair the gateway when Hermes gate preparation fails", async () => { mocks.getSessionAgent.mockReturnValue({ name: "hermes" }); mocks.prepareHermesCronRestoreRecovery.mockImplementation(() => { diff --git a/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.ts b/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.ts index bd41aa21b0a..5c3b10f43ff 100644 --- a/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.ts +++ b/src/lib/actions/sandbox/runtime/hermes-cron-restore-recovery.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as agentRuntime from "../../../agent/runtime"; +import { inspectPortableAgentReceiptDisposition } from "../../../onboard/experimental/portable-agent-lifecycle"; import { withMcpLifecycleLock } from "../../../state/mcp-lifecycle-lock"; import { connectSandbox } from "../connect"; import { @@ -16,6 +17,14 @@ export async function recoverSandboxWithHermesCronRestore(sandboxName: string): await withMcpLifecycleLock( sandboxName, async () => { + const portable = inspectPortableAgentReceiptDisposition(sandboxName); + if (portable.kind === "hermes") { + await connectSandbox(sandboxName, { + probeOnly: true, + requireLaunchReadinessPublication: false, + }); + return; + } const agent = agentRuntime.getSessionAgent(sandboxName); if (agent?.name === "hermes") { prepareHermesCronRestoreRecovery(sandboxName); diff --git a/src/lib/actions/sandbox/sessions/delete.test.ts b/src/lib/actions/sandbox/sessions/delete.test.ts index b6c12b50d73..04a6ffa171c 100644 --- a/src/lib/actions/sandbox/sessions/delete.test.ts +++ b/src/lib/actions/sandbox/sessions/delete.test.ts @@ -16,6 +16,13 @@ vi.mock("../exec", () => ({ execSandbox: vi.fn(async () => undefined), })); +const withLifecycleLockMock = vi.hoisted(() => + vi.fn(async (_sandboxName: string, operation: () => unknown) => await operation()), +); +vi.mock("../../../state/mcp-lifecycle-lock-acquisition", () => ({ + withMcpLifecycleLock: withLifecycleLockMock, +})); + import { execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { deleteSandboxSession } from "./delete"; @@ -47,6 +54,7 @@ beforeEach(() => { hermesAgentMock.mockReturnValue(false); execSandboxMock.mockReset(); execSandboxMock.mockResolvedValue(undefined); + withLifecycleLockMock.mockClear(); processExitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { throw new Error(`process.exit:${code ?? 0}`); }); @@ -68,7 +76,10 @@ describe("deleteSandboxSession", () => { key: "agent:main:slot-1", }); - expect(ensureMock).toHaveBeenCalledWith("sb-1", { allowNonReadyPhase: true }); + expect(ensureMock).toHaveBeenCalledWith("sb-1", { + allowNonReadyPhase: true, + exit: expect.any(Function), + }); expect(gatewayMock).toHaveBeenCalledTimes(1); expect(gatewayMock.mock.calls[0]?.[0]).toMatchObject({ sandboxName: "sb-1", @@ -79,6 +90,17 @@ describe("deleteSandboxSession", () => { expect(result.key).toBe("agent:main:slot-1"); }); + it("defers an OpenClaw readiness exit through lifecycle authority (#9203)", async () => { + ensureMock.mockImplementationOnce(async (_sandboxName, options) => options.exit(1)); + + await expect(deleteSandboxSession("sb-1", { key: "agent:main:slot-1" })).rejects.toThrow( + /process\.exit:1/, + ); + + expect(withLifecycleLockMock).toHaveBeenCalledOnce(); + expect(gatewayMock).not.toHaveBeenCalled(); + }); + it("translates --keep-transcript into deleteTranscript=false", async () => { gatewayMock.mockReturnValue(successResult("agent:main:slot-1", { removedTranscript: false })); @@ -181,8 +203,8 @@ describe("deleteSandboxSession (hermes sandbox)", () => { hermesAgentMock.mockReturnValue(true); // execSandbox streams the native output and exits the process with its // code; model that terminal behavior so the routing never returns a value. - execSandboxMock.mockImplementation(async () => { - process.exit(0); + execSandboxMock.mockImplementation(async (_name, _command, _options, deps) => { + deps.exit(0); }); }); @@ -192,14 +214,16 @@ describe("deleteSandboxSession (hermes sandbox)", () => { ); expect(gatewayMock).not.toHaveBeenCalled(); - expect(ensureMock).toHaveBeenCalledWith("sb-h", { allowNonReadyPhase: true }); - expect(execSandboxMock).toHaveBeenCalledWith("sb-h", [ - "hermes", - "sessions", - "delete", - "20260727_130357_cb2b61", - "--yes", - ]); + expect(ensureMock).toHaveBeenCalledWith("sb-h", { + allowNonReadyPhase: true, + exit: expect.any(Function), + }); + expect(execSandboxMock).toHaveBeenCalledWith( + "sb-h", + ["hermes", "sessions", "delete", "20260727_130357_cb2b61", "--yes"], + {}, + expect.objectContaining({ exit: expect.any(Function) }), + ); }); it("passes the native hermes session id through without OpenClaw canonicalization (#7642)", async () => { @@ -269,4 +293,5 @@ describe("deleteSandboxSession (hermes sandbox)", () => { expect(execSandboxMock).not.toHaveBeenCalled(); expect(consoleErrorSpy.mock.calls.flat().join("\n")).toMatch(/session id/i); }); + }); diff --git a/src/lib/actions/sandbox/sessions/delete.ts b/src/lib/actions/sandbox/sessions/delete.ts index a1b0b6a432d..0937d23f913 100644 --- a/src/lib/actions/sandbox/sessions/delete.ts +++ b/src/lib/actions/sandbox/sessions/delete.ts @@ -1,6 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { assertHermesPortableCommandUnavailable } from "../../../onboard/experimental/portable-agent-lifecycle"; +import { + deferSandboxLifecycleExit, + runWithDeferredSandboxLifecycleExit, +} from "../../../core/process-exit"; +import { withMcpLifecycleLock } from "../../../state/mcp-lifecycle-lock-acquisition"; import { execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { callOpenclawGateway, sandboxUsesHermesAgent } from "./gateway-rpc"; @@ -37,6 +43,18 @@ export interface SessionsDeleteResult { export async function deleteSandboxSession( sandboxName: string, opts: SessionsDeleteOptions, +): Promise { + return runWithDeferredSandboxLifecycleExit(() => + withMcpLifecycleLock(sandboxName, () => { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:sessions:delete"); + return deleteSandboxSessionUnlocked(sandboxName, opts); + }), + ); +} + +async function deleteSandboxSessionUnlocked( + sandboxName: string, + opts: SessionsDeleteOptions, ): Promise { // Route by the sandbox's registered agent before OpenClaw key validation, // the same dispatch `sessions export` uses (#5526). Hermes ships a native @@ -63,31 +81,35 @@ export async function deleteSandboxSession( console.error( ` Drop --agent or pass a key under that agent (e.g. agent:${requestedAgent}:...).`, ); - process.exit(1); + deferSandboxLifecycleExit(1); } const resolvedAgent = keyAgent ?? requestedAgent ?? DEFAULT_AGENT_ID; const canonicalKey = buildCanonicalSessionKey(resolvedAgent, rawKey); const deleteTranscript = opts.keepTranscript !== true; - await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + await ensureLiveSandboxOrExit(sandboxName, { + allowNonReadyPhase: true, + exit: deferSandboxLifecycleExit, + }); const { payload, rawOutput } = callOpenclawGateway({ sandboxName, method: "sessions.delete", params: { key: canonicalKey, deleteTranscript }, + exit: deferSandboxLifecycleExit, }); if (payload.ok === false || payload.error) { const code = payload.error?.code ?? "unknown"; const message = payload.error?.message ?? "no message"; console.error(` Gateway refused sessions.delete for '${canonicalKey}': [${code}] ${message}`); - process.exit(1); + deferSandboxLifecycleExit(1); } if (payload.ok !== true || typeof payload.key !== "string") { console.error(" Gateway returned an unexpected sessions.delete payload."); console.error(` ${rawOutput.trim()}`); - process.exit(1); + deferSandboxLifecycleExit(1); } const removedTranscript = payload.removedTranscript ?? deleteTranscript; @@ -119,19 +141,19 @@ function rejectOpenClawOnlyDeleteOptions(opts: SessionsDeleteOptions): void { console.error( ` Refusing to delete: --agent ${opts.agent} is OpenClaw-only and is not supported on a Hermes sandbox. Omit the flag.`, ); - process.exit(1); + deferSandboxLifecycleExit(1); } if (opts.keepTranscript === true) { console.error( " Refusing to delete: --keep-transcript is OpenClaw-only and is not supported on a Hermes sandbox. Hermes removes the session entry directly; omit the flag.", ); - process.exit(1); + deferSandboxLifecycleExit(1); } if (opts.json || opts.verbose) { console.error( " Refusing to delete: --json and --verbose print the OpenClaw gateway result and are OpenClaw-only; a Hermes sandbox streams the native command output. Omit the flags.", ); - process.exit(1); + deferSandboxLifecycleExit(1); } } @@ -143,7 +165,7 @@ function validateHermesSessionId(rawKey: string): string { console.error( ` Refusing to delete: '${rawKey}' is not a valid Hermes session id. Pass a native id from \`sessions list\` (for example 20260727_130357_cb2b61).`, ); - process.exit(1); + deferSandboxLifecycleExit(1); } return sessionId; } @@ -155,10 +177,18 @@ async function deleteHermesSession( rejectOpenClawOnlyDeleteOptions(opts); const sessionId = validateHermesSessionId(opts.key); - await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + await ensureLiveSandboxOrExit(sandboxName, { + allowNonReadyPhase: true, + exit: deferSandboxLifecycleExit, + }); // execSandbox streams the native command output and exits the process with // its exit code, so control never returns here and there is no NemoClaw-side // result envelope to build (unlike the OpenClaw gateway path above). - await execSandbox(sandboxName, ["hermes", "sessions", "delete", sessionId, "--yes"]); + await execSandbox( + sandboxName, + ["hermes", "sessions", "delete", sessionId, "--yes"], + {}, + { exit: deferSandboxLifecycleExit }, + ); throw new Error("unreachable: execSandbox terminates the process"); } diff --git a/src/lib/actions/sandbox/sessions/export.test.ts b/src/lib/actions/sandbox/sessions/export.test.ts index dccb70f4426..db059c24f68 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -19,14 +19,23 @@ vi.mock("../../../state/registry", () => ({ getSandbox: vi.fn(() => null), })); +const withLifecycleLockMock = vi.hoisted(() => + vi.fn(async (_sandboxName: string, operation: () => unknown) => await operation()), +); +vi.mock("../../../state/mcp-lifecycle-lock-acquisition", () => ({ + withMcpLifecycleLock: withLifecycleLockMock, +})); + import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; import * as registry from "../../../state/registry"; +import { ensureLiveSandboxOrExit } from "../gateway-state"; import { isWarmupSessionId, WARMUP_SESSION_ID_PREFIX } from "../warmup-session"; import { buildSandboxTarArgv, exportSandboxSessions } from "./export"; const captureMock = captureOpenshell as unknown as ReturnType; const runMock = runOpenshell as unknown as ReturnType; const getSandboxMock = registry.getSandbox as unknown as ReturnType; +const ensureLiveMock = ensureLiveSandboxOrExit as unknown as ReturnType; let consoleErrorSpy: ReturnType; let consoleLogSpy: ReturnType; @@ -34,6 +43,7 @@ let statSyncSpy: ReturnType; let stagingMkdtempSpy: ReturnType; let stagingRenameSpy: ReturnType; let stagingRmSpy: ReturnType; +let processExitSpy: ReturnType; beforeEach(() => { captureMock.mockReset(); @@ -41,6 +51,12 @@ beforeEach(() => { runMock.mockReturnValue({ status: 0, stdout: "", stderr: "" }); getSandboxMock.mockReset(); getSandboxMock.mockReturnValue(null); + ensureLiveMock.mockReset(); + ensureLiveMock.mockResolvedValue(undefined); + withLifecycleLockMock.mockClear(); + processExitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { + throw new Error(`process.exit:${code ?? 0}`); + }); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); // Default to a present, non-empty regular file so the post-download artifact @@ -66,6 +82,7 @@ afterEach(() => { stagingMkdtempSpy.mockRestore(); stagingRenameSpy.mockRestore(); stagingRmSpy.mockRestore(); + processExitSpy.mockRestore(); }); function makeCapture(output: string, status = 0) { @@ -111,6 +128,21 @@ describe("isWarmupSessionId", () => { }); describe("exportSandboxSessions warm-up filtering", () => { + it("defers a readiness exit through lifecycle authority (#9203)", async () => { + ensureLiveMock.mockImplementationOnce(async (_sandboxName, options) => options.exit(1)); + + await expect( + exportSandboxSessions({ + sandboxName: "alpha", + out: "./out.tgz", + format: "tar", + }), + ).rejects.toThrow(/process\.exit:1/); + + expect(withLifecycleLockMock).toHaveBeenCalledOnce(); + expect(captureMock).not.toHaveBeenCalled(); + }); + it("excludes the onboard warm-up session from export-all but keeps real sessions (#5511)", async () => { captureMock.mockReturnValueOnce( makeCapture( diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index fdd1633946d..96d93dede6a 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -43,6 +43,12 @@ import fs from "node:fs"; import path from "node:path"; import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; import { CLI_NAME } from "../../../cli/branding"; +import { + deferSandboxLifecycleExit, + runWithDeferredSandboxLifecycleExit, +} from "../../../core/process-exit"; +import { assertHermesPortableCommandUnavailable } from "../../../onboard/experimental/portable-agent-lifecycle"; +import { withMcpLifecycleLock } from "../../../state/mcp-lifecycle-lock-acquisition"; import * as registry from "../../../state/registry"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { resolveHostPathFromCwd } from "../host-path"; @@ -105,6 +111,17 @@ const STAGING_DIR_IN_SANDBOX = "/sandbox/.nemoclaw-staging"; export async function exportSandboxSessions( opts: SessionsExportOptions, +): Promise { + return runWithDeferredSandboxLifecycleExit(() => + withMcpLifecycleLock(opts.sandboxName, () => { + assertHermesPortableCommandUnavailable(opts.sandboxName, "sandbox:sessions:export"); + return exportSandboxSessionsUnlocked(opts); + }), + ); +} + +async function exportSandboxSessionsUnlocked( + opts: SessionsExportOptions, ): Promise { if (registry.getSandbox(opts.sandboxName)?.agent === "hermes") { return exportHermesSessions(opts); @@ -113,7 +130,10 @@ export async function exportSandboxSessions( const trimmedKeys = (opts.keys ?? []).map((value) => validateSessionKey(value)); enforceAgentScope(agent, trimmedKeys); - await ensureLiveSandboxOrExit(opts.sandboxName, { allowNonReadyPhase: true }); + await ensureLiveSandboxOrExit(opts.sandboxName, { + allowNonReadyPhase: true, + exit: deferSandboxLifecycleExit, + }); const format: SessionsExportFormat = opts.format === "tar" ? "tar" : "dir"; const sourceDir = `/sandbox/.openclaw/agents/${agent}/sessions`; @@ -329,7 +349,10 @@ export async function exportSandboxSessions( // staging + download orchestration unnecessary. async function exportHermesSessions(opts: SessionsExportOptions): Promise { rejectOpenClawOnlyOptions(opts); - await ensureLiveSandboxOrExit(opts.sandboxName, { allowNonReadyPhase: true }); + await ensureLiveSandboxOrExit(opts.sandboxName, { + allowNonReadyPhase: true, + exit: deferSandboxLifecycleExit, + }); const hostDest = resolveHermesHostDestination(opts.out, opts.sandboxName); const stagingRemote = hermesStagingPath(); diff --git a/src/lib/actions/sandbox/sessions/gateway-rpc.ts b/src/lib/actions/sandbox/sessions/gateway-rpc.ts index 794b51ec060..18a4e222ddc 100644 --- a/src/lib/actions/sandbox/sessions/gateway-rpc.ts +++ b/src/lib/actions/sandbox/sessions/gateway-rpc.ts @@ -18,6 +18,7 @@ export interface GatewayCallOptions { sandboxName: string; method: GatewayAdminMethod; params: unknown; + exit?: (code: number) => never; } export interface GatewayCallResult { @@ -163,13 +164,17 @@ function isSupportedGatewayAdminMethod(method: string): method is GatewayAdminMe * A missing entry is rejected, and registry read errors propagate, because an * unknown agent identity cannot authorize an OpenClaw admin RPC. */ -function resolveSandboxAgent(sandboxName: string, method: GatewayAdminMethod): string { +function resolveSandboxAgent( + sandboxName: string, + method: GatewayAdminMethod, + exit: (code: number) => never, +): string { const sandbox = registry.getSandbox(sandboxName); if (!sandbox) { console.error( ` Refusing to invoke '${method}' for sandbox '${sandboxName}': it has no registry entry, so NemoClaw cannot confirm that it uses the OpenClaw agent.`, ); - process.exit(1); + exit(1); } return sandbox.agent === undefined || sandbox.agent === null ? OPENCLAW_AGENT_ID : sandbox.agent; } @@ -187,6 +192,7 @@ function refuseUnsupportedSandboxAgent( sandboxName: string, agent: string, method: GatewayAdminMethod, + exit: (code: number) => never, ): never { console.error( ` Refusing to invoke '${method}' for sandbox '${sandboxName}': it uses the '${agent}' agent, which does not expose the OpenClaw gateway admin RPCs. These commands only support the OpenClaw agent.`, @@ -199,7 +205,7 @@ function refuseUnsupportedSandboxAgent( ); console.error(` Delete a Hermes session with: ${cliName} ${sandboxName} sessions delete `); } - process.exit(1); + exit(1); } function redactedGatewayOutput(output: string): string { @@ -241,16 +247,17 @@ function captureGatewayCall(opts: GatewayCallOptions) { export function callOpenclawGateway( opts: GatewayCallOptions, ): GatewayCallResult { + const exit = opts.exit ?? process.exit; if (!isSupportedGatewayAdminMethod(opts.method)) { console.error( ` Refusing unsupported OpenClaw gateway admin RPC method '${opts.method}' for sandbox '${opts.sandboxName}'.`, ); - process.exit(1); + exit(1); } - const agent = resolveSandboxAgent(opts.sandboxName, opts.method); + const agent = resolveSandboxAgent(opts.sandboxName, opts.method, exit); if (agent !== OPENCLAW_AGENT_ID) { - refuseUnsupportedSandboxAgent(opts.sandboxName, agent, opts.method); + refuseUnsupportedSandboxAgent(opts.sandboxName, agent, opts.method, exit); } // Drain allowlisted CLI/webchat pairing or scope-upgrade requests before @@ -274,7 +281,7 @@ export function callOpenclawGateway vi.fn()); const execMock = vi.hoisted(() => vi.fn(async () => {})); const ensureLiveMock = vi.hoisted(() => vi.fn(async () => ({}))); const getSandboxMock = vi.hoisted(() => vi.fn(() => null as { agent?: string } | null)); +const withLifecycleLockMock = vi.hoisted(() => + vi.fn(async (_sandboxName: string, operation: () => unknown) => await operation()), +); vi.mock("../../../adapters/openshell/runtime", () => ({ captureOpenshell: captureMock, @@ -17,6 +20,9 @@ vi.mock("../exec", async () => { }); vi.mock("../gateway-state", () => ({ ensureLiveSandboxOrExit: ensureLiveMock })); vi.mock("../../../state/registry", () => ({ getSandbox: getSandboxMock })); +vi.mock("../../../state/mcp-lifecycle-lock-acquisition", () => ({ + withMcpLifecycleLock: withLifecycleLockMock, +})); import { WARMUP_SESSION_ID_PREFIX } from "../warmup-session"; import { @@ -248,7 +254,10 @@ describe("runSessionsPassthrough", () => { extraArgs: ["--agent", "main", "--json"], }); - expect(ensureLiveMock).toHaveBeenCalledWith("alpha", { allowNonReadyPhase: true }); + expect(ensureLiveMock).toHaveBeenCalledWith("alpha", { + allowNonReadyPhase: true, + exit: expect.any(Function), + }); expect(execMock).not.toHaveBeenCalled(); expect(captureMock).toHaveBeenCalledWith( [ @@ -389,7 +398,12 @@ describe("runSessionsPassthrough", () => { await runSessionsPassthrough("hermes", { extraArgs: [] }); expect(captureMock).not.toHaveBeenCalled(); - expect(execMock).toHaveBeenCalledWith("hermes", ["hermes", "sessions", "list"]); + expect(execMock).toHaveBeenCalledWith( + "hermes", + ["hermes", "sessions", "list"], + {}, + expect.objectContaining({ exit: expect.any(Function) }), + ); }); it("uses openclaw binary for openclaw-agent sandboxes (#6247)", async () => { @@ -414,7 +428,12 @@ describe("runSessionsPassthrough", () => { }); expect(captureMock).not.toHaveBeenCalled(); - expect(execMock).toHaveBeenCalledWith("hermes", ["hermes", "sessions", "list", "--limit", "5"]); + expect(execMock).toHaveBeenCalledWith( + "hermes", + ["hermes", "sessions", "list", "--limit", "5"], + {}, + expect.objectContaining({ exit: expect.any(Function) }), + ); }); it("defaults to the openclaw binary + filter path when the registry has no entry (#6247)", async () => { @@ -467,4 +486,5 @@ describe("runSessionsPassthrough", () => { expect(stdoutSpy).not.toHaveBeenCalled(); expect(String(stderrSpy.mock.calls[0]?.[0])).toBe("unknown flag: --bad\n"); }); + }); diff --git a/src/lib/actions/sandbox/sessions/passthrough.ts b/src/lib/actions/sandbox/sessions/passthrough.ts index 825382097ef..d881b433049 100644 --- a/src/lib/actions/sandbox/sessions/passthrough.ts +++ b/src/lib/actions/sandbox/sessions/passthrough.ts @@ -3,6 +3,12 @@ import { captureOpenshell } from "../../../adapters/openshell/runtime"; import { CLI_NAME } from "../../../cli/branding"; +import { + deferSandboxLifecycleExit, + runWithDeferredSandboxLifecycleExit, +} from "../../../core/process-exit"; +import { assertHermesPortableCommandUnavailable } from "../../../onboard/experimental/portable-agent-lifecycle"; +import { withMcpLifecycleLock } from "../../../state/mcp-lifecycle-lock-acquisition"; import * as registry from "../../../state/registry"; import { buildOpenshellExecArgs, computeExitCode, execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; @@ -213,7 +219,22 @@ export async function runSessionsPassthrough( sandboxName: string, { verb, extraArgs = [] }: SessionsPassthroughOptions = {}, ): Promise { - await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + return runWithDeferredSandboxLifecycleExit(async () => { + await withMcpLifecycleLock(sandboxName, () => { + assertHermesPortableCommandUnavailable(sandboxName, `sandbox:sessions:${verb ?? "list"}`); + return runSessionsPassthroughUnlocked(sandboxName, { verb, extraArgs }); + }); + }); +} + +async function runSessionsPassthroughUnlocked( + sandboxName: string, + { verb, extraArgs = [] }: SessionsPassthroughOptions = {}, +): Promise { + await ensureLiveSandboxOrExit(sandboxName, { + allowNonReadyPhase: true, + exit: deferSandboxLifecycleExit, + }); // Hermes sandboxes ship the `hermes` binary in place of OpenClaw's // `openclaw` binary, and `openclaw` does not exist inside them (#6247). // Route the passthrough at the in-sandbox agent's own binary name and @@ -246,7 +267,7 @@ export async function runSessionsPassthrough( } else if (errorMessage) { console.error(` Failed to invoke openshell: ${errorMessage}`); } - process.exit(code); + deferSandboxLifecycleExit(code); } if (isJsonOutput(extraArgs)) { @@ -256,7 +277,7 @@ export async function runSessionsPassthrough( // an internal warm-up session. if (capturedOutput.includes(WARMUP_SESSION_ID_PREFIX)) { printJsonParseFailure(); - process.exit(1); + deferSandboxLifecycleExit(1); } writeWithTrailingNewline(process.stdout, capturedOutput); writeWithTrailingNewline(process.stderr, capturedError); @@ -272,5 +293,5 @@ export async function runSessionsPassthrough( writeWithTrailingNewline(process.stderr, capturedError); return; } - await execSandbox(sandboxName, command); + await execSandbox(sandboxName, command, {}, { exit: deferSandboxLifecycleExit }); } diff --git a/src/lib/actions/sandbox/sessions/reset.test.ts b/src/lib/actions/sandbox/sessions/reset.test.ts index 08710c27816..34331a0cedb 100644 --- a/src/lib/actions/sandbox/sessions/reset.test.ts +++ b/src/lib/actions/sandbox/sessions/reset.test.ts @@ -11,6 +11,13 @@ vi.mock("./gateway-rpc", () => ({ callOpenclawGateway: vi.fn(), })); +const withLifecycleLockMock = vi.hoisted(() => + vi.fn(async (_sandboxName: string, operation: () => unknown) => await operation()), +); +vi.mock("../../../state/mcp-lifecycle-lock-acquisition", () => ({ + withMcpLifecycleLock: withLifecycleLockMock, +})); + import { ensureLiveSandboxOrExit } from "../gateway-state"; import { callOpenclawGateway } from "./gateway-rpc"; import { resetSandboxSession } from "./reset"; @@ -35,6 +42,7 @@ let consoleLogSpy: ReturnType; beforeEach(() => { ensureMock.mockClear(); gatewayMock.mockReset(); + withLifecycleLockMock.mockClear(); processExitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => { throw new Error(`process.exit:${code ?? 0}`); }); @@ -56,16 +64,31 @@ describe("resetSandboxSession", () => { key: "agent:main:main", }); - expect(ensureMock).toHaveBeenCalledWith("sb-1", { allowNonReadyPhase: true }); + expect(ensureMock).toHaveBeenCalledWith("sb-1", { + allowNonReadyPhase: true, + exit: expect.any(Function), + }); expect(gatewayMock).toHaveBeenCalledTimes(1); expect(gatewayMock.mock.calls[0]?.[0]).toMatchObject({ sandboxName: "sb-1", method: "sessions.reset", params: { key: "agent:main:main", reason: "reset" }, + exit: expect.any(Function), }); expect(result).toEqual({ key: "agent:main:main", reason: "reset", entry: null }); }); + it("defers a readiness exit through lifecycle authority (#9203)", async () => { + ensureMock.mockImplementationOnce(async (_sandboxName, options) => options.exit(1)); + + await expect(resetSandboxSession("sb-1", { key: "agent:main:main" })).rejects.toThrow( + /process\.exit:1/, + ); + + expect(withLifecycleLockMock).toHaveBeenCalledOnce(); + expect(gatewayMock).not.toHaveBeenCalled(); + }); + it("forwards reason='new' when requested", async () => { gatewayMock.mockReturnValue(successResult("agent:main:main")); diff --git a/src/lib/actions/sandbox/sessions/reset.ts b/src/lib/actions/sandbox/sessions/reset.ts index 6e86761aa7c..c1b18a9f721 100644 --- a/src/lib/actions/sandbox/sessions/reset.ts +++ b/src/lib/actions/sandbox/sessions/reset.ts @@ -60,6 +60,12 @@ // comment exists to keep the NemoClaw/OpenClaw responsibility split // explicit while the contract is still informal. +import { assertHermesPortableCommandUnavailable } from "../../../onboard/experimental/portable-agent-lifecycle"; +import { + deferSandboxLifecycleExit, + runWithDeferredSandboxLifecycleExit, +} from "../../../core/process-exit"; +import { withMcpLifecycleLock } from "../../../state/mcp-lifecycle-lock-acquisition"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { callOpenclawGateway } from "./gateway-rpc"; import { @@ -96,6 +102,18 @@ export interface SessionsResetResult { export async function resetSandboxSession( sandboxName: string, opts: SessionsResetOptions, +): Promise { + return runWithDeferredSandboxLifecycleExit(() => + withMcpLifecycleLock(sandboxName, () => { + assertHermesPortableCommandUnavailable(sandboxName, "sandbox:sessions:reset"); + return resetSandboxSessionUnlocked(sandboxName, opts); + }), + ); +} + +async function resetSandboxSessionUnlocked( + sandboxName: string, + opts: SessionsResetOptions, ): Promise { const reason: SessionsResetReason = opts.reason === "new" ? "new" : "reset"; const requestedAgent = opts.agent ? validateAgentId(opts.agent) : null; @@ -109,30 +127,34 @@ export async function resetSandboxSession( console.error( ` Drop --agent or pass a key under that agent (e.g. agent:${requestedAgent}:...).`, ); - process.exit(1); + deferSandboxLifecycleExit(1); } const resolvedAgent = keyAgent ?? requestedAgent ?? DEFAULT_AGENT_ID; const canonicalKey = buildCanonicalSessionKey(resolvedAgent, rawKey); - await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + await ensureLiveSandboxOrExit(sandboxName, { + allowNonReadyPhase: true, + exit: deferSandboxLifecycleExit, + }); const { payload, rawOutput } = callOpenclawGateway({ sandboxName, method: "sessions.reset", params: { key: canonicalKey, reason }, + exit: deferSandboxLifecycleExit, }); if (payload.ok === false || payload.error) { const code = payload.error?.code ?? "unknown"; const message = payload.error?.message ?? "no message"; console.error(` Gateway refused sessions.reset for '${canonicalKey}': [${code}] ${message}`); - process.exit(1); + deferSandboxLifecycleExit(1); } if (payload.ok !== true || typeof payload.key !== "string") { console.error(" Gateway returned an unexpected sessions.reset payload."); console.error(` ${rawOutput.trim()}`); - process.exit(1); + deferSandboxLifecycleExit(1); } if (opts.json) { diff --git a/src/lib/actions/sandbox/snapshot-managed-provider-restore-order.test.ts b/src/lib/actions/sandbox/snapshot-managed-provider-restore-order.test.ts index f5082a04cbb..263a337f2c2 100644 --- a/src/lib/actions/sandbox/snapshot-managed-provider-restore-order.test.ts +++ b/src/lib/actions/sandbox/snapshot-managed-provider-restore-order.test.ts @@ -70,6 +70,7 @@ const providerRestore = vi.hoisted(() => { }); vi.mock("./snapshot/dependencies", () => ({ + assertSandboxSnapshotCommandAvailable: vi.fn(), backupSandboxStateWithManagedAuthority: vi.fn(), captureSandboxRuntimeSnapshot: vi.fn(), confirmSandboxRuntimeRestore: providerRestore.confirmSandboxRuntimeRestore, diff --git a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts index ae7bf98d37a..f0993414c8c 100644 --- a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts +++ b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts @@ -147,6 +147,7 @@ const lifecycleMock = vi.hoisted(() => { }); export const backupSandboxStateMock = vi.fn(); +export const assertHermesPortableCommandUnavailableMock = vi.fn(); export const captureSnapshotRestoreAuthorityMock = vi.fn(() => ({ schemaVersion: 1 as const, backupPath: "/tmp/backup-alpha", @@ -274,6 +275,11 @@ vi.mock("../../runner", () => ({ validateName: vi.fn((value: string) => value), })); +vi.mock("../../onboard/experimental/portable-agent-lifecycle", async (importOriginal) => ({ + ...(await importOriginal()), + assertHermesPortableCommandUnavailable: assertHermesPortableCommandUnavailableMock, +})); + vi.mock("../../runtime-recovery", () => ({ parseLiveSandboxNames: parseLiveSandboxNamesMock, })); @@ -360,6 +366,7 @@ vi.mock("./restore-gateway-pairing", () => ({ export function resetSnapshotRestoreMocks(): void { vi.clearAllMocks(); + assertHermesPortableCommandUnavailableMock.mockReset(); captureSnapshotRestoreAuthorityMock.mockReturnValue({ schemaVersion: 1, backupPath: "/tmp/backup-alpha", diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index fcea6ca097f..4807e013afc 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { serializedLlamaCppHostLocalInferenceReceipt } from "../../../../test/helpers/host-local-inference-receipt"; +import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; import { createSandboxHostLocalInferenceProvenance } from "../../state/registry/host-local-inference"; import { type DcodeProbeState, @@ -53,6 +54,85 @@ describe("runSandboxSnapshot", () => { return String(execArgs.at(-1) ?? ""); } + it( + "rejects schema-5 snapshot creation before Docker or OpenShell work (#9203)", + testTimeoutOptions(30_000), + async () => { + f.assertHermesPortableCommandUnavailableMock.mockImplementation(() => { + throw new Error("schema-5 rejected"); + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect(runSandboxSnapshot("alpha", { kind: "create" })).rejects.toThrow( + "schema-5 rejected", + ); + + expect(f.captureOpenshellMock).not.toHaveBeenCalled(); + expect(f.dockerInspectMock).not.toHaveBeenCalled(); + expect(f.backupSandboxStateMock).not.toHaveBeenCalled(); + }, + ); + + it("rechecks schema-5 snapshot authority after the lifecycle lock is acquired (#9203)", async () => { + f.assertHermesPortableCommandUnavailableMock + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("schema-5 appeared"); + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect(runSandboxSnapshot("alpha", { kind: "create" })).rejects.toThrow( + "schema-5 appeared", + ); + + expect(f.captureOpenshellMock).not.toHaveBeenCalled(); + expect(f.dockerInspectMock).not.toHaveBeenCalled(); + expect(f.backupSandboxStateMock).not.toHaveBeenCalled(); + }); + + it("rejects schema-5 snapshot listing inside the lifecycle fence (#9203)", async () => { + f.assertHermesPortableCommandUnavailableMock.mockImplementation(() => { + throw new Error("schema-5 list rejected"); + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect(runSandboxSnapshot("alpha", { kind: "list" })).rejects.toThrow( + "schema-5 list rejected", + ); + + expect(f.listBackupsMock).not.toHaveBeenCalled(); + expect(f.captureOpenshellMock).not.toHaveBeenCalled(); + expect(f.dockerInspectMock).not.toHaveBeenCalled(); + }); + + it("rejects a schema-5 snapshot restore source or destination before effects (#9203)", async () => { + f.assertHermesPortableCommandUnavailableMock.mockImplementation( + (sandboxName: string) => { + switch (sandboxName) { + case "beta": + throw new Error("schema-5 destination rejected"); + } + }, + ); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect( + runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }), + ).rejects.toThrow("schema-5 destination rejected"); + + expect(f.assertHermesPortableCommandUnavailableMock).toHaveBeenCalledWith( + "alpha", + "sandbox:snapshot:restore", + ); + expect(f.assertHermesPortableCommandUnavailableMock).toHaveBeenCalledWith( + "beta", + "sandbox:snapshot:restore", + ); + expect(f.captureOpenshellMock).not.toHaveBeenCalled(); + expect(f.dockerInspectMock).not.toHaveBeenCalled(); + expect(f.restoreSandboxStateMock).not.toHaveBeenCalled(); + }); + function runProbeScriptWithProcesses( script: string, processes: string, diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 967bb6f82e2..c335778ab62 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -87,6 +87,7 @@ import { } from "./sandbox-gateway-routing"; import { backupSandboxStateWithManagedAuthority, + assertSandboxSnapshotCommandAvailable, confirmHostLocalInferenceAuthority, createSnapshotCloneLifecycle, confirmSandboxRuntimeRestore, @@ -1224,10 +1225,20 @@ async function runSnapshotRestore( const targetSandbox = target === sandboxName ? sandboxName : validateName(target, "target sandbox name"); const lockNames = targetSandbox === sandboxName ? [sandboxName] : [sandboxName, targetSandbox]; + assertSandboxSnapshotCommandAvailable(sandboxName, "sandbox:snapshot:restore"); + if (targetSandbox !== sandboxName) { + assertSandboxSnapshotCommandAvailable(targetSandbox, "sandbox:snapshot:restore"); + } const orderedNames = [...new Set(lockNames)].sort(); const acquire = (index: number): Promise => index === orderedNames.length - ? runSnapshotRestoreUnlocked(sandboxName, request, targetSandbox) + ? Promise.resolve().then(() => { + assertSandboxSnapshotCommandAvailable(sandboxName, "sandbox:snapshot:restore"); + if (targetSandbox !== sandboxName) { + assertSandboxSnapshotCommandAvailable(targetSandbox, "sandbox:snapshot:restore"); + } + return runSnapshotRestoreUnlocked(sandboxName, request, targetSandbox); + }) : withSandboxMutationLock(orderedNames[index], () => acquire(index + 1)); return acquire(0); } @@ -1768,21 +1779,28 @@ export async function runSandboxSnapshot( ) { switch (request.kind) { case "create": { - await withSandboxMutationLock(sandboxName, () => runSnapshotCreate(sandboxName, request)); + assertSandboxSnapshotCommandAvailable(sandboxName, "sandbox:snapshot:create"); + await withSandboxMutationLock(sandboxName, () => { + assertSandboxSnapshotCommandAvailable(sandboxName, "sandbox:snapshot:create"); + return runSnapshotCreate(sandboxName, request); + }); break; } case "list": { - const backups = sandboxState.listBackups(sandboxName); - if (backups.length === 0) { - console.log(` No snapshots found for '${sandboxName}'.`); - return; - } - console.log(` Snapshots for '${sandboxName}':`); - console.log(""); - renderSnapshotTable(backups); - console.log(""); - console.log(` ${backups.length} snapshot(s). Restore with:`); - console.log(` ${CLI_NAME} ${sandboxName} snapshot restore [version|name|timestamp]`); + await withSandboxMutationLock(sandboxName, () => { + assertSandboxSnapshotCommandAvailable(sandboxName, "sandbox:snapshot:list"); + const backups = sandboxState.listBackups(sandboxName); + if (backups.length === 0) { + console.log(` No snapshots found for '${sandboxName}'.`); + return; + } + console.log(` Snapshots for '${sandboxName}':`); + console.log(""); + renderSnapshotTable(backups); + console.log(""); + console.log(` ${backups.length} snapshot(s). Restore with:`); + console.log(` ${CLI_NAME} ${sandboxName} snapshot restore [version|name|timestamp]`); + }); break; } case "restore": { diff --git a/src/lib/actions/sandbox/snapshot/dependencies.ts b/src/lib/actions/sandbox/snapshot/dependencies.ts index d894b93ef42..b28a2ab85bb 100644 --- a/src/lib/actions/sandbox/snapshot/dependencies.ts +++ b/src/lib/actions/sandbox/snapshot/dependencies.ts @@ -4,6 +4,7 @@ import type { RuntimeProviderBundle } from "../../../onboard/runtime-provider/contract"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES } from "../../../onboard/runtime-provider/current"; import { requireRuntimeProviderBundleForSandbox } from "../../../onboard/runtime-provider/registry"; +import { assertHermesPortableCommandUnavailable } from "../../../onboard/experimental/portable-agent-lifecycle"; import type { SandboxEntry } from "../../../state/registry/types"; export { @@ -58,3 +59,13 @@ export function requireCurrentSnapshotRuntimeProvider( ): RuntimeProviderBundle { return requireRuntimeProviderBundleForSandbox(sandbox, CURRENT_RUNTIME_PROVIDER_BUNDLES); } + +export function assertSandboxSnapshotCommandAvailable( + sandboxName: string, + commandId: + | "sandbox:snapshot:create" + | "sandbox:snapshot:list" + | "sandbox:snapshot:restore", +): void { + assertHermesPortableCommandUnavailable(sandboxName, commandId); +} diff --git a/src/lib/actions/sandbox/start.test.ts b/src/lib/actions/sandbox/start.test.ts index d6162ab9a04..cdffbc80de6 100644 --- a/src/lib/actions/sandbox/start.test.ts +++ b/src/lib/actions/sandbox/start.test.ts @@ -46,9 +46,9 @@ function harness(overrides: Partial = {}) { const hasPortableLifecycleReceipt = vi.fn< DockerRuntimeProviderDependencies["hasPortableLifecycleReceipt"] >(() => false); - const recoverPortableSandbox = vi.fn< - DockerRuntimeProviderDependencies["recoverPortableSandbox"] - >(() => ({ kind: "not-installed" })); + const recoverPortableSandbox = vi.fn( + () => ({ kind: "not-installed" }), + ); const recoverDockerDriverSandbox = vi.fn( () => ({ recovered: true, @@ -91,6 +91,7 @@ function harness(overrides: Partial = {}) { waitForManagedGatewaySupervisor, verifyGateway, log, + withLifecycleLock: async (_sandboxName, operation) => operation(), ...overrides, }; return { @@ -459,6 +460,32 @@ describe("startSandbox", () => { expect(h.recoverDockerDriverSandbox).not.toHaveBeenCalled(); }); + it("keeps active Hermes start out of every Docker path (#9203)", async () => { + const probeInferenceInvocation = vi.fn(() => ({ ok: true }) as const); + const h = harness({ probeInferenceInvocation }); + h.getSandbox.mockReturnValue( + sandbox({ + agent: "hermes", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-alpha", + lifecycleLiveIdentityFingerprint: "identity-alpha", + openshellDriver: "docker", + }), + ); + h.hasPortableLifecycleReceipt.mockReturnValue(true); + h.recoverPortableSandbox.mockReturnValue({ kind: "recovered" }); + + await expect(startSandbox("my-sandbox", h.deps)).resolves.toEqual({ exitCode: 0 }); + + expect(h.isDockerRuntimeDown).not.toHaveBeenCalled(); + expect(h.findLabeledSandboxContainers).not.toHaveBeenCalled(); + expect(h.recoverDockerDriverSandbox).not.toHaveBeenCalled(); + expect(h.dockerUnpause).not.toHaveBeenCalled(); + expect(h.restoreStartupState).not.toHaveBeenCalled(); + expect(h.verifyGateway).not.toHaveBeenCalled(); + expect(probeInferenceInvocation).not.toHaveBeenCalled(); + }); + it("still probes when the container was already running (#6026)", async () => { const h = harness(); h.findLabeledSandboxContainers.mockReturnValue([ diff --git a/src/lib/actions/sandbox/start.ts b/src/lib/actions/sandbox/start.ts index 69e9cb2f356..60e23475da5 100644 --- a/src/lib/actions/sandbox/start.ts +++ b/src/lib/actions/sandbox/start.ts @@ -13,6 +13,7 @@ import { READINESS_INFERENCE_INVOCATION_TIMEOUT_MS, type SandboxInferenceInvocationResult, } from "./inference-invocation-probe"; +import { withSandboxLifecycleLock } from "./gateway-state"; import { resolveSandboxLifecycleProvider, type SandboxLifecycleResult, @@ -74,6 +75,7 @@ export interface SandboxStartDeps { waitForManagedGatewaySupervisor?: (sandboxName: string) => boolean; verifyGateway?: (sandboxName: string) => Promise; probeInferenceInvocation?: typeof probeSandboxInferenceInvocation; + withLifecycleLock?: typeof withSandboxLifecycleLock; log?: (message: string) => void; } @@ -155,6 +157,15 @@ function checkStartedSandboxInference( export async function startSandbox( sandboxName: string, deps: SandboxStartDeps = {}, +): Promise { + return (deps.withLifecycleLock ?? withSandboxLifecycleLock)(sandboxName, () => + startSandboxWithinLifecycleFence(sandboxName, deps), + ); +} + +async function startSandboxWithinLifecycleFence( + sandboxName: string, + deps: SandboxStartDeps, ): Promise { const log = deps.log ?? console.log; const sandbox = (deps.getSandbox ?? registry.getSandbox)(sandboxName); @@ -176,6 +187,9 @@ export async function startSandbox( if (preflight) return preflight; const result = resolved.lifecycle.start(input); if (result.exitCode !== 0) return result; + if ("hermesPortableVerified" in result && result.hermesPortableVerified === true) { + return { exitCode: 0 }; + } const readiness: { inference: SandboxInferenceInvocationResult | null } = { inference: null }; await resolved.lifecycle.verifyStarted(input, async (name) => { diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index 7e281ea3c15..82945efa053 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -11,6 +11,16 @@ import { resetStatusFlowModuleCache, } from "../../../../test/support/status-flow-test-harness"; +function hermesPortableDisposition(phase: "pending" | "configuring" | "active") { + return { + kind: "hermes" as const, + phase, + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + liveIdentityFingerprint: phase === "pending" ? null : "fingerprint-1", + }; +} + describe("showSandboxStatus flow", () => { let exitSpy: MockInstance; @@ -27,6 +37,112 @@ describe("showSandboxStatus flow", () => { resetStatusFlowModuleCache(); }); + it.each(["pending", "configuring", "active"] as const)( + "reports Hermes portable receipt phase %s without Docker or OpenClaw status work (#9203)", + async (phase) => { + const harness = createStatusFlowHarness({ + portableDisposition: hermesPortableDisposition(phase), + registryEntry: phase === "pending" ? "missing" : "present", + sandboxEntry: { agent: "hermes" }, + }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + const report = await harness.getSandboxStatusReport("alpha"); + + expect(harness.logSpy.mock.calls.flat().join("\n")).toContain( + `Portable lifecycle phase: ${phase}`, + ); + expect(report).toMatchObject({ + schemaVersion: 1, + name: "alpha", + found: phase === "active", + agent: "hermes", + agentDisplayName: "Hermes", + portableLifecyclePhase: phase, + }); + expect(harness.collectSandboxStatusSnapshotSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + expect(harness.withMcpLifecycleLockSpy).toHaveBeenCalledTimes(2); + }, + ); + + it("rejects malformed portable receipt authority before status probes (#9203)", async () => { + const harness = createStatusFlowHarness({ + portableDisposition: new Error("invalid portable lifecycle receipt"), + }); + + await expect(harness.showSandboxStatus("alpha")).rejects.toThrow( + "invalid portable lifecycle receipt", + ); + expect(harness.collectSandboxStatusSnapshotSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + }); + + it.each([ + { field: "gatewayName", value: "other-gateway" }, + { field: "lifecycleGeneration", value: "other-generation" }, + { field: "lifecycleLiveIdentityFingerprint", value: "other-fingerprint" }, + ] as const)("rejects Hermes portable registry disagreement in $field (#9203)", async (drift) => { + const harness = createStatusFlowHarness({ + portableDisposition: hermesPortableDisposition("active"), + sandboxEntry: { agent: "hermes", [drift.field]: drift.value }, + }); + + await expect(harness.getSandboxStatusReport("alpha")).rejects.toThrow( + "receipt and registry authority disagree", + ); + expect(harness.collectSandboxStatusSnapshotSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + }); + + it("rejects an active Hermes receipt with no registry row (#9203)", async () => { + const harness = createStatusFlowHarness({ + portableDisposition: hermesPortableDisposition("active"), + registryEntry: "missing", + }); + + await expect(harness.getSandboxStatusReport("alpha")).rejects.toThrow( + "missing its registry authority", + ); + expect(harness.collectSandboxStatusSnapshotSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + }); + + it("preserves schema-4 OpenClaw status behavior (#9203)", async () => { + const harness = createStatusFlowHarness({ portableDisposition: { kind: "openclaw" } }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + expect(harness.collectSandboxStatusSnapshotSpy).toHaveBeenCalledWith( + "alpha", + expect.anything(), + ); + expect(harness.getSandboxDockerRuntimeSpy).toHaveBeenCalledWith("alpha"); + expect(harness.withMcpLifecycleLockSpy).toHaveBeenCalledWith("alpha", expect.any(Function)); + }); + + it("classifies publication while waiting for the status lifecycle fence (#9203)", async () => { + let disposition: { readonly kind: "absent" } | ReturnType = { + kind: "absent", + }; + const harness = createStatusFlowHarness({ + portableDisposition: () => disposition, + sandboxEntry: { agent: "hermes" }, + withMcpLifecycleLock: async (_sandboxName, operation) => { + disposition = hermesPortableDisposition("active"); + return await operation(); + }, + }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + expect(harness.logSpy.mock.calls.flat().join("\n")).toContain( + "Portable lifecycle phase: active", + ); + expect(harness.collectSandboxStatusSnapshotSpy).not.toHaveBeenCalled(); + expect(harness.getSandboxDockerRuntimeSpy).not.toHaveBeenCalled(); + }); + it("warns when the live gateway route differs from the sandbox's recorded route (#6315)", async () => { const harness = createStatusFlowHarness({ currentProvider: "nvidia", @@ -104,21 +220,24 @@ describe("showSandboxStatus flow", () => { it.each([ ["high", "high"], [null, "endpoint-default"], - ] as const)("reports the effective compatible-endpoint reasoning effort (%s) (#7659)", async (stored, expected) => { - const harness = createStatusFlowHarness({ - currentProvider: "compatible-endpoint", - sandboxEntry: { - provider: "compatible-endpoint", - preferredInferenceApi: "openai-completions", - compatibleEndpointReasoningEffort: stored, - }, - }); + ] as const)( + "reports the effective compatible-endpoint reasoning effort (%s) (#7659)", + async (stored, expected) => { + const harness = createStatusFlowHarness({ + currentProvider: "compatible-endpoint", + sandboxEntry: { + provider: "compatible-endpoint", + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoningEffort: stored, + }, + }); - await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); - const output = harness.logSpy.mock.calls.flat().join("\n"); - expect(output).toContain(`Reasoning effort: ${expected}`); - }); + const output = harness.logSpy.mock.calls.flat().join("\n"); + expect(output).toContain(`Reasoning effort: ${expected}`); + }, + ); it("prints the live sandbox, inference, runtime, session, version, and recovery signals", async () => { const harness = createStatusFlowHarness(); @@ -832,4 +951,26 @@ describe("showSandboxStatus flow", () => { expect(output).toContain("gateway identity drift after restart"); expect(harness.removeSandboxSpy).not.toHaveBeenCalled(); }); + + it("releases the lifecycle lock before a failing status report exits (#9203)", async () => { + const events: string[] = []; + const harness = createStatusFlowHarness({ + lookupState: "missing", + withMcpLifecycleLock: async (_sandboxName, operation) => { + events.push("lock-enter"); + try { + return await operation(); + } finally { + events.push("lock-exit"); + } + }, + }); + exitSpy.mockImplementationOnce(((code?: number) => { + events.push(`exit-${String(code)}`); + throw new Error(`process.exit(${String(code)})`); + }) as never); + + await expect(harness.showSandboxStatus("alpha")).rejects.toThrow("process.exit(1)"); + expect(events).toEqual(["lock-enter", "lock-exit", "exit-1"]); + }); }); diff --git a/src/lib/actions/sandbox/status-lookup-rendering.ts b/src/lib/actions/sandbox/status-lookup-rendering.ts index e76ab91788e..e14fdba1eac 100644 --- a/src/lib/actions/sandbox/status-lookup-rendering.ts +++ b/src/lib/actions/sandbox/status-lookup-rendering.ts @@ -3,6 +3,7 @@ import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { D, R } from "../../cli/terminal-style"; +import { deferSandboxLifecycleExit } from "../../core/process-exit"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; import { isTerminalSandboxPhase } from "../../state/gateway"; import { getSandboxDockerRuntime } from "./docker-health"; @@ -37,10 +38,10 @@ export async function printSandboxGatewayLookupStatus( return; case "gateway_schema_mismatch": console.log(context.lookup.output); - process.exit(1); + deferSandboxLifecycleExit(1); case "missing": printMissingLiveSandboxStatusGuidance(context); - process.exit(1); + deferSandboxLifecycleExit(1); case "identity_drift": printIdentityDriftLookupStatus(context); return; @@ -73,7 +74,7 @@ function printSandboxRecoveryFailedLookupStatus({ console.log( ` Retry \`${CLI_NAME} ${sandboxName} recover\` after addressing the reported layer.`, ); - process.exit(1); + deferSandboxLifecycleExit(1); } function printMissingLiveSandboxStatusGuidance({ @@ -146,7 +147,7 @@ function printWrongGatewayActiveLookupStatus({ : undefined; console.log(""); printWrongGatewayActiveGuidance(sandboxName, activeGateway, console.log); - process.exit(1); + deferSandboxLifecycleExit(1); } function printIdentityDriftLookupStatus({ @@ -166,7 +167,7 @@ function printIdentityDriftLookupStatus({ console.log( ` Recreate this sandbox with \`${CLI_NAME} onboard\` once the gateway runtime is stable.`, ); - process.exit(1); + deferSandboxLifecycleExit(1); } async function printGatewayUnreachableAfterRestartLookupStatus({ @@ -188,7 +189,7 @@ async function printGatewayUnreachableAfterRestartLookupStatus({ console.log( " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", ); - process.exit(1); + deferSandboxLifecycleExit(1); } async function printGatewayMissingAfterRestartLookupStatus({ @@ -208,7 +209,7 @@ async function printGatewayMissingAfterRestartLookupStatus({ console.log( " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", ); - process.exit(1); + deferSandboxLifecycleExit(1); } async function printUnknownGatewayLookupStatus({ @@ -223,7 +224,7 @@ async function printUnknownGatewayLookupStatus({ } await printGatewayFailureLayerHeader(sandboxName, effectivePreflight.failureLayer); printGatewayLifecycleHint(lookup.output, sandboxName, console.log); - process.exit(1); + deferSandboxLifecycleExit(1); } function printNonReadySandboxPhaseGuidance({ @@ -254,7 +255,7 @@ function printNonReadySandboxPhaseGuidance({ if (!isTerminalSandboxPhase(phase) && isDockerRuntimeDown(sandboxName)) { console.log(""); printDockerRuntimeDownGuidance(sandboxName, { writer: console.log }); - process.exit(1); + deferSandboxLifecycleExit(1); } // A paused Docker-driver container can surface upstream as `Phase: Error` // (e.g. GPU passthrough on Ubuntu 24.04) even though the sandbox is diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index ac2f7345faa..41ce5b403d1 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -169,6 +169,8 @@ export interface SandboxStatusReport { liveRoute: GatewayInference | null; routeDrift: SandboxStatusRouteDrift | null; phase: string | null; + /** Receipt-owned Hermes portable lifecycle phase when schema-5 authority is present. */ + portableLifecyclePhase?: "pending" | "configuring" | "active"; gatewayState: string; inferenceHealth: ProviderHealthStatus | null; rpcIssue: { kind: "image_drift" | "host_process_drift" | "protobuf_mismatch" } | null; diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 338742021d0..8ecfd7a1b31 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -3,17 +3,29 @@ import { printOpenShellStateRpcIssue } from "../../adapters/openshell/gateway-drift"; import { CLI_NAME } from "../../cli/branding"; +import { deferSandboxLifecycleExit, isSandboxLifecycleDeferredExit } from "../../core/process-exit"; import { inspectManagedLlamaCppStatus } from "../../inference/llama-cpp/managed-status"; import { parseSandboxPhase } from "../../state/gateway"; +import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; import * as registry from "../../state/registry"; import { getSandboxDockerRuntime } from "./docker-health"; +import { + qualifyPortableAgentLifecycleAuthority, + type HermesPortableAgentLifecycleAuthority, +} from "./gateway-state"; import { printSandboxGatewayLookupStatus } from "./status-lookup-rendering"; import { getSandboxStatusPreflight, printSandboxStatusPreflightHeader, withoutTerminalPhasePreflight, } from "./status-preflight"; -import { collectSandboxStatusSnapshot, resolveSandboxStatusAgent } from "./status-snapshot"; +import { + collectSandboxStatusSnapshot, + getSandboxStatusReport as getLegacySandboxStatusReport, + normalizeSandboxStatusHostMounts, + resolveSandboxStatusAgent, + type SandboxStatusReport, +} from "./status-snapshot"; import { printAgentProcessStatus, printDockerHealth, @@ -38,7 +50,6 @@ export { export { collectSandboxStatusSnapshot, getSandboxStatusInferenceHealth, - getSandboxStatusReport, isInferenceHealthFailing, maybeGetSandboxStatusInferenceHealth, resolveSandboxStatusDcodeAutoApprovalMode, @@ -47,6 +58,78 @@ export { type ServingProcessHealth, } from "./status-snapshot"; +function inspectHermesPortableStatus( + sandboxName: string, +): HermesPortableAgentLifecycleAuthority | null { + const authority = qualifyPortableAgentLifecycleAuthority(sandboxName, { + readRegistry: registry.getSandbox, + }); + return authority.kind === "hermes" ? authority : null; +} + +function hermesPortableStatusReport( + sandboxName: string, + authority: HermesPortableAgentLifecycleAuthority, +): SandboxStatusReport { + const { entry, phase } = authority; + const model = entry?.model ?? "unknown"; + const provider = entry?.provider ?? "unknown"; + return { + schemaVersion: 1, + name: sandboxName, + found: phase === "active", + agent: "hermes", + agentDisplayName: "Hermes", + agentRuntime: "gateway", + dcodeAutoApprovalMode: null, + model, + provider, + servingProfileProvenance: entry?.servingProfileProvenance ?? null, + recordedRoute: + entry?.provider && entry.model ? { provider: entry.provider, model: entry.model } : null, + liveRoute: null, + routeDrift: null, + phase: null, + portableLifecyclePhase: phase, + gatewayState: "not-probed", + inferenceHealth: null, + rpcIssue: null, + hostGpuDetected: entry?.hostGpuDetected === true, + sandboxGpuEnabled: entry?.sandboxGpuEnabled ?? entry?.gpuEnabled === true, + sandboxGpuMode: entry?.sandboxGpuMode ?? null, + sandboxGpuDevice: entry?.sandboxGpuDevice ?? null, + sandboxGpuProof: entry?.sandboxGpuProof ?? null, + hostMounts: normalizeSandboxStatusHostMounts(entry?.hostMounts), + openshellDriver: entry?.openshellDriver ?? "unknown", + openshellVersion: entry?.openshellVersion ?? "unknown", + policies: + entry?.policies?.filter((policy): policy is string => typeof policy === "string") ?? [], + baselineExclusions: entry?.baselineExclusions?.map((exclusion) => exclusion.key) ?? [], + baselineExclusionStates: [], + baselineExclusionTransition: entry?.baselineExclusionTransition + ? { + operation: entry.baselineExclusionTransition.operation, + key: entry.baselineExclusionTransition.exclusion.key, + } + : null, + failureLayer: null, + terminalRuntimeHealth: null, + servingProcessHealth: null, + dockerPaused: false, + }; +} + +export async function getSandboxStatusReport( + sandboxName: string, + deps: Parameters[1] = {}, +): Promise { + return withMcpLifecycleLock(sandboxName, async () => { + const hermesPortable = inspectHermesPortableStatus(sandboxName); + if (hermesPortable) return hermesPortableStatusReport(sandboxName, hermesPortable); + return getLegacySandboxStatusReport(sandboxName, deps); + }); +} + function maybeEnsureHermesToolGatewayBroker(sb: registry.SandboxEntry | null): void { if ( !sb || @@ -65,6 +148,26 @@ function maybeEnsureHermesToolGatewayBroker(sb: registry.SandboxEntry | null): v } export async function showSandboxStatus(sandboxName: string): Promise { + let deferredExitCode: number | null = null; + try { + await withMcpLifecycleLock(sandboxName, async () => { + const hermesPortable = inspectHermesPortableStatus(sandboxName); + if (hermesPortable) { + console.log(` Sandbox: ${sandboxName}`); + console.log(" Agent: Hermes"); + console.log(` Portable lifecycle phase: ${hermesPortable.phase}`); + return; + } + await showLegacySandboxStatus(sandboxName); + }); + } catch (error) { + if (!isSandboxLifecycleDeferredExit(error)) throw error; + deferredExitCode = error.exitCode; + } + if (deferredExitCode !== null) process.exit(deferredExitCode); +} + +async function showLegacySandboxStatus(sandboxName: string): Promise { const preflight = await getSandboxStatusPreflight(registry.getSandbox(sandboxName)); // #2666: never let an unexpected throw from the gateway probe (e.g. openshell // hanging when its container is stopped and the published port is held by a @@ -104,7 +207,7 @@ export async function showSandboxStatus(sandboxName: string): Promise { action: `checking inference status for sandbox '${sandboxName}'`, command: `${CLI_NAME} ${sandboxName} status`, }); - process.exit(1); + deferSandboxLifecycleExit(1); } const textContext: SandboxStatusTextContext = { sandboxName, diff --git a/src/lib/actions/sandbox/stop.test.ts b/src/lib/actions/sandbox/stop.test.ts index 21ecfd7f786..95043cbe128 100644 --- a/src/lib/actions/sandbox/stop.test.ts +++ b/src/lib/actions/sandbox/stop.test.ts @@ -49,9 +49,9 @@ function harness(overrides: StopHarnessOverrides = {}) { const hasPortableLifecycleReceipt = vi.fn< DockerRuntimeProviderDependencies["hasPortableLifecycleReceipt"] >(() => false); - const stopPortableSandbox = vi.fn< - DockerRuntimeProviderDependencies["stopPortableSandbox"] - >(() => ({ kind: "not-installed" })); + const stopPortableSandbox = vi.fn( + () => ({ kind: "not-installed" }), + ); const stopSandboxChannels = vi.fn>(); const dockerStop = vi.fn( dockerStopOverride ?? (() => ({ status: 0 })), @@ -83,6 +83,7 @@ function harness(overrides: StopHarnessOverrides = {}) { warn, exclusivelyHeldOllamaModel, withOllamaModelOwnershipLock: (operation) => operation(), + withLifecycleLockSync: (_sandboxName, operation) => operation(), ...actionOverrides, }; return { @@ -297,12 +298,35 @@ describe("stopSandbox", () => { expect.any(Function), expect.objectContaining({ env: process.env }), ); - expect(h.stopSandboxChannels).toHaveBeenCalledExactlyOnceWith( - "my-sandbox", - expect.any(Object), + expect(h.stopSandboxChannels).toHaveBeenCalledExactlyOnceWith("my-sandbox", expect.any(Object)); + expect(h.findLabeledSandboxContainers).not.toHaveBeenCalled(); + expect(h.dockerStop).not.toHaveBeenCalled(); + }); + + it("keeps active Hermes stop out of Docker and Docker-capable channel transport (#9203)", () => { + const unloadOllamaModels = vi.fn(); + const h = harness({ unloadOllamaModels }); + h.getSandbox.mockReturnValue( + sandbox({ + agent: "hermes", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-alpha", + lifecycleLiveIdentityFingerprint: "identity-alpha", + openshellDriver: "docker", + provider: "ollama/qwen3-vl:4b", + }), ); + h.hasPortableLifecycleReceipt.mockReturnValue(true); + h.stopPortableSandbox.mockReturnValue({ kind: "stopped", portableAgent: "hermes" }); + + expect(stopSandbox("my-sandbox", h.deps)).toEqual({ exitCode: 0 }); + + expect(h.isDockerRuntimeDown).not.toHaveBeenCalled(); + expect(h.stopSandboxChannels).not.toHaveBeenCalled(); expect(h.findLabeledSandboxContainers).not.toHaveBeenCalled(); expect(h.dockerStop).not.toHaveBeenCalled(); + expect(h.teardownSandboxDashboardForward).not.toHaveBeenCalled(); + expect(unloadOllamaModels).not.toHaveBeenCalled(); }); it("succeeds idempotently when the container is already stopped (#6026)", () => { diff --git a/src/lib/actions/sandbox/stop.ts b/src/lib/actions/sandbox/stop.ts index f14065bb328..616d8845cd7 100644 --- a/src/lib/actions/sandbox/stop.ts +++ b/src/lib/actions/sandbox/stop.ts @@ -9,6 +9,7 @@ import { import * as registry from "../../state/registry"; import { stopSandboxChannels } from "../../tunnel/sandbox-gateway-stop"; import { teardownSandboxDashboardForward } from "./forward-recovery"; +import { withSandboxLifecycleLockSync } from "./gateway-state"; import { resolveSandboxLifecycleProvider, type SandboxLifecycleResult, @@ -42,10 +43,7 @@ function defaultUnloadOllamaModels(onlyModels: readonly string[]): void { * the sandbox is already stopped by this point, so GPU cleanup must never * change the exit code. */ -function unloadOllamaModelsBestEffort( - sandbox: registry.SandboxEntry, - deps: SandboxStopDeps, -): void { +function unloadOllamaModelsBestEffort(sandbox: registry.SandboxEntry, deps: SandboxStopDeps): void { if (!sandbox.provider?.includes("ollama")) return; try { const withOwnershipLock = @@ -54,8 +52,9 @@ function unloadOllamaModelsBestEffort( .withOllamaModelOwnershipLock; const exclusivelyHeldOllamaModel = deps.exclusivelyHeldOllamaModel ?? - (require("../../inference/ollama/model-ownership") as typeof import("../../inference/ollama/model-ownership")) - .exclusivelyHeldOllamaModel; + ( + require("../../inference/ollama/model-ownership") as typeof import("../../inference/ollama/model-ownership") + ).exclusivelyHeldOllamaModel; withOwnershipLock(() => { const { sandboxes } = (deps.listSandboxes ?? registry.listSandboxes)(); const model = exclusivelyHeldOllamaModel(sandbox, sandboxes); @@ -79,6 +78,7 @@ export interface SandboxStopDeps { unloadOllamaModels?: (onlyModels: readonly string[]) => void; exclusivelyHeldOllamaModel?: typeof import("../../inference/ollama/model-ownership").exclusivelyHeldOllamaModel; withOllamaModelOwnershipLock?: typeof import("../../inference/ollama/proxy").withOllamaModelOwnershipLock; + withLifecycleLockSync?: typeof withSandboxLifecycleLockSync; log?: (message: string) => void; warn?: (message: string) => void; } @@ -90,6 +90,15 @@ export interface SandboxStopDeps { export function stopSandbox( sandboxName: string, deps: SandboxStopDeps = {}, +): SandboxLifecycleResult { + return (deps.withLifecycleLockSync ?? withSandboxLifecycleLockSync)(sandboxName, () => + stopSandboxWithinLifecycleFence(sandboxName, deps), + ); +} + +function stopSandboxWithinLifecycleFence( + sandboxName: string, + deps: SandboxStopDeps, ): SandboxLifecycleResult { const log = deps.log ?? console.log; const warn = deps.warn ?? console.warn; @@ -129,6 +138,15 @@ export function stopSandbox( }, }); if (outcome.exitCode !== 0) return outcome; + if ("hermesPortableVerified" in outcome && outcome.hermesPortableVerified === true) { + log( + outcome.state === "already-stopped" + ? ` Sandbox '${sandboxName}' is already stopped.` + : ` Sandbox '${sandboxName}' stopped. Workspace state is preserved.`, + ); + log(` Start it again with '${CLI_NAME} ${sandboxName} start'.`); + return { exitCode: 0 }; + } unloadOllamaModelsBestEffort(resolved.sandbox, deps); diff --git a/src/lib/actions/sandbox/upload.ts b/src/lib/actions/sandbox/upload.ts index a861931f68b..cfcbd550eca 100644 --- a/src/lib/actions/sandbox/upload.ts +++ b/src/lib/actions/sandbox/upload.ts @@ -3,6 +3,8 @@ import { runOpenshell } from "../../adapters/openshell/runtime"; import { CLI_NAME } from "../../cli/branding"; +import { assertHermesPortableCommandUnavailable } from "../../onboard/experimental/portable-agent-lifecycle"; +import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; import { ensureLiveSandboxOrExit } from "./gateway-state"; import { resolveHostPathFromCwd } from "./host-path"; @@ -19,6 +21,15 @@ export interface SandboxUploadResult { } export async function uploadToSandbox(opts: SandboxUploadOptions): Promise { + return withMcpLifecycleLock(opts.sandboxName, () => { + assertHermesPortableCommandUnavailable(opts.sandboxName, "sandbox:upload"); + return uploadToSandboxUnlocked(opts); + }); +} + +async function uploadToSandboxUnlocked( + opts: SandboxUploadOptions, +): Promise { const trimmedHostPath = (opts.hostPath ?? "").trim(); if (!trimmedHostPath) { throw new Error( diff --git a/src/lib/actions/uninstall/portable-runtime-cleanup.ts b/src/lib/actions/uninstall/portable-runtime-cleanup.ts index e8012323531..12278333382 100644 --- a/src/lib/actions/uninstall/portable-runtime-cleanup.ts +++ b/src/lib/actions/uninstall/portable-runtime-cleanup.ts @@ -11,6 +11,8 @@ import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-che import { hasPortableUninstallAuthority } from "../../onboard/portable-retirement-authority"; import { withMcpLifecycleLockSync } from "../../state/mcp-lifecycle-lock-acquisition"; import { + assertNoHermesPortableHostAuthority, + defaultPortableStateDir, inspectPortableRetirementRecovery, PORTABLE_RETIREMENT_STATE_ENTRIES, preparePortableRetirement, @@ -53,6 +55,11 @@ const PORTABLE_SELECTOR_NAMES = [ ] as const; const UTF8 = new TextDecoder("utf-8", { fatal: true }); +/** Refuse legacy uninstall while the host fence keeps schema-5 authority stable. */ +export function assertHermesPortableUninstallAvailable(env: NodeJS.ProcessEnv): void { + assertNoHermesPortableHostAuthority(defaultPortableStateDir(env), "uninstall"); +} + interface PortableRegistryRemoval { readonly present: boolean; removeAndVerify(): void; diff --git a/src/lib/actions/uninstall/run-plan-portable-runtime.test.ts b/src/lib/actions/uninstall/run-plan-portable-runtime.test.ts index c3fba530a62..22c19ba5efd 100644 --- a/src/lib/actions/uninstall/run-plan-portable-runtime.test.ts +++ b/src/lib/actions/uninstall/run-plan-portable-runtime.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, assert, describe, expect, it, vi } from "vitest"; +import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; import { withProvenManagedGatewayProcess, writeManagedGatewayRuntimeProof, @@ -214,7 +215,7 @@ afterEach(() => { } }); -describe("portable runtime cleanup in the uninstall run plan", () => { +describe("portable runtime cleanup in the uninstall run plan", testTimeoutOptions(15_000), () => { it.each<[string, EvidenceMutation]>([ ["receipt without configuration", (home, state) => writeAdmissionReceipt(home, state)], [ @@ -368,6 +369,38 @@ describe("portable runtime cleanup in the uninstall run plan", () => { expect(fs.existsSync(evidence)).toBe(true); }); + it("rejects schema-5 Hermes authority under the host fence before uninstall effects (#9203)", async () => { + const scope = admissionFailureScope("nemoclaw-hermes-uninstall-"); + const authority = path.join(scope.stateDir, "hermes-portable-lifecycle", "receipt-stem"); + fs.mkdirSync(authority, { recursive: true, mode: 0o700 }); + const stderr = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const result = await runUninstallPlanProduction( + { assumeYes: true, deleteModels: true, destroyUserData: true, keepOpenShell: false }, + { + ...admissionFailureDeps(scope), + env: { + HOME: scope.homeDir, + VITEST: "true", + NEMOCLAW_TEST_BASE_HOME: scope.homeDir, + NEMOCLAW_TEST_STATE_DIR: scope.stateDir, + }, + }, + ); + + expect(result.exitCode).toBe(1); + expect(stderr.mock.calls.flat().join("\n")).toContain( + "Command 'uninstall' is not supported while an experimental Hermes portable lifecycle receipt exists", + ); + expect(scope.run).not.toHaveBeenCalled(); + expect(scope.runDocker).not.toHaveBeenCalled(); + expect(scope.runModelCleanup).not.toHaveBeenCalled(); + expect(scope.rmSync).not.toHaveBeenCalled(); + expect(scope.kill).not.toHaveBeenCalled(); + expect(scope.runPortableCleanup).not.toHaveBeenCalled(); + expect(fs.statSync(authority).isDirectory()).toBe(true); + }); + it("uses exact receipt names without Docker or an all-sandbox mutation (#9189)", () => { const order: string[] = []; const logs: string[] = []; diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 60eaded4e25..2bf18b10423 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -85,6 +85,7 @@ import { type UninstallPlan, } from "./plan"; import { + assertHermesPortableUninstallAvailable, hasPortableRuntimeCleanup, PORTABLE_RETIREMENT_STATE_ENTRIES, portableRetirementPreservationEntries, @@ -3156,9 +3157,10 @@ export async function runUninstallPlanProduction( const env = { ...process.env, ...(deps.env ?? {}) }; const home = env.HOME || os.homedir(); try { - return await (deps.withPortableHostFence ?? withPortableHostFence)(home, () => - runUninstallPlan(options, { ...deps, env }), - ); + return await (deps.withPortableHostFence ?? withPortableHostFence)(home, () => { + assertHermesPortableUninstallAvailable(env); + return runUninstallPlan(options, { ...deps, env }); + }); } catch (error) { (deps.error ?? ((message: string) => console.error(message)))( `Uninstall could not acquire or release portable host authority: ${formatError(error)}`, diff --git a/src/lib/adapters/container-engine.test.ts b/src/lib/adapters/container-engine.test.ts index a0a6bf68ee1..1fe1532d556 100644 --- a/src/lib/adapters/container-engine.test.ts +++ b/src/lib/adapters/container-engine.test.ts @@ -131,6 +131,40 @@ describe("operation-scoped container engine command", () => { expect(engine.captureWithEnvironment).toBeUndefined(); }); + it("replaces the ambient environment for one authority-bound operation", () => { + const capture = vi.fn(() => ({ + status: 0, + stdout: "", + stderr: "", + })); + const commandEnvironment = Object.freeze({ + HOME: "/home/receipt", + XDG_CONFIG_HOME: "/home/receipt/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + }); + const engine = createContainerEngineCommand({ + operation: "state-mutation", + engineId: "podman", + displayName: "Podman", + authorityId: "test:podman-socket", + executable: "/usr/bin/podman", + commandEnvironment, + capture, + }); + + engine.capture(["container", "inspect", "abc"]); + + expect(capture).toHaveBeenCalledExactlyOnceWith( + "/usr/bin/podman", + ["container", "inspect", "abc"], + 15_000, + undefined, + commandEnvironment, + ); + expect(capture.mock.calls[0]?.[4]).not.toHaveProperty("HTTP_PROXY"); + expect(capture.mock.calls[0]?.[4]).not.toHaveProperty("DOCKER_HOST"); + }); + it("rejects unsafe or unbounded operation environments before capture", () => { const capture = vi.fn(() => ({ status: 0, diff --git a/src/lib/adapters/container-engine.ts b/src/lib/adapters/container-engine.ts index 1e61fcdab06..7ff6b699d85 100644 --- a/src/lib/adapters/container-engine.ts +++ b/src/lib/adapters/container-engine.ts @@ -72,6 +72,8 @@ export interface ContainerEngineCommandOptions { readonly endpointArgs?: readonly string[]; /** Exact operation-scoped names that may be added to the sanitized child environment. */ readonly allowedEnvironmentNames?: readonly string[]; + /** Complete child environment for one authority-bound engine operation. */ + readonly commandEnvironment?: Readonly>; readonly capture?: ContainerEngineCommandCapture; readonly guard?: (phase: "before" | "after") => void; } @@ -179,6 +181,44 @@ function operationCommandEnvironment( }); } +function replacementCommandEnvironment( + explicit: Readonly>, +): Readonly> { + if (typeof explicit !== "object" || explicit === null || Array.isArray(explicit)) { + throw new Error("Container engine command environment is invalid."); + } + const entries = Object.entries(explicit); + if (entries.length > MAX_ENVIRONMENT_ENTRIES) { + throw new Error("Container engine command environment has too many entries."); + } + let totalBytes = 0; + const normalized: Record = Object.create(null); + for (const [name, value] of entries) { + if ( + !ENVIRONMENT_NAME_PATTERN.test(name) || + ENGINE_ENV_NAMES.has(name) || + (!COMMAND_ENV_NAMES.has(name) && + !COMMAND_ENV_PREFIXES.some((prefix) => name.startsWith(prefix))) + ) { + throw new Error("Container engine command environment name is invalid."); + } + if ( + typeof value !== "string" || + value === "" || + Buffer.byteLength(value, "utf8") > MAX_ARGUMENT_BYTES || + CONTROL_CHARACTERS.test(value) + ) { + throw new Error("Container engine command environment value is invalid."); + } + totalBytes += Buffer.byteLength(name, "utf8") + Buffer.byteLength(value, "utf8"); + if (totalBytes > MAX_ENVIRONMENT_BYTES) { + throw new Error("Container engine command environment exceeds its byte bound."); + } + normalized[name] = value; + } + return Object.freeze(normalized); +} + function boundedText(value: string, label: string, allowPath = false): string { const normalized = value.trim(); if ( @@ -321,6 +361,9 @@ export function createContainerEngineCommand( return name; }), ); + const commandEnvironment = options.commandEnvironment + ? replacementCommandEnvironment(options.commandEnvironment) + : undefined; const capture = options.capture ?? defaultCapture; const run = ( args: readonly string[], @@ -336,13 +379,14 @@ export function createContainerEngineCommand( } const boundedTimeout = positiveTimeout(timeoutMs); return invokeGuarded(options.guard, () => { - if (environment !== undefined) { + const selectedEnvironment = environment ?? commandEnvironment; + if (selectedEnvironment !== undefined) { return capture( executable, commandArgs, boundedTimeout, input === undefined ? undefined : Buffer.from(input), - environment, + selectedEnvironment, ); } return input === undefined diff --git a/src/lib/adapters/openshell/resolve-shared.ts b/src/lib/adapters/openshell/resolve-shared.ts index 7e446fc44ef..9643f6a60d3 100644 --- a/src/lib/adapters/openshell/resolve-shared.ts +++ b/src/lib/adapters/openshell/resolve-shared.ts @@ -1,9 +1,200 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; + +import { + assertPodmanExecutableAuthority, + capturePodmanExecutableAuthority, + type PodmanExecutableAuthority, + type PodmanExecutableAuthorityDeps, +} from "../podman/executable-authority"; import { resolveOpenshell } from "./resolve"; +export const HERMES_PORTABLE_OPENSHELL_VERSION = "0.0.101" as const; +const VERSION_TIMEOUT_MS = 5_000; +const VERSION_MAX_BUFFER_BYTES = 16 * 1024; +const SEMVER_PATTERN = /(?:^|[^0-9.])([0-9]+\.[0-9]+\.[0-9]+)(?![0-9.])/u; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function parseExecutableVersion(value: string, executablePath: string): string | null { + const executable = executablePath.trim().split(/\s+/u, 1)[0]?.split("/").pop() ?? ""; + if (executable) { + const executablePattern = new RegExp(`\\b${escapeRegExp(executable)}\\b`, "iu"); + let executableSeen = false; + for (const line of value.split(/\r?\n/u)) { + const executableMatch = executablePattern.exec(line); + if (!executableMatch) continue; + executableSeen = true; + const version = line + .slice(executableMatch.index + executableMatch[0].length) + .match(SEMVER_PATTERN)?.[1]; + if (version) return version; + } + if (executableSeen) return null; + } + return value.match(SEMVER_PATTERN)?.[1] ?? null; +} + +export interface HermesPortableOpenShellExecutableAuthority { + readonly executable: PodmanExecutableAuthority; + readonly version: typeof HERMES_PORTABLE_OPENSHELL_VERSION; +} + +type VersionResult = Pick< + SpawnSyncReturns, + "error" | "status" | "stderr" | "stdout" +>; + +export interface HermesPortableOpenShellExecutableAuthorityDeps + extends PodmanExecutableAuthorityDeps { + readonly resolve?: (env: NodeJS.ProcessEnv) => string | null; + readonly runVersion?: (executable: string, env: NodeJS.ProcessEnv) => VersionResult; +} + +function failExecutableAuthority(message: string): never { + throw new Error(`Hermes portable OpenShell executable authority ${message}`); +} + +function runVersion(executable: string, env: NodeJS.ProcessEnv): VersionResult { + return spawnSync(executable, ["--version"], { + encoding: "utf8", + env, + maxBuffer: VERSION_MAX_BUFFER_BYTES, + stdio: ["ignore", "pipe", "pipe"], + timeout: VERSION_TIMEOUT_MS, + }); +} + +function requireVersion( + executable: string, + env: NodeJS.ProcessEnv, + probe: (executable: string, env: NodeJS.ProcessEnv) => VersionResult, +): typeof HERMES_PORTABLE_OPENSHELL_VERSION { + const result = probe(executable, env); + if (result.error || result.status !== 0) failExecutableAuthority("version probe failed"); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (parseExecutableVersion(output, executable) !== HERMES_PORTABLE_OPENSHELL_VERSION) { + failExecutableAuthority(`requires OpenShell ${HERMES_PORTABLE_OPENSHELL_VERSION}`); + } + return HERMES_PORTABLE_OPENSHELL_VERSION; +} + +function resolveCurrent( + env: NodeJS.ProcessEnv, + resolver?: (env: NodeJS.ProcessEnv) => string | null, +): string | null { + return resolver?.(env) ?? resolveOpenshell({ env }); +} + +/** Capture the exact schema-5 OpenShell executable before reservation effects. */ +export function captureHermesPortableOpenShellExecutableAuthority( + executablePath: string, + childEnv: NodeJS.ProcessEnv, + resolutionEnv: NodeJS.ProcessEnv, + deps: HermesPortableOpenShellExecutableAuthorityDeps = {}, +): HermesPortableOpenShellExecutableAuthority { + if (resolveCurrent(resolutionEnv, deps.resolve) !== executablePath) { + failExecutableAuthority("disagrees with the admitted OpenShell resolution"); + } + let executable: PodmanExecutableAuthority; + try { + executable = capturePodmanExecutableAuthority(executablePath, deps); + } catch { + failExecutableAuthority("could not capture a safe executable generation"); + } + return Object.freeze({ + executable, + version: requireVersion(executablePath, childEnv, deps.runVersion ?? runVersion), + }); +} + +/** Revalidate exact schema-5 path, generation, digest, and version before a child. */ +export function assertHermesPortableOpenShellExecutableAuthority( + expected: HermesPortableOpenShellExecutableAuthority, + childEnv: NodeJS.ProcessEnv, + resolutionEnv: NodeJS.ProcessEnv, + deps: HermesPortableOpenShellExecutableAuthorityDeps = {}, +): string { + if ( + expected.version !== HERMES_PORTABLE_OPENSHELL_VERSION || + resolveCurrent(resolutionEnv, deps.resolve) !== expected.executable.executablePath + ) { + failExecutableAuthority("disagrees with the current OpenShell resolution"); + } + try { + assertPodmanExecutableAuthority(expected.executable, deps); + } catch { + failExecutableAuthority("executable generation changed after reservation"); + } + requireVersion( + expected.executable.executablePath, + childEnv, + deps.runVersion ?? runVersion, + ); + return expected.executable.executablePath; +} + +export interface OpenShellSubprocessRuntimeAuthority { + readonly homeDir: string; + readonly configHome: string; + readonly runtimeDir: string; +} + +function requireMatchingEnvironmentValue( + source: NodeJS.ProcessEnv, + name: string, + expected: string, +): void { + const actual = source[name]; + if (actual !== undefined && actual !== "" && actual !== expected) { + throw new Error(`Hermes portable OpenShell environment ${name} disagrees with runtime authority`); + } +} + +/** Build the allowlisted environment for a receipt-owned direct OpenShell child. */ +export function buildOpenShellSubprocessEnv( + source: NodeJS.ProcessEnv = process.env, + authority?: OpenShellSubprocessRuntimeAuthority, +): NodeJS.ProcessEnv { + const names = new Set([ + "HOME", + "USER", + "LOGNAME", + "PATH", + "TERM", + "LANG", + "TMPDIR", + "TMP", + "TEMP", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "CURL_CA_BUNDLE", + ]); + const environment = Object.fromEntries( + Object.entries(source).filter( + (entry): entry is [string, string] => + entry[1] !== undefined && + (names.has(entry[0]) || entry[0].startsWith("LC_")), + ), + ); + if (!authority) return environment; + requireMatchingEnvironmentValue(source, "HOME", authority.homeDir); + requireMatchingEnvironmentValue(source, "XDG_CONFIG_HOME", authority.configHome); + requireMatchingEnvironmentValue(source, "XDG_RUNTIME_DIR", authority.runtimeDir); + return { + ...environment, + HOME: authority.homeDir, + XDG_CONFIG_HOME: authority.configHome, + XDG_RUNTIME_DIR: authority.runtimeDir, + }; +} + /** Resolve OpenShell without exiting when it is unavailable. */ -export function resolveOpenshellBinaryOrNull(): string | null { - return resolveOpenshell(); +export function resolveOpenshellBinaryOrNull(env?: NodeJS.ProcessEnv): string | null { + return resolveOpenshell(env ? { env } : undefined); } diff --git a/src/lib/adapters/openshell/resolve.test.ts b/src/lib/adapters/openshell/resolve.test.ts index 03b11e21561..7464fdcddab 100644 --- a/src/lib/adapters/openshell/resolve.test.ts +++ b/src/lib/adapters/openshell/resolve.test.ts @@ -5,7 +5,65 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import type { PodmanExecutableStat } from "../podman/executable-authority"; import { resolveOpenshell } from "./resolve"; +import { + assertHermesPortableOpenShellExecutableAuthority, + captureHermesPortableOpenShellExecutableAuthority, + type HermesPortableOpenShellExecutableAuthorityDeps, +} from "./resolve-shared"; + +const AUTHORITY_BINARY = "/opt/nemoclaw/bin/openshell"; + +function executableAuthorityHarness() { + let executableInode = 10n; + let executableBytes = Buffer.from("openshell-binary"); + let parentInode = 20n; + const stat = ( + kind: "directory" | "file", + inode: bigint, + size = 0n, + ): PodmanExecutableStat => ({ + dev: 1n, + ino: inode, + mode: kind === "file" ? 0o100755n : 0o40755n, + uid: kind === "file" ? 1000n : 0n, + size, + mtimeNs: 30n, + ctimeNs: 40n, + isDirectory: () => kind === "directory", + isFile: () => kind === "file", + isSymbolicLink: () => false, + }); + const deps: HermesPortableOpenShellExecutableAuthorityDeps = { + uid: 1000, + realpath: (filePath) => filePath, + readFile: () => executableBytes, + lstat: (filePath) => + filePath === AUTHORITY_BINARY + ? stat("file", executableInode, BigInt(executableBytes.byteLength)) + : stat("directory", filePath === "/opt/nemoclaw/bin" ? parentInode : 21n), + resolve: (env) => env.NEMOCLAW_OPENSHELL_BIN ?? AUTHORITY_BINARY, + runVersion: () => ({ + status: 0, + stdout: "openshell 0.0.101\n", + stderr: "", + }), + }; + return { + deps, + replaceBinary: () => { + executableInode += 1n; + executableBytes = Buffer.from("replacement-binary"); + }, + changeDigest: () => { + executableBytes = Buffer.alloc(executableBytes.byteLength, 0x78); + }, + rotateParent: () => { + parentInode += 1n; + }, + }; +} describe("lib/resolve-openshell", () => { it("returns command -v result when absolute path", () => { @@ -122,3 +180,137 @@ describe("lib/resolve-openshell", () => { ).toBeNull(); }); }); + +describe("Hermes portable OpenShell executable authority", () => { + const childEnv = { HOME: "/home/test", PATH: "/opt/nemoclaw/bin" }; + + it("captures and reuses the exact canonical OpenShell 0.0.101 generation (#9203)", () => { + const harness = executableAuthorityHarness(); + const authority = captureHermesPortableOpenShellExecutableAuthority( + AUTHORITY_BINARY, + childEnv, + childEnv, + harness.deps, + ); + + expect(authority.version).toBe("0.0.101"); + expect(authority.executable.executablePath).toBe(AUTHORITY_BINARY); + expect( + assertHermesPortableOpenShellExecutableAuthority( + authority, + childEnv, + childEnv, + harness.deps, + ), + ).toBe(AUTHORITY_BINARY); + }); + + it("rejects PATH or explicit binary selection drift before reuse (#9203)", () => { + const harness = executableAuthorityHarness(); + const authority = captureHermesPortableOpenShellExecutableAuthority( + AUTHORITY_BINARY, + childEnv, + childEnv, + harness.deps, + ); + + expect(() => + assertHermesPortableOpenShellExecutableAuthority( + authority, + childEnv, + { ...childEnv, NEMOCLAW_OPENSHELL_BIN: "/tmp/other-openshell" }, + harness.deps, + ), + ).toThrow("disagrees with the current OpenShell resolution"); + }); + + it("rejects binary replacement and parent rotation (#9203)", () => { + const binaryHarness = executableAuthorityHarness(); + const binaryAuthority = captureHermesPortableOpenShellExecutableAuthority( + AUTHORITY_BINARY, + childEnv, + childEnv, + binaryHarness.deps, + ); + binaryHarness.replaceBinary(); + expect(() => + assertHermesPortableOpenShellExecutableAuthority( + binaryAuthority, + childEnv, + childEnv, + binaryHarness.deps, + ), + ).toThrow("executable generation changed after reservation"); + + const parentHarness = executableAuthorityHarness(); + const parentAuthority = captureHermesPortableOpenShellExecutableAuthority( + AUTHORITY_BINARY, + childEnv, + childEnv, + parentHarness.deps, + ); + parentHarness.rotateParent(); + expect(() => + assertHermesPortableOpenShellExecutableAuthority( + parentAuthority, + childEnv, + childEnv, + parentHarness.deps, + ), + ).toThrow("executable generation changed after reservation"); + }); + + it("rejects a same-generation content digest mismatch (#9203)", () => { + const harness = executableAuthorityHarness(); + const authority = captureHermesPortableOpenShellExecutableAuthority( + AUTHORITY_BINARY, + childEnv, + childEnv, + harness.deps, + ); + harness.changeDigest(); + + expect(() => + assertHermesPortableOpenShellExecutableAuthority( + authority, + childEnv, + childEnv, + harness.deps, + ), + ).toThrow("executable generation changed after reservation"); + }); + + it("rejects version mismatch before authority is captured or reused (#9203)", () => { + const harness = executableAuthorityHarness(); + expect(() => + captureHermesPortableOpenShellExecutableAuthority( + AUTHORITY_BINARY, + childEnv, + childEnv, + { + ...harness.deps, + runVersion: () => ({ status: 0, stdout: "openshell 0.0.102\n", stderr: "" }), + }, + ), + ).toThrow("requires OpenShell 0.0.101"); + + const reuseHarness = executableAuthorityHarness(); + const authority = captureHermesPortableOpenShellExecutableAuthority( + AUTHORITY_BINARY, + childEnv, + childEnv, + reuseHarness.deps, + ); + expect(() => + assertHermesPortableOpenShellExecutableAuthority( + authority, + childEnv, + childEnv, + { + ...reuseHarness.deps, + runVersion: () => ({ status: 0, stdout: "openshell 0.0.102\n", stderr: "" }), + }, + ), + ).toThrow("requires OpenShell 0.0.101"); + }); +}); diff --git a/src/lib/adapters/openshell/resolve.ts b/src/lib/adapters/openshell/resolve.ts index fa74b4a45d7..1a733c4fc8b 100644 --- a/src/lib/adapters/openshell/resolve.ts +++ b/src/lib/adapters/openshell/resolve.ts @@ -4,7 +4,7 @@ import { execSync } from "node:child_process"; import { accessSync, constants } from "node:fs"; -import { buildSubprocessEnv } from "../../subprocess-env"; +import { buildSubprocessEnv, isSubprocessEnvNameAllowed } from "../../subprocess-env"; export interface ResolveOpenshellOptions { /** Mock result for `command -v` (undefined = run real command). */ @@ -13,6 +13,8 @@ export interface ResolveOpenshellOptions { checkExecutable?: (path: string) => boolean; /** HOME directory override. */ home?: string; + /** Environment used only for an explicitly authority-bound resolution. */ + env?: NodeJS.ProcessEnv; } /** @@ -22,7 +24,8 @@ export interface ResolveOpenshellOptions { * injection), then falls back to common installation directories. */ export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null { - const home = opts.home ?? process.env.HOME; + const sourceEnv = opts.env ?? process.env; + const home = opts.home ?? sourceEnv.HOME; const checkExecutable = opts.checkExecutable ?? ((p: string): boolean => { @@ -34,7 +37,7 @@ export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | n } }); - const override = process.env.NEMOCLAW_OPENSHELL_BIN; + const override = sourceEnv.NEMOCLAW_OPENSHELL_BIN; if (override?.startsWith("/") && checkExecutable(override)) { return override; } @@ -42,9 +45,15 @@ export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | n // Step 1: command -v if (opts.commandVResult === undefined) { try { + const resolutionEnv = Object.fromEntries( + Object.entries(sourceEnv).filter( + (entry): entry is [string, string] => + entry[1] !== undefined && isSubprocessEnvNameAllowed(entry[0]), + ), + ); const found = execSync("command -v openshell", { encoding: "utf-8", - env: buildSubprocessEnv(), + env: buildSubprocessEnv(resolutionEnv), }).trim(); if (found.startsWith("/")) return found; } catch { diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index da0414fff7f..e9be44fcfaf 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -12,11 +12,13 @@ import { getInstalledOpenshellVersion, runOpenshellCommand, } from "./client"; -import { resolveOpenshellBinaryOrNull } from "./resolve-shared"; +import { buildOpenShellSubprocessEnv, resolveOpenshellBinaryOrNull } from "./resolve-shared"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts"; type CommandArgs = string[]; +export { buildOpenShellSubprocessEnv }; + type RunnerOptions = { /** Exact canonical executable selected by a CUA authority snapshot. */ openshellBinary?: string; @@ -48,7 +50,7 @@ export function getOpenshellBinary(): string { /** Run an OpenShell command, inheriting stdio (no output capture). */ export function runOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { - return runOpenshellCommand(getOpenshellBinary(), args, { + return runOpenshellCommand(opts.openshellBinary ?? getOpenshellBinary(), args, { cwd: ROOT, env: opts.env, replaceEnv: opts.replaceEnv, @@ -68,7 +70,7 @@ export function runOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { * must stay non-fatal yet still read status text OpenShell writes to stderr). */ export function captureOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { - return captureOpenshellCommand(getOpenshellBinary(), args, { + return captureOpenshellCommand(opts.openshellBinary ?? getOpenshellBinary(), args, { cwd: ROOT, env: opts.env, replaceEnv: opts.replaceEnv, diff --git a/src/lib/adapters/openshell/sandbox-identity.test.ts b/src/lib/adapters/openshell/sandbox-identity.test.ts index 0b623d9e763..f9b859fadb7 100644 --- a/src/lib/adapters/openshell/sandbox-identity.test.ts +++ b/src/lib/adapters/openshell/sandbox-identity.test.ts @@ -1,9 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; + import { describe, expect, it, vi } from "vitest"; -import { createOpenshellSandboxIdReader, parseOpenShellSandboxId } from "./sandbox-identity"; +import { + createOpenshellSandboxIdReader, + fingerprintOpenShellSandboxLiveIdentity, + parseOpenShellSandboxId, +} from "./sandbox-identity"; describe("OpenShell sandbox identity parsing", () => { it("accepts one exact durable ID with optional terminal color", () => { @@ -18,6 +24,13 @@ describe("OpenShell sandbox identity parsing", () => { expect(parseOpenShellSandboxId("ID: sandbox/alpha\n")).toBeNull(); expect(parseOpenShellSandboxId("id: sandbox-alpha\n")).toBeNull(); }); + + it("fingerprints only one bounded durable ID (#9203)", () => { + expect(fingerprintOpenShellSandboxLiveIdentity("Name: alpha\nId: sandbox-alpha\n")).toBe( + createHash("sha256").update("sandbox-alpha").digest("hex"), + ); + expect(fingerprintOpenShellSandboxLiveIdentity("Name: alpha\nPhase: Ready\n")).toBeNull(); + }); }); describe("OpenShell sandbox identity reading", () => { diff --git a/src/lib/adapters/openshell/sandbox-identity.ts b/src/lib/adapters/openshell/sandbox-identity.ts index b80724e3e7d..1bbe581af24 100644 --- a/src/lib/adapters/openshell/sandbox-identity.ts +++ b/src/lib/adapters/openshell/sandbox-identity.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; + const ANSI_RE = /\x1b\[[0-9;]*m/gu; const SANDBOX_ID_RE = /^[A-Za-z0-9._-]+$/u; @@ -15,6 +17,14 @@ export function parseOpenShellSandboxId(output: string): string | null { : null; } +/** Hash the one durable OpenShell ID without importing sandbox mutation owners. */ +export function fingerprintOpenShellSandboxLiveIdentity(output: string): string | null { + const clean = String(output).replace(ANSI_RE, ""); + const match = clean.match(/^\s*Id:\s+(\S+)\s*$/im); + if (!match?.[1] || match[1].length > 512) return null; + return createHash("sha256").update(match[1]).digest("hex"); +} + export function resolveOpenShellSandboxId( sandboxName: string, runCaptureOpenshell: (args: string[], options?: Record) => string, diff --git a/src/lib/adapters/podman/index.test.ts b/src/lib/adapters/podman/index.test.ts index 50673c3039e..ba839c8bd98 100644 --- a/src/lib/adapters/podman/index.test.ts +++ b/src/lib/adapters/podman/index.test.ts @@ -1,12 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; import type { ContainerEngineCommandCapture } from "../container-engine"; import { createPodmanContainerEngine, localPodmanEnvironment, + resolvePodmanExecutablePath, type PodmanExecutableAuthorityDeps, type PodmanExecutableStat, type PodmanSocketAuthority, @@ -56,6 +61,34 @@ function executableAuthorityDeps( } describe("Podman container engine command adapter", () => { + it("resolves and pins the canonical Podman executable for state mutation", ({ + onTestFinished, + }) => { + const directory = fs.mkdtempSync( + path.join(fs.realpathSync(os.homedir()), ".nemoclaw-podman-executable-"), + ); + onTestFinished(() => fs.rmSync(directory, { recursive: true, force: true })); + const executable = path.join(directory, "podman"); + fs.writeFileSync(executable, PODMAN_BYTES, { mode: 0o700 }); + + expect(resolvePodmanExecutablePath({ PATH: directory })).toBe(executable); + const capture = vi.fn(() => ({ + status: 0, + stdout: "ok", + stderr: "", + })); + const engine = createPodmanContainerEngine({ + operation: "state-mutation", + socketAuthority: AUTHORITY, + executableSearchEnv: { PATH: directory }, + assertAuthority: vi.fn(), + capture, + }); + + expect(engine.capture(["inspect", "qualified-id"], 2000).status).toBe(0); + expect(capture.mock.calls[0]?.[0]).toBe(executable); + }); + it("removes ambient remote and Docker TLS selectors from local Podman commands (#9035)", () => { const source = { CONTAINER_HOST: "ssh://attacker.test", @@ -234,15 +267,16 @@ describe("Podman container engine command adapter", () => { expect(assertAuthority).toHaveBeenCalledTimes(2); }); - it("requires an explicit canonical absolute executable for host-local inference", () => { + it("requires a resolvable canonical absolute executable for host-local inference", () => { expect(() => createPodmanContainerEngine({ operation: "host-local-inference", socketAuthority: AUTHORITY, + executableSearchEnv: { PATH: "" }, assertAuthority: vi.fn(), capture: vi.fn(), }), - ).toThrow("canonical absolute path"); + ).toThrow("could not resolve podman from PATH"); expect(() => createPodmanContainerEngine({ operation: "host-local-inference", diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts index cded0c9baea..ada980495a0 100644 --- a/src/lib/adapters/podman/index.ts +++ b/src/lib/adapters/podman/index.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import { type ContainerEngine, @@ -33,7 +35,10 @@ export interface PodmanContainerEngineOptions { | "state-mutation"; readonly socketAuthority: PodmanSocketAuthority; readonly executable?: string; + readonly executableAuthority?: PodmanExecutableAuthority; + readonly executableSearchEnv?: NodeJS.ProcessEnv; readonly capture?: ContainerEngineCommandCapture; + readonly commandEnvironment?: Readonly>; readonly authorityDeps?: PodmanSocketAuthorityDeps; readonly executableAuthorityDeps?: PodmanExecutableAuthorityDeps; readonly assertAuthority?: ( @@ -51,6 +56,25 @@ export interface PodmanBoundContainerEngine extends PodmanContainerEngine { readonly assertAuthority: () => void; } +export function resolvePodmanExecutablePath(env: NodeJS.ProcessEnv = process.env): string { + const searchPath = env.PATH; + if (!searchPath) { + throw new Error("Podman executable authority could not resolve podman from PATH."); + } + for (const directory of searchPath.split(path.delimiter)) { + if (!path.isAbsolute(directory) || path.normalize(directory) !== directory) continue; + const candidate = path.join(directory, "podman"); + try { + fs.accessSync(candidate, fs.constants.X_OK); + const resolved = fs.realpathSync(candidate); + if (path.isAbsolute(resolved) && path.normalize(resolved) === resolved) return resolved; + } catch { + // Continue to the next absolute PATH entry. + } + } + throw new Error("Podman executable authority could not resolve podman from PATH."); +} + export function localPodmanEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const local = { ...env }; delete local.CONTAINER_CONNECTION; @@ -108,11 +132,21 @@ export function createPodmanContainerEngine( options: PodmanContainerEngineOptions, ): PodmanBoundContainerEngine { const assertAuthority = options.assertAuthority ?? assertPodmanSocketAuthority; - const executable = options.executable ?? "podman"; const protectsRuntimeMutation = options.operation === "host-local-inference" || options.operation === "state-mutation"; + const executable = + options.executable ?? + options.executableAuthority?.executablePath ?? + (protectsRuntimeMutation ? resolvePodmanExecutablePath(options.executableSearchEnv) : "podman"); + if (options.executableAuthority && executable !== options.executableAuthority.executablePath) { + throw new Error("Podman executable path disagrees with its recorded authority."); + } + if (options.executableAuthority) { + assertPodmanExecutableAuthority(options.executableAuthority, options.executableAuthorityDeps); + } const executableAuthority = protectsRuntimeMutation - ? capturePodmanExecutableAuthority(executable, options.executableAuthorityDeps) + ? (options.executableAuthority ?? + capturePodmanExecutableAuthority(executable, options.executableAuthorityDeps)) : undefined; let executableCommandCount = 0; let hasExecutableAuthorityFailure = false; @@ -137,6 +171,7 @@ export function createPodmanContainerEngine( endpointArgs: ["--url", `unix://${options.socketAuthority.socketPath}`], allowedEnvironmentNames: options.operation === "host-local-inference" ? ["NGC_API_KEY", "NIM_NGC_API_KEY"] : [], + commandEnvironment: options.commandEnvironment, capture: options.capture, guard: (phase) => { let failure: unknown; diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index 22b3d36c7e9..65d90550c51 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -364,10 +364,14 @@ export function readMcpCapability(record: ManifestRecord): AgentMcpCapability { }; } -export function loadManifestRecord(manifestPath: string): ManifestRecord { - const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8")); +export function parseManifestRecord(source: string, label: string): ManifestRecord { + const parsed = yaml.load(source); if (!isManifestRecord(parsed)) { - throw new Error(`Agent manifest must be a YAML object: ${manifestPath}`); + throw new Error(`Agent manifest must be a YAML object: ${label}`); } return parsed; } + +export function loadManifestRecord(manifestPath: string): ManifestRecord { + return parseManifestRecord(fs.readFileSync(manifestPath, "utf8"), manifestPath); +} diff --git a/src/lib/cli/nemoclaw-oclif-command.test.ts b/src/lib/cli/nemoclaw-oclif-command.test.ts index 4a7d6dee498..43ab8e17200 100644 --- a/src/lib/cli/nemoclaw-oclif-command.test.ts +++ b/src/lib/cli/nemoclaw-oclif-command.test.ts @@ -1,7 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { Args } from "@oclif/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as receiptAuthority from "../onboard/experimental/hermes-portable-receipt"; +import * as portableHostAuthority from "../state/portable-uninstall-retirement"; +import { withMcpLifecycleLock } from "../state/mcp-lifecycle-lock-acquisition"; import { log } from "./logger"; import { type CommandExitResult, NemoClawCommand } from "./nemoclaw-oclif-command"; @@ -70,16 +78,102 @@ class PlainFailureCommand extends NemoClawCommand { } } +class RawUnsupportedSandboxCommand extends NemoClawCommand { + static id = "sandbox:agent"; + static ran = false; + + public async run(): Promise { + RawUnsupportedSandboxCommand.ran = true; + } +} + +class RawSandboxDoctorCommand extends NemoClawCommand { + static id = "sandbox:doctor"; + static ran = false; + + public async run(): Promise { + RawSandboxDoctorCommand.ran = true; + } +} + +class ParsedUnsupportedSandboxCommand extends NemoClawCommand { + static id = "sandbox:destroy"; + static args = { sandboxName: Args.string({ required: true }) }; + static flags = {}; + static ran = false; + + public async run(): Promise { + await this.parse(ParsedUnsupportedSandboxCommand); + ParsedUnsupportedSandboxCommand.ran = true; + } +} + +class ParsedSupportedSandboxCommand extends NemoClawCommand { + static id = "sandbox:status"; + static args = { sandboxName: Args.string({ required: true }) }; + static flags = {}; + static operation: () => Promise = async () => undefined; + + public async run(): Promise { + await this.parse(ParsedSupportedSandboxCommand); + await ParsedSupportedSandboxCommand.operation(); + } +} + +class GlobalUnsupportedMutationCommand extends NemoClawCommand { + static id = "tunnel:start"; + static flags = { ...NemoClawCommand.baseFlags }; + static ran = false; + + public async run(): Promise { + await this.parse(GlobalUnsupportedMutationCommand); + GlobalUnsupportedMutationCommand.ran = true; + } +} + +class GlobalUseMutationCommand extends NemoClawCommand { + static id = "use"; + static args = { sandboxName: Args.string({ required: true }) }; + static flags = {}; + static ran = false; + + public async run(): Promise { + await this.parse(GlobalUseMutationCommand); + GlobalUseMutationCommand.ran = true; + } +} + +function useHermesPortableAuthority(): void { + vi.spyOn(receiptAuthority, "inspectPortableAgentReceiptAuthority").mockReturnValue({ + kind: "hermes", + snapshot: { receipt: { phase: "active" } } as never, + }); +} + function makeCommand(): TestCommand { return Object.create(TestCommand.prototype) as TestCommand; } describe("NemoClawCommand", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-oclif-command-")); + vi.stubEnv("NEMOCLAW_TEST_STATE_DIR", stateDir); + }); + afterEach(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); vi.restoreAllMocks(); vi.unstubAllEnvs(); log.configure(); process.exitCode = undefined; + RawUnsupportedSandboxCommand.ran = false; + RawSandboxDoctorCommand.ran = false; + ParsedUnsupportedSandboxCommand.ran = false; + ParsedSupportedSandboxCommand.operation = async () => undefined; + GlobalUnsupportedMutationCommand.ran = false; + GlobalUseMutationCommand.ran = false; }); it("records status-like command results without throwing", () => { @@ -155,4 +249,198 @@ describe("NemoClawCommand", () => { it("passes non-sentinel failures to the default oclif handler", async () => { await expect(PlainFailureCommand.run([], process.cwd())).rejects.toThrow("real failure"); }); + + it("rejects schema-5 unsupported parsed commands before the action body (#9203)", async () => { + useHermesPortableAuthority(); + + await expect(ParsedUnsupportedSandboxCommand.run(["alpha"], process.cwd())).rejects.toThrow( + "not supported for an experimental Hermes portable sandbox", + ); + expect(ParsedUnsupportedSandboxCommand.ran).toBe(false); + }); + + it("resolves a flag-first parsed sandbox before acquiring the lifecycle fence (#9203)", async () => { + useHermesPortableAuthority(); + + await expect( + ParsedUnsupportedSandboxCommand.run(["--debug", "alpha"], process.cwd()), + ).rejects.toThrow("not supported for an experimental Hermes portable sandbox"); + expect(ParsedUnsupportedSandboxCommand.ran).toBe(false); + }); + + it("holds the lifecycle fence through the ordinary action before schema-5 publication (#9203)", async () => { + vi.spyOn(receiptAuthority, "inspectPortableAgentReceiptAuthority").mockReturnValue({ + kind: "none", + }); + let releaseAction!: () => void; + const actionWaiting = new Promise((resolve) => { + releaseAction = resolve; + }); + let actionEntered!: () => void; + const actionStarted = new Promise((resolve) => { + actionEntered = resolve; + }); + ParsedSupportedSandboxCommand.operation = async () => { + actionEntered(); + await actionWaiting; + }; + let contenderEntered = false; + const command = ParsedSupportedSandboxCommand.run(["alpha"], process.cwd()); + await actionStarted; + const contender = withMcpLifecycleLock("alpha", () => { + contenderEntered = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(contenderEntered).toBe(false); + releaseAction(); + await command; + await contender; + expect(contenderEntered).toBe(true); + }); + + it("classifies schema-5 publication that wins the lifecycle fence before dispatch (#9203)", async () => { + const inspect = vi + .spyOn(receiptAuthority, "inspectPortableAgentReceiptAuthority") + .mockReturnValue({ kind: "none" }); + let releasePublisher!: () => void; + const publisherWaiting = new Promise((resolve) => { + releasePublisher = resolve; + }); + let publisherEntered!: () => void; + const publisherStarted = new Promise((resolve) => { + publisherEntered = resolve; + }); + const publisher = withMcpLifecycleLock("alpha", async () => { + inspect.mockReturnValue({ + kind: "hermes", + snapshot: { receipt: { phase: "active" } } as never, + }); + publisherEntered(); + await publisherWaiting; + }); + await publisherStarted; + const command = ParsedUnsupportedSandboxCommand.run(["alpha"], process.cwd()); + await new Promise((resolve) => setImmediate(resolve)); + + expect(ParsedUnsupportedSandboxCommand.ran).toBe(false); + releasePublisher(); + await publisher; + await expect(command).rejects.toThrow( + "not supported for an experimental Hermes portable sandbox", + ); + expect(ParsedUnsupportedSandboxCommand.ran).toBe(false); + }); + + it("rejects schema-5 unsupported raw-argv commands before the action body (#9203)", async () => { + useHermesPortableAuthority(); + + await expect( + RawUnsupportedSandboxCommand.run(["alpha", "--json"], process.cwd()), + ).rejects.toThrow("not supported for an experimental Hermes portable sandbox"); + expect(RawUnsupportedSandboxCommand.ran).toBe(false); + }); + + it("does not treat raw payload help as a schema-5 admission bypass (#9203)", async () => { + useHermesPortableAuthority(); + + await expect( + RawUnsupportedSandboxCommand.run(["alpha", "--help"], process.cwd()), + ).rejects.toThrow("not supported for an experimental Hermes portable sandbox"); + expect(RawUnsupportedSandboxCommand.ran).toBe(false); + }); + + it("reclassifies raw commands after waiting for schema-5 publication (#9203)", async () => { + const inspect = vi + .spyOn(receiptAuthority, "inspectPortableAgentReceiptAuthority") + .mockReturnValue({ kind: "none" }); + let releasePublisher!: () => void; + const publisherWaiting = new Promise((resolve) => { + releasePublisher = resolve; + }); + let publisherEntered!: () => void; + const publisherStarted = new Promise((resolve) => { + publisherEntered = resolve; + }); + const publisher = withMcpLifecycleLock("alpha", async () => { + inspect.mockReturnValue({ + kind: "hermes", + snapshot: { receipt: { phase: "active" } } as never, + }); + publisherEntered(); + await publisherWaiting; + }); + await publisherStarted; + const command = RawUnsupportedSandboxCommand.run(["alpha", "--", "--help"], process.cwd()); + await new Promise((resolve) => setImmediate(resolve)); + + expect(RawUnsupportedSandboxCommand.ran).toBe(false); + releasePublisher(); + await publisher; + await expect(command).rejects.toThrow( + "not supported for an experimental Hermes portable sandbox", + ); + expect(RawUnsupportedSandboxCommand.ran).toBe(false); + }); + + it("rejects schema-5 doctor --fix with option-specific guidance (#9203)", async () => { + useHermesPortableAuthority(); + + await expect(RawSandboxDoctorCommand.run(["alpha", "--fix"], process.cwd())).rejects.toThrow( + "The --fix option is not supported for an experimental Hermes portable sandbox", + ); + expect(RawSandboxDoctorCommand.ran).toBe(false); + }); + + it("holds the host fence and rejects global mutations before effects (#9203)", async () => { + const events: string[] = []; + vi.spyOn(portableHostAuthority, "withCurrentPortableHostFence").mockImplementation( + async (operation) => { + events.push("fence"); + return await operation(); + }, + ); + vi.spyOn(portableHostAuthority, "assertNoHermesPortableHostAuthority").mockImplementation( + () => { + events.push("classify"); + throw new Error("schema-5 host authority exists"); + }, + ); + + await expect(GlobalUnsupportedMutationCommand.run([], process.cwd())).rejects.toThrow( + "schema-5 host authority exists", + ); + expect(events).toEqual(["fence", "classify"]); + expect(GlobalUnsupportedMutationCommand.ran).toBe(false); + }); + + it("preserves side-effect-free help for host mutation commands (#9203)", async () => { + const fence = vi.spyOn(portableHostAuthority, "withCurrentPortableHostFence"); + + await expect(GlobalUnsupportedMutationCommand.run(["--help"], process.cwd())).rejects.toThrow( + "Parsing --help", + ); + + expect(fence).not.toHaveBeenCalled(); + expect(GlobalUnsupportedMutationCommand.ran).toBe(false); + }); + + it("does not treat a positional --help value after -- as host help (#9203)", async () => { + const fence = vi + .spyOn(portableHostAuthority, "withCurrentPortableHostFence") + .mockImplementation(async (operation) => await operation()); + const classify = vi + .spyOn(portableHostAuthority, "assertNoHermesPortableHostAuthority") + .mockImplementation(() => { + throw new Error("schema-5 host authority exists"); + }); + + await expect(GlobalUseMutationCommand.run(["--", "--help"], process.cwd())).rejects.toThrow( + "schema-5 host authority exists", + ); + + expect(fence).toHaveBeenCalledOnce(); + expect(classify).toHaveBeenCalledWith(expect.any(String), "use"); + expect(GlobalUseMutationCommand.ran).toBe(false); + }); }); diff --git a/src/lib/cli/nemoclaw-oclif-command.ts b/src/lib/cli/nemoclaw-oclif-command.ts index 8c4bb4827af..c58971c7878 100644 --- a/src/lib/cli/nemoclaw-oclif-command.ts +++ b/src/lib/cli/nemoclaw-oclif-command.ts @@ -2,8 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 import { Command, Flags, type Interfaces } from "@oclif/core"; +import { + assertHermesPortableCommandSupported, + assertHermesPortableCommandUnavailable, + classifyHermesPortableCommand, + HERMES_PORTABLE_UNSUPPORTED_COMMAND_MESSAGE, + HERMES_PORTABLE_UNSUPPORTED_DOCTOR_FIX_MESSAGE, +} from "../onboard/experimental/portable-agent-lifecycle"; +import { defaultPortableDemoStateDir } from "../onboard/experimental/portable-runtime-receipt-readiness"; import { redactForLog } from "../security/redact"; import { isDeferredShieldsExit } from "../shields/deferred-exit"; +import { + assertNoHermesPortableHostAuthority, + withCurrentPortableHostFence, +} from "../state/portable-uninstall-retirement"; +import { withMcpLifecycleLock } from "../state/mcp-lifecycle-lock-acquisition"; import { log } from "./logger"; export type CommandExitResult = { @@ -12,6 +25,11 @@ export type CommandExitResult = { status?: number | null; }; +export { HERMES_PORTABLE_UNSUPPORTED_COMMAND_MESSAGE }; +export { assertHermesPortableCommandUnavailable }; +export const withSandboxCommandLifecycleLock = withMcpLifecycleLock; +export { HERMES_PORTABLE_UNSUPPORTED_DOCTOR_FIX_MESSAGE }; + /** * Shared oclif base for NemoClaw commands. * @@ -19,6 +37,12 @@ export type CommandExitResult = { * describe their own grammar. */ export abstract class NemoClawCommand extends Command { + private lifecycleParserOutput: Interfaces.ParserOutput< + Interfaces.OutputFlags, + Interfaces.OutputFlags, + Interfaces.OutputArgs + > | null = null; + static baseFlags = { help: Flags.help({ char: "h" }), // Hidden logging flags. Universal visible flags would have to be @@ -45,6 +69,73 @@ export abstract class NemoClawCommand extends Command { // passthrough commands intentionally stop here: only environment-based // logging configuration applies to them. log.configure({ debug: false, quiet: false }); + const commandId = this.id; + const sandboxName = this.argv[0]; + const portablePolicy = + typeof commandId === "string" ? classifyHermesPortableCommand(commandId, this.argv) : null; + if ( + typeof commandId === "string" && + sandboxName && + (commandId === "launch" || commandId.startsWith("sandbox:")) && + !portablePolicy?.helpRequested + ) { + assertHermesPortableCommandSupported(commandId, sandboxName, this.argv); + } + } + + protected override async _run(): Promise { + const commandId = this.id; + const portablePolicy = + typeof commandId === "string" ? classifyHermesPortableCommand(commandId, this.argv) : null; + if (portablePolicy?.hostFence === "read" && !portablePolicy.helpRequested) { + return await withCurrentPortableHostFence(() => super._run()); + } + if ( + typeof commandId === "string" && + portablePolicy?.hostFence === "deny" && + !portablePolicy.helpRequested + ) { + return await withCurrentPortableHostFence(() => { + assertNoHermesPortableHostAuthority(defaultPortableDemoStateDir(process.env), commandId); + return super._run(); + }); + } + const sandboxName = await this.resolveLifecycleSandboxName(portablePolicy); + if (!sandboxName) return await super._run(); + return await withMcpLifecycleLock(sandboxName, () => { + if (typeof commandId === "string" && portablePolicy?.rawSandboxName) { + assertHermesPortableCommandSupported(commandId, sandboxName, this.argv); + } + return super._run(); + }); + } + + private async resolveLifecycleSandboxName( + portablePolicy: ReturnType | null, + ): Promise { + const commandId = this.id; + if ( + typeof commandId !== "string" || + (commandId !== "launch" && !commandId.startsWith("sandbox:")) || + !portablePolicy || + portablePolicy.multiSandboxLifecycle + ) { + return null; + } + if (portablePolicy.rawSandboxName) { + const sandboxName = this.argv[0]; + return sandboxName && sandboxName !== "--help" && sandboxName !== "-h" ? sandboxName : null; + } + try { + const parsed = await super.parse(); + this.lifecycleParserOutput = parsed; + const parsedSandboxName = (parsed.args as Record).sandboxName; + const sandboxName = + typeof parsedSandboxName === "string" ? parsedSandboxName : parsed.argv[0]; + return typeof sandboxName === "string" && sandboxName.trim() !== "" ? sandboxName : null; + } catch { + return null; + } } protected override async parse< @@ -55,7 +146,20 @@ export abstract class NemoClawCommand extends Command { options?: Interfaces.Input, argv?: string[], ): Promise> { - const parsed = await super.parse(options, argv); + const parsed = this.lifecycleParserOutput + ? (this.lifecycleParserOutput as Interfaces.ParserOutput) + : await super.parse(options, argv); + this.lifecycleParserOutput = null; + + const commandId = this.id; + const parsedSandboxName = (parsed.args as Record).sandboxName; + if ( + typeof commandId === "string" && + typeof parsedSandboxName === "string" && + (commandId === "launch" || commandId.startsWith("sandbox:")) + ) { + assertHermesPortableCommandSupported(commandId, parsedSandboxName, this.argv); + } // Logging flags belong to the host only when a command invokes oclif's // parser. Commands that deliberately consume raw argv (for example diff --git a/src/lib/cli/oclif-runner.test.ts b/src/lib/cli/oclif-runner.test.ts index d252882a320..f7350da9056 100644 --- a/src/lib/cli/oclif-runner.test.ts +++ b/src/lib/cli/oclif-runner.test.ts @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { flushMock, handleMock, loadMock, runCommandMock, runMock } = vi.hoisted(() => ({ @@ -11,15 +15,19 @@ const { flushMock, handleMock, loadMock, runCommandMock, runMock } = vi.hoisted( runMock: vi.fn(), })); -vi.mock("@oclif/core", () => ({ - Config: { - load: loadMock, - }, - flush: flushMock, - handle: handleMock, - run: runMock, -})); +vi.mock("@oclif/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Config: { load: loadMock }, + flush: flushMock, + handle: handleMock, + run: runMock, + }; +}); +import * as receiptAuthority from "../onboard/experimental/hermes-portable-receipt"; +import { NemoClawCommand } from "./nemoclaw-oclif-command"; import { runOclifArgv, runOclifCommandById } from "./oclif-runner"; function makeConfig() { @@ -46,6 +54,84 @@ class UnexpectedArgsError extends Error { oclif = { exit: 2 }; } +class RunnerUnsupportedCommand extends NemoClawCommand { + static id = "sandbox:destroy"; + static ran = false; + + public async run(): Promise { + RunnerUnsupportedCommand.ran = true; + } +} + +function useHermesPortableAuthority(): void { + vi.spyOn(receiptAuthority, "inspectPortableAgentReceiptAuthority").mockReturnValue({ + kind: "hermes", + snapshot: { receipt: { phase: "active" } } as never, + }); +} + +describe("Hermes portable command admission through both oclif runners", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-oclif-runner-")); + vi.stubEnv("NEMOCLAW_TEST_STATE_DIR", stateDir); + flushMock.mockReset(); + handleMock.mockReset(); + loadMock.mockReset(); + runCommandMock.mockReset(); + runMock.mockReset(); + loadMock.mockResolvedValue(makeConfig()); + RunnerUnsupportedCommand.ran = false; + process.exitCode = undefined; + }); + + afterEach(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + process.exitCode = undefined; + }); + + it("rejects direct command-id execution before the raw action body (#9203)", async () => { + useHermesPortableAuthority(); + runCommandMock.mockImplementation(() => RunnerUnsupportedCommand.run(["alpha"], process.cwd())); + + await expect( + runOclifCommandById("sandbox:destroy", ["alpha"], { rootDir: "/repo" }), + ).rejects.toThrow("not supported for an experimental Hermes portable sandbox"); + expect(RunnerUnsupportedCommand.ran).toBe(false); + }); + + it("rejects native argv execution before the raw action body (#9203)", async () => { + useHermesPortableAuthority(); + runMock.mockImplementation(() => RunnerUnsupportedCommand.run(["alpha"], process.cwd())); + + await runOclifArgv(["sandbox", "destroy", "alpha"], { rootDir: "/repo" }); + + expect(RunnerUnsupportedCommand.ran).toBe(false); + expect(handleMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + "not supported for an experimental Hermes portable sandbox", + ), + }), + ); + }); + + it("preserves native raw-argv help without action admission (#9203)", async () => { + useHermesPortableAuthority(); + runMock.mockImplementation(() => + RunnerUnsupportedCommand.run(["alpha", "--help"], process.cwd()), + ); + + await runOclifArgv(["sandbox", "destroy", "alpha", "--help"], { rootDir: "/repo" }); + + expect(RunnerUnsupportedCommand.ran).toBe(true); + expect(handleMock).not.toHaveBeenCalled(); + }); +}); + describe("runOclifArgv", () => { let originalArgv: string[]; diff --git a/src/lib/core/process-exit.test.ts b/src/lib/core/process-exit.test.ts index 098b4b231bf..f2369b4ab5b 100644 --- a/src/lib/core/process-exit.test.ts +++ b/src/lib/core/process-exit.test.ts @@ -1,8 +1,61 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; -import { spawnExitCode } from "./process-exit"; +import { describe, expect, it, vi } from "vitest"; +import { + deferSandboxLifecycleExit, + runWithDeferredSandboxLifecycleExit, + spawnExitCode, +} from "./process-exit"; + +describe("runWithDeferredSandboxLifecycleExit", () => { + it("returns a completed operation without exiting", async () => { + const exit = vi.fn((_exitCode: number): never => { + throw new Error("unexpected exit"); + }); + + await expect( + runWithDeferredSandboxLifecycleExit(async () => "complete", exit), + ).resolves.toBe("complete"); + expect(exit).not.toHaveBeenCalled(); + }); + + it("propagates an ordinary operation failure without exiting", async () => { + const exit = vi.fn((_exitCode: number): never => { + throw new Error("unexpected exit"); + }); + + await expect( + runWithDeferredSandboxLifecycleExit(async () => { + throw new Error("operation failed"); + }, exit), + ).rejects.toThrow("operation failed"); + expect(exit).not.toHaveBeenCalled(); + }); + + it("exits only after async cleanup completes", async () => { + const events: string[] = []; + const exit = vi.fn((exitCode: number): never => { + events.push(`exit:${String(exitCode)}`); + throw new Error(`process.exit:${String(exitCode)}`); + }); + + await expect( + runWithDeferredSandboxLifecycleExit(async () => { + events.push("operation"); + try { + deferSandboxLifecycleExit(7); + } finally { + await Promise.resolve(); + events.push("cleanup"); + } + }, exit), + ).rejects.toThrow("process.exit:7"); + + expect(events).toEqual(["operation", "cleanup", "exit:7"]); + expect(exit).toHaveBeenCalledWith(7); + }); +}); describe("spawnExitCode", () => { it.each([ diff --git a/src/lib/core/process-exit.ts b/src/lib/core/process-exit.ts index 917496a8e6b..2e3b541fdbd 100644 --- a/src/lib/core/process-exit.ts +++ b/src/lib/core/process-exit.ts @@ -3,6 +3,55 @@ import os from "node:os"; +const SANDBOX_LIFECYCLE_DEFERRED_EXIT = Symbol.for("nemoclaw.sandbox-lifecycle.deferred-exit"); + +export class SandboxLifecycleDeferredExit extends Error { + readonly [SANDBOX_LIFECYCLE_DEFERRED_EXIT] = true; + readonly exitCode: number; + + constructor(exitCode: number) { + super(`Sandbox lifecycle operation requested exit ${String(exitCode)}.`); + this.name = "SandboxLifecycleDeferredExit"; + this.exitCode = exitCode; + } +} + +/** Carry a terminal CLI result through the lifecycle lock's async cleanup. */ +export function deferSandboxLifecycleExit(exitCode: number): never { + throw new SandboxLifecycleDeferredExit(exitCode); +} + +export function isSandboxLifecycleDeferredExit( + error: unknown, +): error is SandboxLifecycleDeferredExit { + const candidate = error as + | (Error & { + exitCode?: unknown; + [SANDBOX_LIFECYCLE_DEFERRED_EXIT]?: unknown; + }) + | null; + return ( + candidate instanceof Error && + candidate[SANDBOX_LIFECYCLE_DEFERRED_EXIT] === true && + candidate.name === "SandboxLifecycleDeferredExit" && + typeof candidate.exitCode === "number" && + Number.isInteger(candidate.exitCode) + ); +} + +/** Complete an async cleanup boundary before honoring a deferred CLI exit. */ +export async function runWithDeferredSandboxLifecycleExit( + operation: () => Promise, + exit: (exitCode: number) => never = process.exit, +): Promise { + try { + return await operation(); + } catch (error) { + if (!isSandboxLifecycleDeferredExit(error)) throw error; + return exit(error.exitCode); + } +} + export function spawnExitCode(result: { status: number | null; signal?: NodeJS.Signals | null; diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index 355278c988f..b79fb9d9704 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -11,6 +11,8 @@ import { isSafeLlamaCppServedModelAlias, LLAMA_CPP_CREDENTIAL_ENV } from "./llam import { DEFAULT_OLLAMA_MODEL } from "./local"; import { OPENROUTER_CREDENTIAL_ENV, OPENROUTER_PROVIDER_NAME } from "./openrouter"; +export { isSafeModelId }; + export const INFERENCE_ROUTE_URL = "https://inference.local/v1"; export const NOUS_RECOMMENDED_MODELS_URL = "https://portal.nousresearch.com/api/nous/recommended-models"; diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index 52bb0fa0756..56955f7ab32 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -337,6 +337,107 @@ describe("inventory commands", () => { ]); }); + it("reports schema-5 phase without ambient global probes", () => { + const getLiveInference = vi.fn(); + const getGatewayHealth = vi.fn(); + const getServiceStatuses = vi.fn(); + const report = getStatusReport({ + listSandboxes: () => ({ + sandboxes: [ + { + name: "alpha", + agent: "hermes", + provider: "ollama", + model: "qwen3-vl:4b", + }, + ], + defaultSandbox: "alpha", + }), + getHermesPortablePhase: () => "configuring", + getHermesPortableHostAuthorityCount: () => 1, + getLiveInference, + getGatewayHealth, + getServiceStatuses, + showServiceStatus: vi.fn(), + }); + + expect(report.sandboxes[0]).toMatchObject({ + agent: "hermes", + name: "alpha", + phase: "configuring", + }); + expect(report.liveInference).toBeNull(); + expect(report.gatewayHealth).toBeNull(); + expect(report.services).toEqual([]); + expect(getLiveInference).not.toHaveBeenCalled(); + expect(getGatewayHealth).not.toHaveBeenCalled(); + expect(getServiceStatuses).not.toHaveBeenCalled(); + }); + + it("renders schema-5 phase without sessions, services, messaging, or logs", () => { + const lines: string[] = []; + const effects = { + getLiveInference: vi.fn(), + getActiveSessionCount: vi.fn(), + getGatewayHealth: vi.fn(), + showServiceStatus: vi.fn(), + findMessagingOverlaps: vi.fn(), + checkMessagingBridgeHealth: vi.fn(), + readGatewayLog: vi.fn(), + }; + showStatusCommand({ + listSandboxes: () => ({ + sandboxes: [{ name: "alpha", agent: "hermes", provider: "ollama", model: "qwen3-vl:4b" }], + defaultSandbox: "alpha", + }), + getHermesPortablePhase: () => "active", + getHermesPortableHostAuthorityCount: () => 1, + ...effects, + log: (message = "") => lines.push(message), + }); + + expect(lines).toContain(" agent: hermes phase: active"); + expect(Object.values(effects).every((effect) => effect.mock.calls.length === 0)).toBe(true); + }); + + it("fails before ambient probes when a schema-5 phase has no registry row", () => { + const getLiveInference = vi.fn(); + const showServiceStatus = vi.fn(); + expect(() => + showStatusCommand({ + listSandboxes: () => ({ sandboxes: [], defaultSandbox: null }), + getHermesPortableHostAuthorityCount: () => 1, + getHermesPortablePhase: vi.fn(), + getLiveInference, + showServiceStatus, + }), + ).toThrow("without an exact registry row"); + expect(getLiveInference).not.toHaveBeenCalled(); + expect(showServiceStatus).not.toHaveBeenCalled(); + }); + + it("fails before ambient probes when schema-5 registry agreement is rejected", () => { + const getLiveInference = vi.fn(); + const getGatewayAuthority = vi.fn(); + expect(() => + getStatusReport({ + listSandboxes: () => ({ + sandboxes: [{ name: "alpha", agent: "hermes" }], + defaultSandbox: "alpha", + }), + getHermesPortableHostAuthorityCount: () => 1, + getHermesPortablePhase: () => { + throw new Error("registry row disagreement"); + }, + getLiveInference, + getGatewayAuthority, + showServiceStatus: vi.fn(), + }), + ).toThrow("registry row disagreement"); + expect(getLiveInference).not.toHaveBeenCalled(); + expect(getGatewayAuthority).not.toHaveBeenCalled(); + }); + it("omits invalid configured inference fields from status text", () => { const lines: string[] = []; showStatusCommand({ diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index da91901c8af..64331179822 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -154,6 +154,12 @@ export interface ShowStatusCommandDeps { ) => MessagingBridgeHealth[]; findMessagingOverlaps?: () => MessagingOverlap[]; readGatewayLog?: (sandboxName: string) => string | null; + /** Receipt-only schema-5 phase lookup held by the global portable host fence. */ + getHermesPortablePhase?: ( + sandboxName: string, + ) => "pending" | "configuring" | "active" | null; + /** Count receipt-root authority so an unregistered phase cannot permit ambient probes. */ + getHermesPortableHostAuthorityCount?: () => number; log?: (message?: string) => void; } @@ -170,6 +176,7 @@ export interface StatusSandboxRow { openshellVersion: string | null; policies: string[]; agent: string; + phase?: "pending" | "configuring" | "active"; dashboardPort?: number | null; isDefault: boolean; } @@ -394,6 +401,7 @@ function buildStatusSandboxRow( sandbox: SandboxEntry, defaultSandbox: string | null, liveInference: GatewayInference | null, + portablePhase: "pending" | "configuring" | "active" | null, ): StatusSandboxRow { const isDefault = sandbox.name === defaultSandbox; const liveModel = isDefault ? liveInference?.model : null; @@ -424,6 +432,7 @@ function buildStatusSandboxRow( .map((policy) => safeStatusString(policy) || policy) : [], agent: redactFull(resolveDisplayAgent(sandbox)), + ...(portablePhase ? { phase: portablePhase } : {}), ...(dashboardPort != null ? { dashboardPort } : {}), isDefault, }; @@ -485,13 +494,31 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { (sandbox) => !isRouteOnlySandboxReservation(sandbox), ); const resolvedDefault = resolveDefaultSandboxName(() => sandboxList) ?? null; - const liveInference = sandboxes.length > 0 ? deps.getLiveInference() : null; + const portablePhases = new Map( + sandboxes.flatMap((sandbox) => { + const phase = deps.getHermesPortablePhase?.(sandbox.name) ?? null; + return phase ? ([[sandbox.name, phase]] as const) : []; + }), + ); + const portableAuthorityCount = deps.getHermesPortableHostAuthorityCount?.() ?? 0; + if (portableAuthorityCount !== portablePhases.size) { + throw new Error( + "Global status cannot inspect an experimental Hermes portable receipt without an exact registry row. Resume its existing onboarding transaction first.", + ); + } + const hasHermesPortable = portableAuthorityCount > 0; + const liveInference = + sandboxes.length > 0 && !hasHermesPortable ? deps.getLiveInference() : null; const gatewayHealth = - deps.getGatewayHealth && sandboxes.length > 0 ? deps.getGatewayHealth() : null; + deps.getGatewayHealth && sandboxes.length > 0 && !hasHermesPortable + ? deps.getGatewayHealth() + : null; const services = - deps + !hasHermesPortable + ? (deps .getServiceStatuses?.({ sandboxName: resolvedDefault || undefined }) - .map(normalizeServiceStatus) ?? []; + .map(normalizeServiceStatus) ?? []) + : []; return { schemaVersion: 1, @@ -503,9 +530,16 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport { } : null, gatewayHealth: normalizeGatewayHealth(gatewayHealth), - gatewayAuthority: normalizeGatewayAuthority(deps.getGatewayAuthority?.()), + gatewayAuthority: hasHermesPortable + ? null + : normalizeGatewayAuthority(deps.getGatewayAuthority?.()), sandboxes: sandboxes.map((sandbox) => - buildStatusSandboxRow(sandbox, resolvedDefault, liveInference), + buildStatusSandboxRow( + sandbox, + resolvedDefault, + liveInference, + portablePhases.get(sandbox.name) ?? null, + ), ), services, }; @@ -528,10 +562,23 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { (sandbox) => !isRouteOnlySandboxReservation(sandbox), ); const resolvedDefault = resolveDefaultSandboxName(() => sandboxList) ?? null; + const portablePhases = new Map( + sandboxes.flatMap((sandbox) => { + const phase = deps.getHermesPortablePhase?.(sandbox.name) ?? null; + return phase ? ([[sandbox.name, phase]] as const) : []; + }), + ); + const portableAuthorityCount = deps.getHermesPortableHostAuthorityCount?.() ?? 0; + if (portableAuthorityCount !== portablePhases.size) { + throw new Error( + "Global status cannot inspect an experimental Hermes portable receipt without an exact registry row. Resume its existing onboarding transaction first.", + ); + } + const hasHermesPortable = portableAuthorityCount > 0; log(""); log(" Global status (registered sandboxes and host services):"); if (sandboxes.length > 0) { - const live = deps.getLiveInference(); + const live = hasHermesPortable ? null : deps.getLiveInference(); log(" Sandboxes:"); for (const sb of sandboxes) { const isDefault = sb.name === resolvedDefault; @@ -545,6 +592,8 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const provider = liveProvider || inference.provider; const portSuffix = sb.dashboardPort != null ? ` :${sb.dashboardPort}` : ""; log(` ${sb.name}${def}${model ? ` (${model})` : ""}${portSuffix}`); + const portablePhase = portablePhases.get(sb.name); + if (portablePhase) log(` agent: hermes phase: ${portablePhase}`); if (isDefault && liveModel && liveModel !== inference.model) { log(` (onboarded: ${inference.model || "unknown"})`); } @@ -556,7 +605,7 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const parts = [provider, model].filter(Boolean).join(" / "); log(` Inference: ${parts}`); } - if (deps.getActiveSessionCount) { + if (deps.getActiveSessionCount && !portablePhase) { const count = deps.getActiveSessionCount(sb.name); if (count !== null) { log(` SSH sessions: ${count > 0 ? count : "none"}`); @@ -566,7 +615,9 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { log(""); } - const gatewayAuthority = normalizeGatewayAuthority(deps.getGatewayAuthority?.()); + const gatewayAuthority = hasHermesPortable + ? null + : normalizeGatewayAuthority(deps.getGatewayAuthority?.()); if (gatewayAuthority) { const owner = gatewayAuthority.supervisor ? `${gatewayAuthority.supervisor.kind} ${gatewayAuthority.supervisor.serviceName} (${gatewayAuthority.supervisor.execPath})` @@ -587,7 +638,7 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { // the rest of the report keeps printing. A clean machine with no registered // sandboxes has no expectation of a configured gateway, so the check is // suppressed in that case to avoid a spurious failure exit code. - if (deps.getGatewayHealth && sandboxes.length > 0) { + if (deps.getGatewayHealth && sandboxes.length > 0 && !hasHermesPortable) { const health = deps.getGatewayHealth(); if (!health.healthy) { log(""); @@ -598,9 +649,9 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { } } - deps.showServiceStatus({ sandboxName: resolvedDefault || undefined }); + if (!hasHermesPortable) deps.showServiceStatus({ sandboxName: resolvedDefault || undefined }); - if (deps.findMessagingOverlaps) { + if (deps.findMessagingOverlaps && !hasHermesPortable) { const overlaps = deps.findMessagingOverlaps(); if (overlaps.length > 0) { log(""); @@ -623,7 +674,7 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { } } - if (deps.checkMessagingBridgeHealth && resolvedDefault) { + if (deps.checkMessagingBridgeHealth && resolvedDefault && !hasHermesPortable) { const refreshed = deps.listSandboxes().sandboxes; const defaultEntry = refreshed.find((sb) => sb.name === resolvedDefault); const channels = getActiveChannelIdsFromPlan(defaultEntry?.messaging?.plan); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3dbd7e72688..976bf33a3c8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6,8 +6,9 @@ const { envInt, LOCAL_INFERENCE_TIMEOUT_SECS, }: typeof import("./onboard/env") = require("./onboard/env"); -const { isNonInteractiveEnv }: typeof import("./core/non-interactive") = - require("./core/non-interactive"); +const { + isNonInteractiveEnv, +}: typeof import("./core/non-interactive") = require("./core/non-interactive"); const { agentProductName, cliDisplayName, @@ -48,9 +49,8 @@ const preparedDcodeRebuild: typeof import("./onboard/prepared-dcode-rebuild") = const sandboxBuildPatchConfig: typeof import("./onboard/sandbox-build-patch-config") = require("./onboard/sandbox-build-patch-config"); const baseImageResolutionFlow: typeof import("./onboard/base-image-resolution-flow") = require("./onboard/base-image-resolution-flow"); const sandboxCreateIntentResolution: typeof import("./onboard/sandbox-create-intent-resolution") = require("./onboard/sandbox-create-intent-resolution"); -const sandboxCreatePlanMaterialization: typeof import("./onboard/sandbox-create-plan-materialization") = require("./onboard/sandbox-create-plan-materialization"); -const managedWorkloadOnboard: typeof import("./onboard/managed-workload/onboard-orchestration") = - require("./onboard/managed-workload/onboard-orchestration"); +const sandboxCreateOrchestration: typeof import("./onboard/sandbox-create/orchestration") = require("./onboard/sandbox-create/orchestration"); +const managedWorkloadOnboard: typeof import("./onboard/managed-workload/onboard-orchestration") = require("./onboard/managed-workload/onboard-orchestration"); const onboardEntryOptions: typeof import("./onboard/entry-options") = require("./onboard/entry-options"); const onboardSessionBootstrap: typeof import("./onboard/session-bootstrap") = require("./onboard/session-bootstrap"); const resumeRuntime: typeof import("./onboard/resume/locked-runtime") = require("./onboard/resume/locked-runtime"); @@ -116,7 +116,9 @@ const { usesManagedDcodeIdentity, }: typeof import("./onboard/dcode-selection-drift") = require("./onboard/dcode-selection-drift"); const { - finalizeCreatedSandbox, + completeOrdinaryOnboardSandboxCreation, + createOnboardCreatedSandboxCompletion, + createOnboardCreatedSandboxRegistration, }: typeof import("./onboard/created-sandbox-finalization") = require("./onboard/created-sandbox-finalization"); const providerKeyBridge: typeof import("./onboard/provider-key-bridge") = require("./onboard/provider-key-bridge"); const compatibleEndpointGatewayRoute: typeof import("./onboard/inference-providers/compatible-endpoint-gateway-route") = require("./onboard/inference-providers/compatible-endpoint-gateway-route"); @@ -130,8 +132,7 @@ const { }: typeof import("./onboard/e2e-failure-injection") = require("./onboard/e2e-failure-injection"); const onboardTracing: typeof import("./onboard/tracing") = require("./onboard/tracing"); const sandboxReadinessTracing: typeof import("./onboard/sandbox-readiness-tracing") = require("./onboard/sandbox-readiness-tracing"); -const messagingChannelSetup: typeof import("./onboard/messaging-channel-setup") = - require("./onboard/messaging-channel-setup"); +const messagingChannelSetup: typeof import("./onboard/messaging-channel-setup") = require("./onboard/messaging-channel-setup"); const { applySessionRecovery } = require("./onboard/session-recovery") as typeof import("./onboard/session-recovery"); const bedrockRuntimeOnboard: typeof import("./onboard/bedrock-runtime") = require("./onboard/bedrock-runtime"); @@ -200,8 +201,9 @@ const { OLLAMA_PROXY_PORT, } = require("./core/ports"); const localInference: typeof import("./inference/local") = require("./inference/local"); -const { ollamaModelRefsMatch }: typeof import("./inference/ollama/model-discovery") = - require("./inference/ollama/model-discovery"); +const { + ollamaModelRefsMatch, +}: typeof import("./inference/ollama/model-discovery") = require("./inference/ollama/model-discovery"); const { resetOllamaHostCache, getLocalProviderBaseUrl, @@ -225,7 +227,8 @@ const { persistAndProbeOllamaProxy, prepareOllamaModel, printOllamaExposureWarning, - promptOllamaModel, unloadOllamaModels, + promptOllamaModel, + unloadOllamaModels, } = require("./inference/ollama/proxy"); const { installOllamaOnWindowsHost, @@ -241,12 +244,10 @@ const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference } const onboardProviders = require("./onboard/providers"); const credentialProviderRegistration: typeof import("./onboard/credential-provider-registration") = require("./onboard/credential-provider-registration"); const inferenceProviders: typeof import("./onboard/inference-providers") = require("./onboard/inference-providers"); -const setupInferenceFactory: typeof import("./onboard/setup-inference") = - require("./onboard/setup-inference"); +const setupInferenceFactory: typeof import("./onboard/setup-inference") = require("./onboard/setup-inference"); const hermesProviderAuth = require("./hermes-provider-auth"); const onboardHermesDashboard: typeof import("./onboard/hermes-dashboard") = require("./onboard/hermes-dashboard"); const hermesAuth: typeof import("./onboard/hermes-auth") = require("./onboard/hermes-auth"); -const { warnIfLandlockUnsupported } = require("./onboard/landlock-warning"); const { HERMES_AUTH_METHOD_API_KEY, HERMES_AUTH_METHOD_OAUTH, @@ -314,8 +315,9 @@ const { rejectUnsupportedWindowsHostOllama, shouldFrontOllamaWithProxy, }: typeof import("./onboard/local-inference-topology") = require("./onboard/local-inference-topology"); -const { getGatewayHealthWaitConfig }: typeof import("./onboard/gateway-health-wait") = - require("./onboard/gateway-health-wait"); +const { + getGatewayHealthWaitConfig, +}: typeof import("./onboard/gateway-health-wait") = require("./onboard/gateway-health-wait"); const { resolveOpenshell } = require("./adapters/openshell/resolve"); const credentials: typeof import("./credentials/store") = require("./credentials/store"); const { @@ -335,10 +337,8 @@ const { cleanupStaleHostFiles, }: typeof import("./host-artifact-cleanup") = require("./host-artifact-cleanup"); const registry: typeof import("./state/registry") = require("./state/registry"); -const sandboxMutationLock: typeof import("./state/mcp-lifecycle-lock") = - require("./state/mcp-lifecycle-lock"); -const gatewayRouteMutationLock: typeof import("./inference/gateway-route-mutation-lock") = - require("./inference/gateway-route-mutation-lock"); +const sandboxMutationLock: typeof import("./state/mcp-lifecycle-lock") = require("./state/mcp-lifecycle-lock"); +const gatewayRouteMutationLock: typeof import("./inference/gateway-route-mutation-lock") = require("./inference/gateway-route-mutation-lock"); const { resolveSandboxImageTagFromCreateOutput } = require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag"); const nim: typeof import("./inference/nim") = require("./inference/nim"); @@ -396,8 +396,7 @@ const { shouldUseOpenshellDevChannel, versionGte, } = openshellVersion; -const credentialNavigation: typeof import("./onboard/credential-navigation") = - require("./onboard/credential-navigation"); +const credentialNavigation: typeof import("./onboard/credential-navigation") = require("./onboard/credential-navigation"); const { BACK_TO_SELECTION, createCredentialPromptHelpers, isBackToSelection } = credentialNavigation; const { @@ -411,17 +410,14 @@ const { getRecordedMessagingChannelsForResume: getRecordedMessagingChannelsForResumeFromState, }: typeof import("./onboard/messaging-credentials") = require("./onboard/messaging-credentials"); const { getStoredMessagingChannelConfig, messagingChannelConfigsEqual } = messagingConfig; -const messagingPlanSession: typeof import("./onboard/messaging-plan-session") = - require("./onboard/messaging-plan-session"); +const messagingPlanSession: typeof import("./onboard/messaging-plan-session") = require("./onboard/messaging-plan-session"); const { getChannelsFromPlan } = messagingPlanSession; const sandboxAgent: typeof import("./onboard/sandbox-agent") = require("./onboard/sandbox-agent"); const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require("./onboard/sandbox-lifecycle"); const sandboxRegistryMetadata: typeof import("./onboard/sandbox-registry-metadata") = require("./onboard/sandbox-registry-metadata"); const sandboxReuse: typeof import("./onboard/sandbox-reuse") = require("./onboard/sandbox-reuse"); -const sandboxRecreateTransaction: typeof import("./onboard/sandbox-recreate-transaction") = - require("./onboard/sandbox-recreate-transaction"); -const sandboxRegistration: typeof import("./onboard/sandbox-registration") = - require("./onboard/sandbox-registration"); +const sandboxRecreateTransaction: typeof import("./onboard/sandbox-recreate-transaction") = require("./onboard/sandbox-recreate-transaction"); +const sandboxRegistration: typeof import("./onboard/sandbox-registration") = require("./onboard/sandbox-registration"); const { formatSandboxAgentName, getAgentInferenceProviderOptions, @@ -455,9 +451,13 @@ const { installSandboxCancelRollback, makeOnboardCancelExit, wasSandboxDefault, - restoreDefaultAfterRecreate, }: typeof import("./onboard/cancel-rollback") = require("./onboard/cancel-rollback"); -const { createCoreOnboardFlowPhases, prepareCoreOnboardFlowContext, prepareFinalOnboardFlowContext, runCoreOnboardFlowSlice }: typeof import("./onboard/machine/core-flow-composition") = require("./onboard/machine/core-flow-composition"); +const { + createCoreOnboardFlowPhases, + prepareCoreOnboardFlowContext, + prepareFinalOnboardFlowContext, + runCoreOnboardFlowSlice, +}: typeof import("./onboard/machine/core-flow-composition") = require("./onboard/machine/core-flow-composition"); const { createFinalOnboardFlowPhases, finalizationHandlerDeps, @@ -470,8 +470,9 @@ const { runInitialOnboardFlowSlice, verifyGatewayContainerRunning, }: typeof import("./onboard/machine/initial-flow-composition") = require("./onboard/machine/initial-flow-composition"); -const { skippedStepMessage }: typeof import("./onboard/skipped-step-message") = - require("./onboard/skipped-step-message"); +const { + skippedStepMessage, +}: typeof import("./onboard/skipped-step-message") = require("./onboard/skipped-step-message"); const policyPresetCarry: typeof import("./onboard/policy-preset-persistence") = require("./onboard/policy-preset-persistence"); const { findAvailableDashboardPort, @@ -479,8 +480,7 @@ const { reserveCreateSandboxDashboardPort, withDashboardPortReservationScope: withSandboxPortReservationScope, } = require("./onboard/dashboard-port") as typeof import("./onboard/dashboard-port"); -const authoritativeRebuildTarget: typeof import("./onboard/authoritative-rebuild-target") = - require("./onboard/authoritative-rebuild-target"); +const authoritativeRebuildTarget: typeof import("./onboard/authoritative-rebuild-target") = require("./onboard/authoritative-rebuild-target"); const { assertDashboardPortNotReserved, buildRequiredPreflightPorts } = require("./onboard/preflight-ports") as typeof import("./onboard/preflight-ports"); const { printPortConflictReport } = @@ -501,8 +501,7 @@ const { } = require("./onboard/gateway-http-readiness") as typeof import("./onboard/gateway-http-readiness"); const { isGatewayTcpReady: probeGatewayTcpReady } = require("./onboard/gateway-tcp-readiness") as typeof import("./onboard/gateway-tcp-readiness"); -const dockerDriverGatewayEnv: typeof import("./onboard/docker-driver-gateway-env") = - require("./onboard/docker-driver-gateway-env"); +const dockerDriverGatewayEnv: typeof import("./onboard/docker-driver-gateway-env") = require("./onboard/docker-driver-gateway-env"); const { createDockerDriverGatewayStart, createGatewayLifecycleApplication, @@ -512,13 +511,10 @@ const { } = require("./onboard/gateway/application") as typeof import("./onboard/gateway/application"); const { createGatewayProcessLifecycle } = require("./onboard/gateway/process-lifecycle") as typeof import("./onboard/gateway/process-lifecycle"); -const entryDecisions: typeof import("./onboard/gateway/entry-decisions") = - require("./onboard/gateway/entry-decisions"); +const entryDecisions: typeof import("./onboard/gateway/entry-decisions") = require("./onboard/gateway/entry-decisions"); const gatewayBinding: typeof import("./onboard/gateway-binding") = require("./onboard/gateway-binding"); -const fatalRuntimePreflight: typeof import("./onboard/fatal-runtime-preflight") = - require("./onboard/fatal-runtime-preflight"); -const preflightGatewayAuthority: typeof import("./onboard/machine/preflight-gateway-authority") = - require("./onboard/machine/preflight-gateway-authority"); +const fatalRuntimePreflight: typeof import("./onboard/fatal-runtime-preflight") = require("./onboard/fatal-runtime-preflight"); +const preflightGatewayAuthority: typeof import("./onboard/machine/preflight-gateway-authority") = require("./onboard/machine/preflight-gateway-authority"); const preflightUtils: typeof import("./onboard/preflight") = require("./onboard/preflight"); const clusterImagePatch: typeof import("./cluster-image-patch") = require("./cluster-image-patch"); const overlayfsAutoFix: typeof import("./onboard/overlayfs-auto-fix") = require("./onboard/overlayfs-auto-fix"); @@ -540,12 +536,9 @@ const modelPrompts: typeof import("./inference/model-prompts") = require("./infe const providerModels: typeof import("./inference/provider-models") = require("./inference/provider-models"); const validationRecovery: typeof import("./validation-recovery") = require("./validation-recovery"); const webSearch: typeof import("./inference/web-search") = require("./inference/web-search"); -const openshellInstallFlow: typeof import("./onboard/openshell-install") = - require("./onboard/openshell-install"); -const openshellPinFlow: typeof import("./onboard/openshell-pin") = - require("./onboard/openshell-pin"); -const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-failure") = - require("./onboard/sandbox-create-failure"); +const openshellInstallFlow: typeof import("./onboard/openshell-install") = require("./onboard/openshell-install"); +const openshellPinFlow: typeof import("./onboard/openshell-pin") = require("./onboard/openshell-pin"); +const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-failure") = require("./onboard/sandbox-create-failure"); import type { CurlProbeResult } from "./adapters/http/probe"; import type { AgentDefinition } from "./agent/defs"; @@ -572,7 +565,6 @@ import { createOnboardPolicyApplication } from "./onboard/policy-selection"; import { printGpuPreflightLines, printLowMemoryWarning, - printMessagingProviderMissing, printSwapCreationFailed, } from "./onboard/preflight-messages"; import { shouldSkipPreRecreateBackup } from "./onboard/sandbox-backup-on-recreate"; @@ -766,11 +758,17 @@ const { refreshDockerDriverGatewayReuseState } = rememberDockerDriverGatewayPid, }); -const { getSandboxReuseState, getSandboxRecreateObservation, waitForSandboxRecreateDeleteAbsence } = sandboxReuse.createSandboxReuseHelpers({ runCaptureOpenshell, captureOpenshell, getSandboxStateFromOutputs, getGatewayName: () => GATEWAY_NAME, waitUntil }); +const { getSandboxReuseState, getSandboxRecreateObservation, waitForSandboxRecreateDeleteAbsence } = + sandboxReuse.createSandboxReuseHelpers({ + runCaptureOpenshell, + captureOpenshell, + getSandboxStateFromOutputs, + getGatewayName: () => GATEWAY_NAME, + waitUntil, + }); const { executeSandboxCommandForVerification, -}: typeof import("./onboard/sandbox-verification-exec") = - require("./onboard/sandbox-verification-exec"); +}: typeof import("./onboard/sandbox-verification-exec") = require("./onboard/sandbox-verification-exec"); // URL/string utilities — delegated to src/lib/core/url-utils.ts const { @@ -780,8 +778,9 @@ const { formatEnvAssignment, parsePolicyPresetEnv, } = urlUtils; -const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") = - require("./onboard/credential-env"); +const { + hydrateCredentialEnv, +}: typeof import("./onboard/credential-env") = require("./onboard/credential-env"); const { summarizeCurlFailure, summarizeProbeFailure } = httpProbe; @@ -944,7 +943,13 @@ const { inspectSandboxForCreate, confirmRecreateForSelectionDrift, isOpenclawRea isAffirmativeAnswer, }); -const { ensureValidatedWebSearchCredential, ensureValidatedBraveSearchCredential, configureWebSearch, verifyWebSearchInsideSandbox, webSearchProviderForConfig } = createWebSearchFlowHelpers({ prompt, note, isNonInteractive, cliName, runCaptureOpenshell }); +const { + ensureValidatedWebSearchCredential, + ensureValidatedBraveSearchCredential, + configureWebSearch, + verifyWebSearchInsideSandbox, + webSearchProviderForConfig, +} = createWebSearchFlowHelpers({ prompt, note, isNonInteractive, cliName, runCaptureOpenshell }); const { hasResponsesToolCall, @@ -1039,7 +1044,17 @@ const handleVllmSelection = createSetupNimVllmHandler({ vllmInference.persistConfiguredManagedVllmRuntimeReceipt, exitProcess: (code) => process.exit(code), }); -const handleLlamaCppSelection = setupNimFlow.createLlamaCppSelectionHandler({ isNonInteractive, resolveCredential: resolveProviderCredential, ensureNamedCredential: (envName, label) => credentialPrompt.ensureNamedCredential(envName, label), returningToProviderSelection: credentialPrompt.returningToProviderSelection, probeLlamaCppAttachment: setupNimFlow.probeLlamaCppAttachment, validateOpenAiLikeSelection, error: (message) => console.error(message), log: (message) => console.log(message), exitProcess: (code): never => process.exit(code) }); +const handleLlamaCppSelection = setupNimFlow.createLlamaCppSelectionHandler({ + isNonInteractive, + resolveCredential: resolveProviderCredential, + ensureNamedCredential: (envName, label) => credentialPrompt.ensureNamedCredential(envName, label), + returningToProviderSelection: credentialPrompt.returningToProviderSelection, + probeLlamaCppAttachment: setupNimFlow.probeLlamaCppAttachment, + validateOpenAiLikeSelection, + error: (message) => console.error(message), + log: (message) => console.log(message), + exitProcess: (code): never => process.exit(code), +}); const ollamaModelSize: typeof import("./inference/ollama/model-size") = require("./inference/ollama/model-size"); function isOpenshellInstalled(): boolean { return resolveOpenshell() !== null; @@ -1083,7 +1098,18 @@ function getOpenShellInstallDeps( isOpenshellDevVersion, versionGte, hasRequiredOpenshellMessagingFeatures: () => - (require("./onboard/openshell-feature-gate") as typeof import("./onboard/openshell-feature-gate")).hasRequiredOpenshellMessagingFeatures({ openshellBin: resolveOpenshell(), gatewayBin: resolveOpenShellGatewayBinary(), sandboxBin: resolveOpenShellSandboxBinary(), allowExternalGatewayBin: Boolean(process.env.NEMOCLAW_OPENSHELL_GATEWAY_BIN?.trim()), allowExternalSandboxBin: Boolean(process.env.NEMOCLAW_OPENSHELL_SANDBOX_BIN?.trim()), requireSandboxBin: process.platform !== "darwin" || Boolean(process.env.NEMOCLAW_OPENSHELL_SANDBOX_BIN?.trim()) }), + ( + require("./onboard/openshell-feature-gate") as typeof import("./onboard/openshell-feature-gate") + ).hasRequiredOpenshellMessagingFeatures({ + openshellBin: resolveOpenshell(), + gatewayBin: resolveOpenShellGatewayBinary(), + sandboxBin: resolveOpenShellSandboxBinary(), + allowExternalGatewayBin: Boolean(process.env.NEMOCLAW_OPENSHELL_GATEWAY_BIN?.trim()), + allowExternalSandboxBin: Boolean(process.env.NEMOCLAW_OPENSHELL_SANDBOX_BIN?.trim()), + requireSandboxBin: + process.platform !== "darwin" || + Boolean(process.env.NEMOCLAW_OPENSHELL_SANDBOX_BIN?.trim()), + }), shouldAllowOpenshellAboveBlueprintMax, cliDisplayName, log: console.log, @@ -1519,599 +1545,141 @@ const { getSandboxRuntimeRegistryFields, hasSandboxGpuDrift, updateReusedSandbox getInstalledOpenshellVersion, runCaptureOpenshell, }); -async function createSandboxWithBaseImageResolution( - baseImageResolutionContext: import("./onboard/base-image-resolution-flow").BaseImageResolutionContext, - portableRuntimeAuthority: import("./state/onboard-checkpoint-types").CheckpointPortableRuntimeAuthority | null, - computePlan: import("./onboard/compute/plan").OpenShellComputePlan, - managedWorkloadRebuild: import("./onboard/workload/rebuild").ManagedWorkloadRebuildHandoff | null, - tempManagedRuntime: boolean, - tempManagedRuntimeCatalog: string | null, - dashboardPortReservationScope: import("./onboard/dashboard-port").DashboardPortReservationScope, - hermesApiPortReservationScope: import("./agent/onboard").HermesApiPortReservationScope, - gpu: ReturnType, - model: string, - provider: string, - preferredInferenceApi: string | null = null, - sandboxNameOverride: string | null = null, - webSearchConfig: WebSearchConfig | null = null, - enabledChannels: string[] | null = null, - fromDockerfile: string | null = null, - agent: AgentDefinition | null = null, - controlUiPort: number | null = null, - sandboxGpuConfig: SandboxGpuConfig | null = null, - resourceProfile: import("./resources-cmd").ResourceProfile | null = null, - hermesToolGateways: string[] = [], - hermesAuthMethod: HermesAuthMethod | null = null, - createIntent: import("./onboard/types").SandboxCreateIntent | null = null, - preparedBuildContext: PreparedSandboxBuildContext | null = null, -) { - step(6, 8, "Creating sandbox"); - const sandboxName = validateName( - sandboxNameOverride ?? (await promptValidatedSandboxName(agent)), - "sandbox name", - ); - preparedDcodeRebuild.assertPreparedDcodeTarget(preparedBuildContext, agent, fromDockerfile); - const effectiveAgent = sandboxAgent.getEffectiveSandboxAgent(agent); - const requestedAgentName = getRequestedSandboxAgentName(effectiveAgent); - const legacyDockerfilePath = - effectiveAgent.dockerfilePath ?? - effectiveAgent.legacyPaths?.dockerfile ?? - path.join(ROOT, "Dockerfile"); - enabledChannels = filterEnabledChannelsByAgent(enabledChannels, agent); - const effectiveSandboxGpuConfig = - sandboxGpuConfig ?? resolveSandboxGpuConfig(gpu, { flag: null, device: null }); - const extraProviderPlan = createIntent?.extraProviders - ? { extraProviders: createIntent.extraProviders, staleExtraProviders: [] } - : planRegisteredExtraProviders(GATEWAY_NAME, { runOpenshell }); - const resolvedCreateIntent = createIntent?.resolved ?? (await sandboxCreateIntentResolver.resolve({ sandboxName, inferenceProvider: provider, enabledChannels, webSearchConfig, agent, sandboxGpuConfig: effectiveSandboxGpuConfig, resourceProfile, hermesToolGateways, extraProviders: extraProviderPlan.extraProviders, staleExtraProviders: extraProviderPlan.staleExtraProviders, baselineExclusions: sandboxRegistration.baselineExclusionsForCreate(sandboxName), ...(createIntent?.reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), ...(createIntent?.policyTier !== undefined ? { policyTier: createIntent.policyTier } : {}) })); - const messagingCapabilities = await sandboxCreateIntentResolver.rebind( - { - sandboxName, - enabledChannels, - webSearchConfig, - agent, - ...(createIntent?.reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), - }, - resolvedCreateIntent, - ); - const manageDashboard = dashboardRuntime.shouldManageDashboardForAgent(agent); - const isManagedDcodeAgent = usesManagedDcodeIdentity(agent?.name, fromDockerfile); - let effectivePort = 0, chatUiUrl = "", hermesApiPortReservationInput = { agentName: agent?.name, sandboxName, env: process.env, getSandbox: registry.getSandbox, captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }), warn: (message: string) => console.warn(message) }; - if (manageDashboard) { - const dashboardSelection = await reserveCreateSandboxDashboardPort({ sandboxName, controlUiPort, chatUiUrlEnv: process.env.CHAT_UI_URL, persistedPort: registry.getSandbox(sandboxName)?.dashboardPort ?? null, agentForwardPort: dashboardRuntime.getAgentPrimaryForwardPort(agent, DASHBOARD_PORT), defaultPort: DASHBOARD_PORT, forwardListOutput: runCaptureOpenshell(["forward", "list"], { ignoreError: true }), warn: (message: string) => console.warn(message) }); - ({ effectivePort, chatUiUrl } = dashboardSelection); - dashboardPortReservationScope.current = dashboardSelection.reservation; - } - const hermesDashboardForwarding = onboardHermesDashboard.createHermesDashboardOnboardForwarding({ agentName: agent?.name, env: process.env, ensureForward: ensureAgentFixedForward, note, runOpenshell, getApiForwardPort: () => getDashboardForwardPort(chatUiUrl) }); - const hermesDashboardState = hermesDashboardForwarding.resolveStateForPort(effectivePort); - const { messagingTokenDefs, hasMessagingTokens } = messagingCapabilities; - - const { existingEntry, preservedMcpState, liveExists, effectiveToolDisclosure, toolDisclosureMigrationNeeded, toolDisclosureMigrationNote } = toolDisclosureFlow.prepareSandboxToolDisclosure(sandboxName, preparedBuildContext?.rebuildTarget?.fromDockerfile ? preparedBuildContext.stagedDockerfile : fromDockerfile, isRecreateSandbox(createIntent?.recreate), inspectSandboxForCreate, createIntent?.toolDisclosure ?? null); - let recreateRuntime: import("./onboard/sandbox-recreate-transaction").SandboxRecreateRuntime | recreateJournal.OwnedSandboxRecreateRuntime = sandboxRecreateTransaction.createSandboxRecreateRuntime(onboardSession, createIntent?.recreateTransaction, sandboxName, GATEWAY_NAME, existingEntry, getSandboxRecreateObservation, note); - const restoreReusedSandboxDashboard = async (selectionVerified: boolean): Promise => { - await dashboardPortReservationScope.release(); - ({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({ - sandboxName, - chatUiUrl, - env: process.env, - agent, - model, - provider, - selectionVerified, - sandboxGpuConfig: effectiveSandboxGpuConfig, - gatewayName: GATEWAY_NAME, - gatewayPort: GATEWAY_PORT, - manageDashboard, - ensureDashboardForward, - hermesDashboardForwarding, - updateReusedSandboxMetadata, - })); - }; - if (recreateRuntime.acceptedTarget) { - await restoreReusedSandboxDashboard(true); - return sandboxName; - } - const observabilityDrift = observabilityPolicy.hasRegisteredDcodeObservabilityDrift(liveExists, isManagedDcodeAgent, existingEntry, createIntent?.observabilityEnabled); - const dcodeAutoApprovalPlan = dcodeAutoApprovalFlow.prepareDcodeAutoApprovalCreatePlan({ sandboxName, liveExists, managedDcodeAgent: isManagedDcodeAgent, registryEntry: existingEntry, requestedMode: createIntent?.dcodeAutoApprovalMode }, { error: console.error, exitProcess: (code) => process.exit(code) }); - const envMessagingState = messagingChannelSetup.MessagingHostStateApplier.readPlanStateFromEnv(); - const plannedMessagingState = - envMessagingState?.plan.sandboxName === sandboxName ? envMessagingState : undefined; - const managedWorkloadRuntime = managedWorkloadOnboard.createManagedWorkloadOnboardRuntime({ computePlan, managedWorkloadRebuild, tempManagedRuntime, tempManagedRuntimeCatalog, agentName: requestedAgentName, legacyDockerfilePath, customDockerfilePath: fromDockerfile ?? (preparedBuildContext ? preparedBuildContext.stagedDockerfile : null), rootDir: ROOT, model, provider, preferredInferenceApi, endpointUrl: createIntent?.endpointUrl ?? null, startupProfile: { chatUiUrl, effectiveDashboardPort: effectivePort, manageDashboard, dashboardBindAddress: process.env.NEMOCLAW_DASHBOARD_BIND, wslExposure: requestedAgentName === "openclaw" && isWsl(), hermesDashboardState, webSearch: webSearchConfig, toolDisclosure: effectiveToolDisclosure, hermesToolGateways, messagingPlan: plannedMessagingState?.plan ?? null, dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode, observabilityEnabled: createIntent?.observabilityEnabled === true, environment: process.env }, note, fallbackBuildEstimate: () => process.env.NEMOCLAW_IGNORE_RUNTIME_RESOURCES === "1" ? null : formatSandboxBuildEstimateNote(assessHost()) }, { resolveAgentInferenceApi: inferenceConfig.resolveAgentInferenceApi, getSandboxInferenceConfig }); - const ensurePreparedSandboxWorkload = () => managedWorkloadOnboard.prepareSandboxWorkloadForPortableLifecycle(managedWorkloadRuntime, sandboxGpuCreateFlow.resolvePortableLifecycleMode(agent)); - const prepareHermesStateVolumeLifecycle = (workload: Awaited>) => managedWorkloadOnboard.createManagedHermesStateVolumeOnboardLifecycle({ agentName: requestedAgentName, runtimeProvider: managedWorkloadRuntime.runtimeProvider, sandboxName, workloadKind: workload.source.kind }); - // #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox. - const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName); - - let pendingStateRestore: BackupResult | null = null; - let notReadyRecreateInProgress = false; - const customOpenClawImage = - Boolean(fromDockerfile) && getRequestedSandboxAgentName(agent) === "openclaw"; - const recreateProtection = createSandboxRecreateProtection({ - sandboxName, - sandboxEntry: existingEntry, - customOpenClawImage, - note, - }); - const openRecreateJournal = (): recreateJournal.OwnedSandboxRecreateRuntime => recreateJournal.openOnboardRecreateJournal({ target: { sandboxName, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT }, agentName: getRequestedSandboxAgentName(agent) || "openclaw", note, observe: (probeTarget) => getSandboxRecreateObservation(probeTarget.sandboxName, probeTarget.gatewayName), intent: { agent: getRequestedSandboxAgentName(agent) || null, fromDockerfile: fromDockerfile ?? null, provider: provider ?? null, model: model ?? null, preferredInferenceApi: preferredInferenceApi ?? null, sandboxGpuConfig: effectiveSandboxGpuConfig ?? null, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, toolDisclosure: effectiveToolDisclosure, dcodeAutoApprovalMode: createIntent?.dcodeAutoApprovalMode ?? null, observabilityEnabled: createIntent?.observabilityEnabled === true, policyTier: createIntent?.policyTier ?? null } }); - let pendingStateRestoreBackupPath: string | null = null, preparedSandboxWorkload!: Awaited>, hermesStateVolumeLifecycle!: ReturnType; - if (!liveExists && existingEntry) ({ runtime: recreateRuntime, backupPath: pendingStateRestoreBackupPath } = recreateProtection.selectJournalBoundPreUpgradeBackup({ runtime: recreateRuntime, openJournal: createIntent?.recreateTransaction ? null : openRecreateJournal, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, readRegistryEntry: () => registry.getSandbox(sandboxName), observe: () => getSandboxRecreateObservation(sandboxName, GATEWAY_NAME) })); - - if (liveExists) { - const existingSandboxState = getSandboxReuseState(sandboxName); - const agentDrift = getSandboxAgentDrift(sandboxName, requestedAgentName); - let recreateForAgentDrift = agentDrift.changed && isRecreateSandbox(createIntent?.recreate); - - if (agentDrift.changed && !isRecreateSandbox(createIntent?.recreate)) { - console.log( - ` Sandbox '${sandboxName}' already exists as ${formatSandboxAgentName(agentDrift.existingAgentName)}.`, - ); - console.log( - ` ${cliDisplayName()} is onboarding ${formatSandboxAgentName(agentDrift.requestedAgentName)} for this sandbox name.`, - ); - console.log(" Side-by-side agents are supported, but each sandbox name has one agent type."); - if (isNonInteractive()) { - console.error( - ` Aborting: choose a different name or set NEMOCLAW_RECREATE_SANDBOX=1 to recreate '${sandboxName}'.`, - ); - console.error( - ` Example: ${cliName()} onboard --name ${getDefaultSandboxNameForAgent(agent)}`, - ); - process.exit(1); - } - if ( - await promptYesNoOrDefault( - ` Delete and recreate '${sandboxName}' as ${formatSandboxAgentName(agentDrift.requestedAgentName)}?`, - null, - false, - ) - ) { - recreateForAgentDrift = true; - } else { - console.error(" Aborted. Existing sandbox left unchanged."); - console.error( - ` Re-run with a different name, for example: ${cliName()} onboard --name ${getDefaultSandboxNameForAgent(agent)}`, - ); - process.exit(1); - } - } - - // Check whether messaging providers are missing from the gateway. Only - // force recreation when at least one required provider doesn't exist yet — - // this avoids destroying sandboxes already created with provider attachments. - const needsProviderMigration = - hasMessagingTokens && - messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); - const selectionDrift = isManagedDcodeAgent - ? getDcodeSelectionDrift(sandboxName, provider, model, preferredInferenceApi, { - runCaptureOpenshell, - }) - : getSelectionDrift(sandboxName, provider, model, { runOpenshell }); - const actionableSelectionDrift = requiresSelectionRecreate(selectionDrift, isManagedDcodeAgent); - const sandboxGpuDrift = hasSandboxGpuDrift(sandboxName, effectiveSandboxGpuConfig); - const existingSandboxEntry = registry.getSandbox(sandboxName); - const recordedHermesToolGateways = normalizeHermesToolGatewaySelections( - existingSandboxEntry?.hermesToolGateways, - ); - const hermesToolGatewayDrift = !stringSetsEqual(recordedHermesToolGateways, hermesToolGateways); - const hermesDashboardDrift = onboardHermesDashboard.hasHermesDashboardDrift({ - agentName: agent?.name, - existing: existingSandboxEntry, - state: hermesDashboardState, - }); - - // Detect whether any messaging credential has been rotated since the - // sandbox was created. Provider credentials are resolved once at sandbox - // startup, so a rotated token requires a rebuild to take effect. - const credentialRotation = hasMessagingTokens - ? detectMessagingCredentialRotation(sandboxName, messagingTokenDefs) - : { changed: false, changedProviders: [] }; - - if ( - !isRecreateSandbox(createIntent?.recreate) && - !recreateForAgentDrift && - !needsProviderMigration && - !sandboxGpuDrift && - !credentialRotation.changed && - !hermesToolGatewayDrift && - !hermesDashboardDrift && - !toolDisclosureMigrationNeeded && - !observabilityDrift && - !dcodeAutoApprovalPlan.hasDrift - ) { - // Guard against reusing a CPU-only sandbox when GPU passthrough is enabled. - // Placed before the non-interactive / interactive split so all reuse - // paths are covered (interactive prompt, non-interactive ready, unknown drift). - // Note: legacy registries had gpuEnabled always true (bug fixed in this PR), - // so gpuEnabled=true on a legacy entry doesn't guarantee GPU support. - // The gateway Docker-inspect check (above) catches legacy CPU-only gateways - // before we reach this point, so a legacy sandbox behind a verified GPU - // gateway is safe to reuse — the sandbox will be recreated if needed. - if (effectiveSandboxGpuConfig.sandboxGpuEnabled) { - const entry = registry.getSandbox(sandboxName); - if (entry && !entry.gpuEnabled) { - console.error( - ` Sandbox '${sandboxName}' exists but was created without GPU passthrough.`, - ); - console.error( - " Pass --recreate-sandbox to recreate with GPU, or destroy and re-onboard:", - ); - console.error(` nemoclaw onboard --recreate-sandbox`); - process.exit(1); - } - } - - if (isNonInteractive()) { - if (existingSandboxState === "ready") { - if (actionableSelectionDrift) { - note(" [non-interactive] Recreating sandbox due to provider/model drift."); - } else { - policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); - // Upsert messaging providers even on reuse so credential changes take - // effect without requiring a full sandbox recreation. - upsertMessagingProviders(messagingTokenDefs); - if (selectionDrift.unknown) { - note( - " [non-interactive] Existing provider/model selection is unreadable; reusing sandbox.", - ); - note( - " [non-interactive] Set NEMOCLAW_RECREATE_SANDBOX=1 (or --recreate-sandbox) to force recreation.", - ); - } else { - note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`); - note( - " Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.", - ); - } - await restoreReusedSandboxDashboard(!selectionDrift.unknown); - return sandboxName; - } - } else { - notReadyRecreateInProgress = true; - const outcome = recreateProtection.resolveNotReadyOutcome(); - if (outcome.kind === "blocked") { - for (const hint of outcome.hints) console.error(hint); - process.exit(1); - } - pendingStateRestoreBackupPath = outcome.restoreBackupPath; - } - } else if (existingSandboxState === "ready") { - if (actionableSelectionDrift) { - const confirmed = await confirmRecreateForSelectionDrift( - sandboxName, - selectionDrift, - provider, - model, - ); - if (!confirmed) { - console.error(" Aborted. Existing sandbox left unchanged."); - process.exit(1); - } - } else { - console.log(` Sandbox '${sandboxName}' already exists.`); - console.log(" Choosing 'n' will delete the existing sandbox and create a new one."); - if (await promptYesNoOrDefault(" Reuse existing sandbox?", null, true)) { - policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); - upsertMessagingProviders(messagingTokenDefs); - await restoreReusedSandboxDashboard(!selectionDrift.unknown); - return sandboxName; - } - } - } else { - console.log(` Sandbox '${sandboxName}' exists but is not ready.`); - console.log(" Selecting 'n' will abort onboarding."); - if (!(await promptYesNoOrDefault(" Delete it and create a new one?", null, true))) { - console.log(" Aborting onboarding."); - process.exit(1); - } - } - } - - if (credentialRotation.changed && existingSandboxState === "ready") { - const rotatedNames = credentialRotation.changedProviders.join(", "); - console.log(` Messaging credential(s) rotated: ${rotatedNames}`); - console.log(" Rebuilding sandbox to propagate new credentials to the L7 proxy..."); - if (!shouldSkipPreRecreateBackup(process.env)) { - const result = recreateProtection.backup(); - if (!result.ok) { - console.error( - " Set NEMOCLAW_RECREATE_WITHOUT_BACKUP=1 to recreate without preserving state.", - ); - process.exit(1); - } - pendingStateRestore = result.backup; - } - } - - if (recreateForAgentDrift) { - note( - ` Sandbox '${sandboxName}' exists as ${formatSandboxAgentName(agentDrift.existingAgentName)} — recreating as ${formatSandboxAgentName(agentDrift.requestedAgentName)}.`, - ); - } else if (needsProviderMigration) { - console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); - console.log(" Recreating to ensure credentials flow through the provider pipeline."); - } else if (actionableSelectionDrift) { - note( - ` Sandbox '${sandboxName}' exists — recreating because its live model/provider selection is stale or unreadable.`, - ); - } else if (sandboxGpuDrift) { - note(` Sandbox '${sandboxName}' exists — recreating to apply sandbox GPU settings.`); - } else if (hermesToolGatewayDrift) { - note(` Sandbox '${sandboxName}' exists — recreating to apply Hermes managed-tool changes.`); - } else if (hermesDashboardDrift) { - note(` Sandbox '${sandboxName}' exists — recreating to apply Hermes dashboard settings.`); - } else if (observabilityDrift) { - note(` Sandbox '${sandboxName}' exists — recreating to apply observability settings.`); - } else if (dcodeAutoApprovalPlan.hasDrift) { - note(` Sandbox '${sandboxName}' exists — recreating to apply DCode auto-approval settings.`); - } else if (toolDisclosureMigrationNote) { - note(toolDisclosureMigrationNote); - } else if (credentialRotation.changed) { - // Message already printed above during backup. - } else if (existingSandboxState === "ready") { - note(` Sandbox '${sandboxName}' exists and is ready — recreating by explicit request.`); - } else { - note(` Sandbox '${sandboxName}' exists but is not ready — recreating it.`); - } - - if (preservedMcpState) { - for (const hint of recreateJournal.managedMcpRecreateRefusalHints({ sandboxName, cliName: cliName(), toolDisclosure: effectiveToolDisclosure, rebuildFlag: dcodeAutoApprovalPlan.rebuildFlag, observabilityFlag: observabilityCommandFlag.explicitObservabilityFlag(createIntent?.observabilityEnabled === true, createIntent?.observabilityRequestedExplicitly === true) })) console.error(hint); - process.exit(1); - } - // Resolve and validate immutable workload authority before opening a recreate journal or - // mutating a live sandbox. - preparedSandboxWorkload = await ensurePreparedSandboxWorkload(); - await hermesApiPortReservationScope.selectAndReserve(hermesApiPortReservationInput); - if (!createIntent?.recreateTransaction) recreateRuntime = openRecreateJournal(); - if (recreateRuntime.acceptedTarget) { - if ("complete" in recreateRuntime) recreateRuntime.complete(); - await restoreReusedSandboxDashboard(true); - return sandboxName; - } - const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName); - baseImageResolutionFlow.captureBaseResolution(baseImageResolutionContext, previousEntry?.imageTag); - policyPresetCarry.applyRecreatePolicyCarryForward(sandboxName, isNonInteractive(), note); - - const noRestorePending = pendingStateRestore === null && pendingStateRestoreBackupPath === null; - if (noRestorePending && !notReadyRecreateInProgress && !shouldSkipPreRecreateBackup(process.env)) { - note(" Backing up workspace state before recreating sandbox..."); - const result = recreateProtection.backup(); - if (!result.ok) { - console.error( - " Set NEMOCLAW_RECREATE_WITHOUT_BACKUP=1 to recreate without preserving state.", - ); - process.exit(1); - } - pendingStateRestore = result.backup; - } - - hermesStateVolumeLifecycle = prepareHermesStateVolumeLifecycle(preparedSandboxWorkload); - note(` Deleting and recreating sandbox '${sandboxName}'...`); - - if (recreateRuntime.beginDelete() === "source") { runSandboxProviderPreDeleteCleanup(sandboxName, { runOpenshell, redact }); runOpenshell(["sandbox", "delete", "-g", recreateRuntime.journaledGatewayName ?? GATEWAY_NAME, sandboxName], { ignoreError: true }); if (!waitForSandboxRecreateDeleteAbsence(sandboxName, recreateRuntime.journaledGatewayName ?? GATEWAY_NAME, note)) throw new Error(`Cannot continue sandbox '${sandboxName}' recreation: OpenShell did not confirm explicit source absence after delete.`); } - recreateRuntime.confirmDeleted(); - sandboxLifecycle.removeSandboxUnlessSessionReservation(previousEntry, sandboxName); - await hermesApiPortReservationScope.rebindAfterOwnedForwardDelete( - hermesApiPortReservationInput, - ); - } - if (!liveExists) { await hermesApiPortReservationScope.selectAndReserve(hermesApiPortReservationInput); preparedSandboxWorkload = await ensurePreparedSandboxWorkload(); hermesStateVolumeLifecycle = prepareHermesStateVolumeLifecycle(preparedSandboxWorkload); } - applyExtraProviderReconciliation({ - extraProviders: resolvedCreateIntent.extraProviders, - staleExtraProviders: resolvedCreateIntent.staleExtraProviders ?? [], - }); - const dockerDriverGateway = isLinuxDockerDriverGatewayEnabled(); - const { initialSandboxPolicy, policyTier: resolvedCreatePolicyTier, messagingProviders, gpuRoutePlan, compatibilityPolicyPath, initialGpuRoute, sandboxReadyTimeoutSecs, buildId, dashboardRemoteBindPrepared, legacyBuildContext, launch: { createArgv, effectiveDashboardPort, intendedSandboxStartupCommand, managedBootstrapIdentity, managedStartupRootApplyRequest, prebuild, sandboxEnv, sandboxStartupCommand } } = await managedWorkloadOnboard.prepareOnboardSandboxWorkloadLaunch({ - runtime: managedWorkloadRuntime, workload: preparedSandboxWorkload, - legacy: { preparedBuildContext, agent, fromDockerfile, createAgentSandbox: (selectedAgent) => baseImageResolutionFlow.createAgentSandboxWithResolution(baseImageResolutionContext, selectedAgent, agentOnboard.createAgentSandbox), resolvePatchInput: () => ({ preparedBuildContext, agent, fromDockerfile, model, chatUiUrl, provider, endpointUrl: createIntent?.endpointUrl ?? null, compatibleEndpointReasoning: createIntent?.compatibleEndpointReasoning, preferredInferenceApi, webSearchConfig, toolDisclosure: effectiveToolDisclosure, rebuildPreservedEnv: createIntent?.rebuildPreservedEnv, ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), hermesToolGateways, sandboxGpuConfig: effectiveSandboxGpuConfig, ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions(baseImageResolutionContext), gatewayPort: GATEWAY_PORT }) }, - plan: { intent: resolvedCreateIntent, rebindMessagingTokenDefs: async () => (await sandboxCreateIntentResolver.rebind({ sandboxName, enabledChannels, webSearchConfig, agent, ...(createIntent?.reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}) }, resolvedCreateIntent)).messagingTokenDefs, runProviderPreDeleteCleanup: () => runSandboxProviderPreDeleteCleanup(sandboxName, { runOpenshell, redact, tolerateMissingSandbox: true }), upsertMessagingProviders, getHermesToolGatewayProviderName: (targetSandbox) => getHermesToolGatewayBroker().getHermesToolGatewayProviderName(targetSandbox), discloseInitialSandboxPolicy }, - launchInput: { agent, observabilityEnabled: createIntent?.observabilityEnabled === true, chatUiUrl, sandboxName, env: process.env, extraPlaceholderKeys: resolvedCreateIntent.extraPlaceholderKeys, getDashboardForwardPort, hermesDashboardState, hermesApiPort: hermesApiPortReservationScope.effectivePort, manageDashboard, openshellShellCommand, openshellArgv }, - plannedMessagingPlan: plannedMessagingState?.plan ?? null, - gpu: { provider, config: effectiveSandboxGpuConfig, dockerDriverGateway, gatewayPort: GATEWAY_PORT }, - dependencies: { materializeSandboxCreatePlan: (input) => hermesStateVolumeLifecycle.materializeSandboxCreatePlan(input, sandboxCreatePlanMaterialization.materializeSandboxCreatePlan), prepareSandboxBuildPatchConfig: sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig }, - }); - const restoreBackupPath = - pendingStateRestore?.manifest?.backupPath ?? pendingStateRestoreBackupPath; - onboardSessionBootstrap.verifyReadOnlyHostMountSources(resolvedCreateIntent.hostMounts); - recreateRuntime.advance("creating"); - const managedBootstrap = managedWorkloadOnboard.resolveOnboardManagedBootstrapLaunch({ - runtime: managedWorkloadRuntime, - workload: preparedSandboxWorkload, - stateRoot: getDockerDriverGatewayStateDir(), - bootstrapIdentity: managedBootstrapIdentity, - request: managedStartupRootApplyRequest, - intendedWorkloadArgv: intendedSandboxStartupCommand, - }); - const createdSandboxLifecycle = sandboxRecreateTransaction.createCreatedSandboxLifecycle(recreateRuntime, { sandboxName, gatewayName: GATEWAY_NAME }, getSandboxRecreateObservation); - const { - createResult, - runtimePatch, - route: selectedGpuRoute, - firstCreateOutput, - registryImageRef, - lifecycleRegistrationFields, - } = await sandboxGpuCreateFlow.runSandboxGpuCreateFlow( - { - sandboxName, - provider, - sandboxGpuConfig: effectiveSandboxGpuConfig, - gpuRoutePlan, - initialGpuRoute, - compatibilityPolicyPath, - dockerDriverGateway, - gatewayPort: GATEWAY_PORT, - sandboxReadyTimeoutSecs, - createArgv, - sandboxEnv, - sandboxStartupCommand, - lifecycleGeneration: createdSandboxLifecycle.generation, - portableRuntimeAuthority, - prebuild, - restoreBackupPath, - terminalAgent: agentDefs.isTerminalAgent(agent), - managedBootstrap, - ...sandboxGpuCreateFlow.resolveAgentCreateInput(agent, dockerDriverGateway), - }, - { - runOpenshell, - runCaptureOpenshell, - sleep: sleepSeconds, - openshellArgv, - verifyDirectSandboxGpu, - }, - ); - - if (initialSandboxPolicy.cleanup && initialSandboxPolicy.cleanup()) { - process.removeListener("exit", initialSandboxPolicy.cleanup); - } - - // Only deregister the 'exit' safety net when inline cleanup succeeded; - // otherwise leave it armed so a later process.exit() still removes the - // temp dir (which may hold source and env-arg API keys). - const cleanupBuildCtx = legacyBuildContext?.cleanupBuildCtx; - if (cleanupBuildCtx?.()) { - process.removeListener("exit", cleanupBuildCtx); - } - - if (effectiveSandboxGpuConfig.sandboxGpuEnabled) { - await dockerGpuLocalInference.verifyGpuSandboxLocalInferenceAndCommitAfterReady( - effectiveSandboxGpuConfig, - provider, - { - sandboxName, - dockerDriverGateway, - selectedRoute: selectedGpuRoute, - verifyDirectSandboxGpu, - runCaptureOpenshell, - log: console.log, - }, - runtimePatch, - ); - } - - let actualDashboardPort = 0; - let finalHermesDashboardState = hermesDashboardState; - if (manageDashboard) { - await dashboardPortReservationScope.release(); - actualDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl, { - rollbackSandboxOnFailure: true, - }); - if (actualDashboardPort !== Number(getDashboardForwardPort(chatUiUrl))) { - chatUiUrl = `http://127.0.0.1:${actualDashboardPort}`; - } - process.env.CHAT_UI_URL = chatUiUrl; - finalHermesDashboardState = hermesDashboardForwarding.resolveStateForPort(actualDashboardPort); - hermesDashboardForwarding.ensureForState(finalHermesDashboardState, sandboxName, true); - } - - const { resolvedImageTag, workloadReceipt } = - managedWorkloadOnboard.resolveOnboardSandboxWorkloadReceipt({ - runtime: managedWorkloadRuntime, - workload: preparedSandboxWorkload, - registryImageRef, - prebuildImageRef: prebuild.imageRef, - firstCreateOutput, - createOutput: createResult.output, - buildId, - extractBuiltImageRef: buildContext.extractBuiltImageRef, - resolveSandboxImageTagFromCreateOutput, - }); - const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); - const pinnedLifecycleRegistration = createdSandboxLifecycle.capture(lifecycleRegistrationFields); - finalizeCreatedSandbox( - { - sandboxName, - restoreBackupPath, - preUpgradeBackup: pendingStateRestoreBackupPath !== null, - targetAgentType: agent?.name ?? "openclaw", - customImage: Boolean(fromDockerfile), - discoverOpenClawImagePluginInstalls: customOpenClawImage, - validateManagedDcode: isManagedDcodeAgent, - provider, - model, - preferredInferenceApi, - }, - { - discoverFreshOpenClawImagePluginInstalls: (name) => openClawPluginRestore.discoverFreshOpenClawImagePluginInstalls(name, sandboxState, agent?.configPaths.dir), - restoreRecreatedSandboxState: sandboxState.restoreRecreatedSandboxState, - getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => - getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { - runCaptureOpenshell, - }), - note, - error: console.error, - exitProcess: (code) => process.exit(code), - register: (openclawImagePluginInstalls) => sandboxRegistration.registerCreatedSandbox({ - sandboxName, - inferenceSelection: sandboxRegistration.selection(sandboxName, provider, model, preferredInferenceApi, createIntent?.endpointSource ?? null), - runtimeFields: sandboxRuntimeFields, - agent, - agentVersionKnown: !fromDockerfile, portableLifecycle: sandboxGpuCreateFlow.resolvePortableLifecycleMode(agent, process.env), - imageTag: resolvedImageTag, - workload: workloadReceipt, - openclawImagePluginInstalls, - appliedPolicies: initialSandboxPolicy.appliedPresets, - toolDisclosure: effectiveToolDisclosure, - observabilityEnabled: createIntent?.observabilityEnabled === true, - ...(isManagedDcodeAgent ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } : {}), - policyTier: resolvedCreatePolicyTier, - ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod), dashboardRemoteBindPrepared, resolvedCreateIntent.policy.options.baselineExclusions), - plannedMessagingState, - preservedMcpState, - hermesToolGateways, - hermesDashboardState: finalHermesDashboardState, - hermesApiPort: hermesApiPortReservationScope.effectivePort, - dashboardPort: actualDashboardPort, - ...createdSandboxLifecycle.revalidate(pinnedLifecycleRegistration), - gatewayName: GATEWAY_NAME, - gatewayPort: GATEWAY_PORT, - hostMounts: resolvedCreateIntent.hostMounts, - }), - }, - ); - hermesStateVolumeLifecycle.commit(); if ("complete" in recreateRuntime) recreateRuntime.complete(); - restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization - - // DNS proxy — run a forwarder in the sandbox pod so the isolated - // sandbox namespace can resolve hostnames (fixes #626). - if (sandboxRuntimeFields.openshellDriver === "kubernetes") { - console.log(" Setting up sandbox DNS proxy..."); - runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], { - ignoreError: true, - }); - } - - require("./onboard/vm-dns-monkeypatch").applyOnboardVmDnsMonkeypatch( - sandboxName, - sandboxRuntimeFields, +const sandboxCreateOrchestrationRuntime = { + DASHBOARD_PORT, + get GATEWAY_NAME() { + return GATEWAY_NAME; + }, + get GATEWAY_PORT() { + return GATEWAY_PORT; + }, + ROOT, + SCRIPTS, + agentDefs, + agentOnboard, + applyExtraProviderReconciliation, + assessHost, + baseImageResolutionFlow, + cliDisplayName, + cliName, + completeOrdinaryOnboardSandboxCreation, + confirmRecreateForSelectionDrift, + createOnboardCreatedSandboxCompletion, + createOnboardCreatedSandboxRegistration, + createSandboxRecreateProtection, + dashboardRuntime, + dcodeAutoApprovalFlow, + detectMessagingCredentialRotation, + get ensureAgentFixedForward() { + return ensureAgentFixedForward; + }, + get ensureDashboardForward() { + return ensureDashboardForward; + }, + filterEnabledChannelsByAgent, + formatSandboxAgentName, + formatSandboxBuildEstimateNote, + get getDashboardForwardPort() { + return getDashboardForwardPort; + }, + getDcodeSelectionDrift, + getDefaultSandboxNameForAgent, + getDockerDriverGatewayStateDir, + getHermesToolGatewayBroker, + getRequestedSandboxAgentName, + getSandboxAgentDrift, + getSandboxRecreateObservation, + getSandboxReuseState, + getSandboxRuntimeRegistryFields, + getSelectionDrift, + hasSandboxGpuDrift, + inferenceConfig, + inspectSandboxForCreate, + isLinuxDockerDriverGatewayEnabled, + isNonInteractive, + isRecreateSandbox, + isWsl, + managedWorkloadOnboard, + messagingChannelSetup, + nim, + normalizeHermesAuthMethod, + normalizeHermesToolGatewaySelections, + note, + observabilityCommandFlag, + observabilityPolicy, + onboardHermesDashboard, + onboardSession, + onboardSessionBootstrap, + openshellArgv, + path, + planRegisteredExtraProviders, + policyPresetCarry, + preparedDcodeRebuild, + promptValidatedSandboxName, + promptYesNoOrDefault, + providerExistsInGateway, + recreateJournal, + registry, + requiresSelectionRecreate, + reserveCreateSandboxDashboardPort, + resolveSandboxGpuConfig, + runCaptureOpenshell, + runOpenshell, + runSandboxProviderPreDeleteCleanup, + sandboxAgent, + sandboxBuildPatchConfig, + get sandboxCancelRollback() { + return sandboxCancelRollback; + }, + get sandboxCreateIntentResolver() { + return sandboxCreateIntentResolver; + }, + sandboxGpuCreateFlow, + sandboxLifecycle, + sandboxMutationLock, + sandboxRecreateTransaction, + sandboxRegistration, + sandboxRegistryMetadata, + sandboxReuse, + shouldSkipPreRecreateBackup, + sleepSeconds, + step, + stringSetsEqual, + toolDisclosureFlow, + upsertMessagingProviders, + usesManagedDcodeIdentity, + validateName, + verifyDirectSandboxGpu, + waitForSandboxRecreateDeleteAbsence, + wasSandboxDefault, + updateReusedSandboxMetadata, + getSandboxInferenceConfig, + redact, + openshellShellCommand, + discloseInitialSandboxPolicy, + compactText, + runFile, + dockerInfoFormat, + runCapture, +}; +export type SandboxCreateOrchestrationRuntime = typeof sandboxCreateOrchestrationRuntime; +const createSandboxWithBaseImageResolution = + sandboxCreateOrchestration.createSandboxWithBaseImageResolution( + sandboxCreateOrchestrationRuntime, ); - // Check that messaging providers exist in the gateway (sandbox attachment - // cannot be verified via CLI yet — only gateway-level existence is checked). - for (const p of messagingProviders) { - if (!providerExistsInGateway(p)) { - printMessagingProviderMissing(p); - } - } - - console.log(` ✓ Sandbox '${sandboxName}' created`); - - warnIfLandlockUnsupported({ dockerInfoFormat, runCapture }); - - // #4614: arm rollback only when the sandbox was not live before (never a recreate/rebuild). - if (!liveExists) sandboxCancelRollback.arm(sandboxName); - return sandboxName; -} - const { createSandbox, createSandboxWithTemporaryManagedRuntime } = agentOnboard.createHermesApiPortScopedSandboxEntryPoints({ createBaseImageResolutionContext: () => baseImageResolutionFlow.createBaseImageResolutionContext({ fresh: false }), createSandboxWithBaseImageResolution, - resolvePortableRuntimeAuthority: () => - sandboxGpuCreateFlow.resolveExportedPortableRuntimeAuthority( + resolvePortableRuntimeContext: () => { + const authority = sandboxGpuCreateFlow.resolveExportedPortableRuntimeAuthority( process.env, onboardSession.loadSession, - ), + ); + return authority ? { authority, environmentScope: null } : null; + }, resolveComputePlan: dockerDriverPlatform.resolveCurrentOpenShellComputePlan, }); @@ -2120,8 +1688,18 @@ const { createSandbox, createSandboxWithTemporaryManagedRuntime } = type ProviderChoice = import("./onboard/provider-menu").ProviderMenuChoice; type RebuildRouteHandoff = import("./onboard/rebuild-route-handoff").RebuildRouteHandoff; -const { readRecordedProvider, readRecordedNimContainer, readRecordedModel, readRecordedEndpointUrl, - readRecordedInferenceRoute, readRecordedProviderEndpoints } = providerRecovery.createProviderRecoveryHelpers({ parseGatewayInference, runCaptureOpenshell, warn: (message) => console.warn(message) }); +const { + readRecordedProvider, + readRecordedNimContainer, + readRecordedModel, + readRecordedEndpointUrl, + readRecordedInferenceRoute, + readRecordedProviderEndpoints, +} = providerRecovery.createProviderRecoveryHelpers({ + parseGatewayInference, + runCaptureOpenshell, + warn: (message) => console.warn(message), +}); async function selectAndValidateOllamaModel( gpu: ReturnType, @@ -2142,7 +1720,11 @@ async function selectAndValidateOllamaModel( } else if (isNonInteractive()) { model = localInference.resolveNonInteractiveOllamaModel(requestedModel, recoveredModel, gpu); } else { - model = await promptOllamaModel(gpu, { defaultModel: promptDefaultModel && isSafeModelId(promptDefaultModel) ? promptDefaultModel : null, excludeModels: probeFailures.excludedModels() }); + model = await promptOllamaModel(gpu, { + defaultModel: + promptDefaultModel && isSafeModelId(promptDefaultModel) ? promptDefaultModel : null, + excludeModels: probeFailures.excludedModels(), + }); } if (isBackToSelection(model)) { console.log(" Returning to provider selection."); @@ -2215,7 +1797,11 @@ async function selectAndValidateOllamaModel( " ℹ Using chat completions API (Ollama tool calls require /v1/chat/completions)", ); } - return ollamaFlow.completeOllamaRuntimeContextSelection(localInference.applyOllamaRuntimeContextWindow(selectedModel, defaults), { outcome: "selected", model: selectedModel, allowToolsIncompatible }, isNonInteractive); + return ollamaFlow.completeOllamaRuntimeContextSelection( + localInference.applyOllamaRuntimeContextWindow(selectedModel, defaults), + { outcome: "selected", model: selectedModel, allowToolsIncompatible }, + isNonInteractive, + ); } } @@ -2225,7 +1811,16 @@ type OllamaModelSelectionDefaults = import("./onboard/setup-nim-selection").OllamaModelSelectionDefaults; type SetupNimSelectionResult = "selected" | "retry-selection"; -type RemoteProviderSelectionArgs = { selected: ProviderChoice; requestedModel: string | null; recoveredFromSandbox: boolean; recoveredModel: string | null; sandboxName: string | null; gatewayName: string | null; intendedInferenceApi: string | null; recoverySessionId: string | null | undefined }; +type RemoteProviderSelectionArgs = { + selected: ProviderChoice; + requestedModel: string | null; + recoveredFromSandbox: boolean; + recoveredModel: string | null; + sandboxName: string | null; + gatewayName: string | null; + intendedInferenceApi: string | null; + recoverySessionId: string | null | undefined; +}; async function handleRoutedSelection( state: SetupNimSelectionState, @@ -2423,8 +2018,19 @@ async function handleNimLocalSelection( return "selected"; } -async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, state: SetupNimSelectionState, recoveredRegistryRoute: RebuildRouteHandoff["route"] | null): Promise { - const { selected, requestedModel, recoveredFromSandbox, recoveredModel, sandboxName, intendedInferenceApi } = args; +async function handleRemoteProviderSelection( + args: RemoteProviderSelectionArgs, + state: SetupNimSelectionState, + recoveredRegistryRoute: RebuildRouteHandoff["route"] | null, +): Promise { + const { + selected, + requestedModel, + recoveredFromSandbox, + recoveredModel, + sandboxName, + intendedInferenceApi, + } = args; const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; state.provider = remoteConfig.providerName; state.credentialEnv = remoteConfig.credentialEnv; @@ -2454,7 +2060,12 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, ); } const explicitApi = (process.env.NEMOCLAW_PREFERRED_API || "").trim().toLowerCase(); - state.preferredInferenceApi = selected.key === "custom" ? (explicitApi === "chat-completions" ? "openai-completions" : explicitApi || null) : null; + state.preferredInferenceApi = + selected.key === "custom" + ? explicitApi === "chat-completions" + ? "openai-completions" + : explicitApi || null + : null; if (!state.preferredInferenceApi) { state.preferredInferenceApi = selected.key === "custom" || @@ -2503,7 +2114,9 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, ); const defaultModel = - requestedModel || (typeof state.model === "string" && state.model) || remoteConfig.defaultModel; + requestedModel || + (typeof state.model === "string" && state.model) || + remoteConfig.defaultModel; if (isNonInteractive()) { state.model = defaultModel; } else { @@ -2546,14 +2159,23 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, provider: state.provider, helpUrl: REMOTE_PROVIDER_CONFIG.build.helpUrl, recoveredFromSandbox, - providerExistsInGateway: (name) => providerExistsInGateway(name, args.gatewayName ?? GATEWAY_NAME), + providerExistsInGateway: (name) => + providerExistsInGateway(name, args.gatewayName ?? GATEWAY_NAME), }); state.skipHostInferenceSmoke = reuseGatewayCredential; state.reuseGatewayCredentialWithoutLocalKey = reuseGatewayCredential; } else { apiKeyNavigation = await ensureApiKey(); } - state.model = await selectFeaturedModelAfterCredentialPrompt(state.nvidiaFeaturedModels!, apiKeyNavigation, credentialPrompt.shouldReturnToProviderSelection, requestedModel || (typeof state.model === "string" ? state.model : null), recoveredFromSandbox ? recoveredModel : null, isNonInteractive(), process.env.NEMOCLAW_MODEL); + state.model = await selectFeaturedModelAfterCredentialPrompt( + state.nvidiaFeaturedModels!, + apiKeyNavigation, + credentialPrompt.shouldReturnToProviderSelection, + requestedModel || (typeof state.model === "string" ? state.model : null), + recoveredFromSandbox ? recoveredModel : null, + isNonInteractive(), + process.env.NEMOCLAW_MODEL, + ); if (isBackToSelection(state.model)) { console.log(" Returning to provider selection."); console.log(""); @@ -2569,9 +2191,23 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, _envModelRemote || (recoveredFromSandbox && recoveredModel) || remoteConfig.defaultModel; - const selectedCredentialEnv = requireValue(state.credentialEnv, `Missing credential env for ${remoteConfig.label}`); - const compatibleNoAuth = selected.key === "custom" && Boolean(state.endpointUrl && compatibleEndpointGatewayRoute.gatewayReachableCompatibleEndpointUrl(state.provider, state.endpointUrl) !== state.endpointUrl); - const useNoAuth = compatibleNoAuth && (!isNonInteractive() || (process.env.NEMOCLAW_COMPATIBLE_AUTH_MODE || "").trim().toLowerCase() === "none"); + const selectedCredentialEnv = requireValue( + state.credentialEnv, + `Missing credential env for ${remoteConfig.label}`, + ); + const compatibleNoAuth = + selected.key === "custom" && + Boolean( + state.endpointUrl && + compatibleEndpointGatewayRoute.gatewayReachableCompatibleEndpointUrl( + state.provider, + state.endpointUrl, + ) !== state.endpointUrl, + ); + const useNoAuth = + compatibleNoAuth && + (!isNonInteractive() || + (process.env.NEMOCLAW_COMPATIBLE_AUTH_MODE || "").trim().toLowerCase() === "none"); const bedrockSelection = await bedrockRuntimeOnboard.selectBedrockRuntimeCustomAnthropic({ selectedKey: selected.key, endpointUrl: state.endpointUrl, @@ -2601,18 +2237,59 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, if (isNonInteractive()) { state.model = defaultModel; state.assertRouteCompatible?.(); - if (useNoAuth) state.credentialEnv = OLLAMA_PROXY_CREDENTIAL_ENV; else recoveredProviderReuse.resolveRecoveredProviderCredentialReuse( - { selected, remoteConfig, state, selectedCredentialEnv, recoveredFromSandbox, selectedModel: defaultModel, sandboxName, recoveredRegistryRoute }, - { resolveProviderCredential, readRecordedInferenceRoute: (name) => readRecordedInferenceRoute(name, args.recoverySessionId), readRecordedProviderEndpoints, readGatewayProviderMetadata: (provider) => onboardProviders.readGatewayProviderMetadata(provider, runOpenshell, args.gatewayName ?? GATEWAY_NAME), note }, - ); + if (useNoAuth) state.credentialEnv = OLLAMA_PROXY_CREDENTIAL_ENV; + else + recoveredProviderReuse.resolveRecoveredProviderCredentialReuse( + { + selected, + remoteConfig, + state, + selectedCredentialEnv, + recoveredFromSandbox, + selectedModel: defaultModel, + sandboxName, + recoveredRegistryRoute, + }, + { + resolveProviderCredential, + readRecordedInferenceRoute: (name) => + readRecordedInferenceRoute(name, args.recoverySessionId), + readRecordedProviderEndpoints, + readGatewayProviderMetadata: (provider) => + onboardProviders.readGatewayProviderMetadata( + provider, + runOpenshell, + args.gatewayName ?? GATEWAY_NAME, + ), + note, + }, + ); } else { - const credentialResult = await credentialPrompt.ensureNamedCredential(selectedCredentialEnv, compatibleNoAuth ? `${remoteConfig.label} API key (press Enter for no authentication)` : `${remoteConfig.label} API key`, remoteConfig.helpUrl, openrouterSelection.credentialValidatorForProvider(selected.key), compatibleNoAuth); + const credentialResult = await credentialPrompt.ensureNamedCredential( + selectedCredentialEnv, + compatibleNoAuth + ? `${remoteConfig.label} API key (press Enter for no authentication)` + : `${remoteConfig.label} API key`, + remoteConfig.helpUrl, + openrouterSelection.credentialValidatorForProvider(selected.key), + compatibleNoAuth, + ); if (credentialPrompt.returningToProviderSelection(credentialResult)) { return "retry-selection"; } if (credentialResult === "") state.credentialEnv = OLLAMA_PROXY_CREDENTIAL_ENV; } - if (!useNoAuth) openrouterSelection.validateNonInteractiveCredential({ selectedKey: selected.key, selectedCredentialEnv, isNonInteractive: isNonInteractive(), reuseGatewayCredentialWithoutLocalKey: state.reuseGatewayCredentialWithoutLocalKey, resolveProviderCredential, getCredential, error: (message) => console.error(message), exitProcess: (code) => process.exit(code) }); + if (!useNoAuth) + openrouterSelection.validateNonInteractiveCredential({ + selectedKey: selected.key, + selectedCredentialEnv, + isNonInteractive: isNonInteractive(), + reuseGatewayCredentialWithoutLocalKey: state.reuseGatewayCredentialWithoutLocalKey, + resolveProviderCredential, + getCredential, + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + }); let modelValidator: ((candidate: string) => ModelValidationResult) | null = null; if (openrouterSelection.isOpenAiLikeRemoteProvider(selected.key)) { const modelAuthMode = getProbeAuthMode(state.provider); @@ -2642,7 +2319,14 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, if (isNonInteractive()) { state.model = defaultModel; } else if (openrouterSelection.isOpenRouterProvider(selected.key)) { - state.model = await openrouterSelection.selectModel({ state, requestedModel, recoveredFromSandbox, recoveredModel, remoteConfig, validateOpenAiLikeModel }); + state.model = await openrouterSelection.selectModel({ + state, + requestedModel, + recoveredFromSandbox, + recoveredModel, + remoteConfig, + validateOpenAiLikeModel, + }); } else if (remoteConfig.modelMode === "curated") { state.model = await promptRemoteModel( remoteConfig.label, @@ -2662,9 +2346,13 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs, const validationResult = state.reuseGatewayCredentialWithoutLocalKey ? "selected" - : await validateSelectedRemoteModel( - { selected, remoteConfig, state, selectedCredentialEnv, intendedInferenceApi }, - ); + : await validateSelectedRemoteModel({ + selected, + remoteConfig, + state, + selectedCredentialEnv, + intendedInferenceApi, + }); if (validationResult === "selected") { state.assertRouteCompatible?.(); break; @@ -2775,7 +2463,10 @@ function getSetupInferenceDeps(): SetupInferenceDeps { verifyInferenceRoute, verifyOnboardInferenceSmoke, isNonInteractive, - updateSandbox: registry.reserveSandboxInferenceRoute, getSandbox: registry.getSandbox, listSandboxes: registry.listSandboxes, unloadOllamaModels, + updateSandbox: registry.reserveSandboxInferenceRoute, + getSandbox: registry.getSandbox, + listSandboxes: registry.listSandboxes, + unloadOllamaModels, hermesProviderAuth, getHermesToolGatewayBroker, providerExistsInGateway, @@ -2836,7 +2527,25 @@ const sandboxCreateIntentResolver = sandboxCreateIntentResolution.createSandboxC import("./resources-cmd").ResourceProfile >({ channels: MESSAGING_CHANNELS, - messagingPreflightDeps: { readMessagingPlanFromEnv: messagingChannelSetup.readMessagingPlanFromEnv, resolveDisabledChannels: channelState.resolveDisabledChannels, gatewayName: () => GATEWAY_NAME, registry, providerExistsInGateway, providerMatchesGatewayCredential, isNonInteractive, promptYesNoOrDefault, cliName, log: (message) => console.log(message), error: (message) => console.error(message), exitProcess: (code) => process.exit(code), getValidatedMessagingTokenByEnvKey, getCredential, normalizeCredentialValue, registerExtraPlaceholderProviders: extraPlaceholderKeysModule.registerExtraPlaceholderProviders, getMessagingChannelForEnvKey }, + messagingPreflightDeps: { + readMessagingPlanFromEnv: messagingChannelSetup.readMessagingPlanFromEnv, + resolveDisabledChannels: channelState.resolveDisabledChannels, + gatewayName: () => GATEWAY_NAME, + registry, + providerExistsInGateway, + providerMatchesGatewayCredential, + isNonInteractive, + promptYesNoOrDefault, + cliName, + log: (message) => console.log(message), + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + getValidatedMessagingTokenByEnvKey, + getCredential, + normalizeCredentialValue, + registerExtraPlaceholderProviders: extraPlaceholderKeysModule.registerExtraPlaceholderProviders, + getMessagingChannelForEnvKey, + }, filterEnabledChannelsByAgent, defaultPolicyPath: path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), getAgentPolicyPath: (agent) => (agent ? agentOnboard.getAgentPolicyPath(agent) : null), @@ -2851,7 +2560,13 @@ const sandboxCreateIntentResolver = sandboxCreateIntentResolution.createSandboxC }), }); -const stageSandboxCredentialProviders = (input: import("./onboard/credential-provider-registration").StageSandboxCredentialProvidersInput) => registeredCredentialProviders.stageSandboxCredentialProviders(input, sandboxCreateIntentResolver.prepareCredentialProviders); +const stageSandboxCredentialProviders = ( + input: import("./onboard/credential-provider-registration").StageSandboxCredentialProvidersInput, +) => + registeredCredentialProviders.stageSandboxCredentialProviders( + input, + sandboxCreateIntentResolver.prepareCredentialProviders, + ); function getRecordedMessagingChannelsForResume( resume: boolean, @@ -3094,605 +2809,689 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { collector: null, span: null, }; - let completed = false, returnedNormally = false; + let completed = false, + returnedNormally = false; try { await portableRetirementEntry.run(async () => { - const lockedRuntime = await resumeRuntime.prepare(opts, resume, isNonInteractive(), onboardSession.loadSession); - portableEnvScope = lockedRuntime.environmentScope; - entryDecisions.clearGatewayEnvironmentWithoutBinding(authoritativeGateway, process.env); - preparedDcodeRuntime.applyGatewayEnv(process.env); - if (isNonInteractive() && !validatePolicyTierBeforeRuntime) validatePolicyTierEnvEarly(); - // Validate provider/model hints only after the locked profile and runtime authority are active. - const stationSessionInput = onboardEntryOptions.prepareSessionInput( - runtimeControlRequests, - requestedSandboxName, - resume, - () => - resumeConfig.preflightEarlyOnboardEnvForResume( - isNonInteractive(), - opts.authoritativeResumeConfig === true, - ), - ); - const onboardingComputePlan = dockerDriverPlatform.resolveCurrentOpenShellComputePlan(); - entryDecisions.applyGatewayBindingIfPresent(authoritativeGateway, (binding) => { - GATEWAY_NAME = binding.name; - GATEWAY_PORT = binding.port; - process.env.OPENSHELL_GATEWAY = binding.name; - }); - onboardTrace = onboardTracing.startOnboardTrace(opts, process.env); - let selectedMessagingChannels: string[] = []; - let { session, fromDockerfile } = await onboardSessionBootstrap.prepareOnboardSessionValidated( - { + const lockedRuntime = await resumeRuntime.prepare( + opts, resume, - fresh, - requestedFromDockerfile, + isNonInteractive(), + onboardSession.loadSession, + ); + portableEnvScope = lockedRuntime.environmentScope; + entryDecisions.clearGatewayEnvironmentWithoutBinding(authoritativeGateway, process.env); + preparedDcodeRuntime.applyGatewayEnv(process.env); + if (isNonInteractive() && !validatePolicyTierBeforeRuntime) validatePolicyTierEnvEarly(); + // Validate provider/model hints only after the locked profile and runtime authority are active. + const stationSessionInput = onboardEntryOptions.prepareSessionInput( + runtimeControlRequests, requestedSandboxName, - cannotPrompt, - nonInteractive: isNonInteractive(), - authoritativeResumeConfig: opts.authoritativeResumeConfig === true, - servingProfileProvenance: opts.servingProfileProvenance ?? null, - checkpointProfile: lockedRuntime.checkpointProfile, - portableRuntimeAuthority: lockedRuntime.preparedPortableAuthority, - agentFlag: opts.agent || null, - envAgent: process.env.NEMOCLAW_AGENT || null, - requestedHostMounts: opts.hostMounts, - ...stationSessionInput, - }, - { - loadSession: onboardSession.loadSession, - clearSession: onboardSession.clearSession, - createSession: onboardSession.createSession, - saveSession: onboardSession.saveSession, - updateSession: onboardSession.updateSession, - applySessionRecovery, - setOnboardBrandingAgent, - getResumeConfigConflicts, - recordResumeConflict: (conflict) => onboardRuntimeBoundary.recordResumeConflict(conflict), - resolvePath: path.resolve, - cliName, - error: (message) => console.error(message), - exitProcess: (code) => process.exit(code), - }, - ); - stagedLegacyValues.clear(); - migratedLegacyKeys.clear(); - stagedLegacyKeys = stageLegacyCredentialsToEnv(); - for (const key of stagedLegacyKeys) { - const value = process.env[key]; - if (value) stagedLegacyValues.set(key, value); - } - if (resume) { - const persistedHashes = session?.migratedLegacyValueHashes ?? {}; - for (const [key, hash] of Object.entries(persistedHashes)) { - if (typeof key !== "string" || typeof hash !== "string") continue; - const currentValue = stagedLegacyValues.get(key); - if (currentValue === undefined || legacyValueHash(currentValue) !== hash) continue; - migratedLegacyKeys.add(key); - } - } - if (stagedLegacyKeys.length > 0) { - console.error( - ` Staged ${String(stagedLegacyKeys.length)} legacy credential(s) for migration to the OpenShell gateway.`, + resume, + () => + resumeConfig.preflightEarlyOnboardEnvForResume( + isNonInteractive(), + opts.authoritativeResumeConfig === true, + ), ); - } - const effectiveHostMounts = hostMountScope.activate(session?.metadata.hostMounts); - await onboardRuntimeBoundary.recordOnboardStarted(resume); - // Resume backstop: a session may exist without a sandboxName if sandbox - // creation failed before that step. Non-interactive --from cannot infer a - // safe name in that state. - if ( - resume && - cannotPrompt && - fromDockerfile && - !requestedSandboxName && - !session?.sandboxName - ) { - console.error( - " --from requires --name (or NEMOCLAW_SANDBOX_NAME) when running without a TTY or with --non-interactive.", + const onboardingComputePlan = dockerDriverPlatform.resolveCurrentOpenShellComputePlan(); + entryDecisions.applyGatewayBindingIfPresent(authoritativeGateway, (binding) => { + GATEWAY_NAME = binding.name; + GATEWAY_PORT = binding.port; + process.env.OPENSHELL_GATEWAY = binding.name; + }); + onboardTrace = onboardTracing.startOnboardTrace(opts, process.env); + let selectedMessagingChannels: string[] = []; + let { session, fromDockerfile } = + await onboardSessionBootstrap.prepareOnboardSessionValidated( + { + resume, + fresh, + requestedFromDockerfile, + requestedSandboxName, + cannotPrompt, + nonInteractive: isNonInteractive(), + authoritativeResumeConfig: opts.authoritativeResumeConfig === true, + servingProfileProvenance: opts.servingProfileProvenance ?? null, + checkpointProfile: lockedRuntime.checkpointProfile, + portableRuntimeAuthority: lockedRuntime.portableRuntimeContext?.authority ?? null, + agentFlag: opts.agent || null, + envAgent: process.env.NEMOCLAW_AGENT || null, + requestedHostMounts: opts.hostMounts, + ...stationSessionInput, + }, + { + loadSession: onboardSession.loadSession, + clearSession: onboardSession.clearSession, + createSession: onboardSession.createSession, + saveSession: onboardSession.saveSession, + updateSession: onboardSession.updateSession, + applySessionRecovery, + setOnboardBrandingAgent, + getResumeConfigConflicts, + recordResumeConflict: (conflict) => + onboardRuntimeBoundary.recordResumeConflict(conflict), + resolvePath: path.resolve, + cliName, + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + }, + ); + stagedLegacyValues.clear(); + migratedLegacyKeys.clear(); + stagedLegacyKeys = stageLegacyCredentialsToEnv(); + for (const key of stagedLegacyKeys) { + const value = process.env[key]; + if (value) stagedLegacyValues.set(key, value); + } + if (resume) { + const persistedHashes = session?.migratedLegacyValueHashes ?? {}; + for (const [key, hash] of Object.entries(persistedHashes)) { + if (typeof key !== "string" || typeof hash !== "string") continue; + const currentValue = stagedLegacyValues.get(key); + if (currentValue === undefined || legacyValueHash(currentValue) !== hash) continue; + migratedLegacyKeys.add(key); + } + } + if (stagedLegacyKeys.length > 0) { + console.error( + ` Staged ${String(stagedLegacyKeys.length)} legacy credential(s) for migration to the OpenShell gateway.`, + ); + } + const effectiveHostMounts = hostMountScope.activate(session?.metadata.hostMounts); + await onboardRuntimeBoundary.recordOnboardStarted(resume); + // Resume backstop: a session may exist without a sandboxName if sandbox + // creation failed before that step. Non-interactive --from cannot infer a + // safe name in that state. + if ( + resume && + cannotPrompt && + fromDockerfile && + !requestedSandboxName && + !session?.sandboxName + ) { + console.error( + " --from requires --name (or NEMOCLAW_SANDBOX_NAME) when running without a TTY or with --non-interactive.", + ); + console.error( + " The resumed session has no recorded sandbox name, so one cannot be inferred.", + ); + process.exit(1); + } + + registerIncompleteOnboardExitHandlerForSession( + onboardSession, + () => completed || returnedNormally, ); - console.error( - " The resumed session has no recorded sandbox name, so one cannot be inferred.", + const agent = await selectOnboardAgent({ + agentFlag: opts.agent, + session, + resume, + canPrompt: !cannotPrompt, + }); + const recordedSandboxName = + session?.steps?.sandbox?.status === "complete" ? session?.sandboxName || null : null; + const checkpointedSandboxName = onboardSessionBootstrap.getCheckpointedSandboxName( + resume, + agent, + session, ); - process.exit(1); - } - - registerIncompleteOnboardExitHandlerForSession(onboardSession, () => completed || returnedNormally); - const agent = await selectOnboardAgent({ - agentFlag: opts.agent, - session, - resume, - canPrompt: !cannotPrompt, - }); - const recordedSandboxName = - session?.steps?.sandbox?.status === "complete" ? session?.sandboxName || null : null; - const checkpointedSandboxName = onboardSessionBootstrap.getCheckpointedSandboxName(resume, agent, session); - const gatewaySandboxName = entryDecisions.selectResumeSandboxName( - resume, - recordedSandboxName, - requestedSandboxName, - checkpointedSandboxName, - ); - const onboardGateway = gatewayBinding.resolveCoreOnboardGatewayBinding({ - authoritativeGateway, - currentGateway: { name: GATEWAY_NAME, port: GATEWAY_PORT }, - resume, - sandbox: entryDecisions.readSandboxForGatewayBinding(gatewaySandboxName, registry.getSandbox), - }); - ({ name: GATEWAY_NAME, port: GATEWAY_PORT } = onboardGateway); - process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; - const resolvedGatewayOwner = getGatewayOwner(); - let checkpointedGatewayOwner = resolvedGatewayOwner; - session = onboardSession.updateSession((currentSession) => { - checkpointedGatewayOwner = gatewayAuthorityCheckpoint.bindGatewayAuthorityToCheckpoint( - currentSession, - resolvedGatewayOwner, + const gatewaySandboxName = entryDecisions.selectResumeSandboxName( + resume, + recordedSandboxName, + requestedSandboxName, + checkpointedSandboxName, ); - }); - bindGatewayOwner(checkpointedGatewayOwner); - const selectedAgentTransition = runtimeControlFlow.planSelectedAgentTransition({ - resume, - session, - selectedAgentName: agent?.name, - routerPort: loadBlueprintProfile("routed")?.router.port || 4000, - note, - }); - setOnboardBrandingAgent(agent?.name || "openclaw"); - session = selectedAgentTransition.session; - const resumeAgentChanged = selectedAgentTransition.resumeAgentChanged; - const forceProviderSelectionForAgentChange = resumeAgentChanged; - console.log(""); - console.log(` ${cliDisplayName()} Onboarding`); - if (isNonInteractive()) note(" (non-interactive mode)"); - if (resume) note(" (resume mode)"); - console.log(" ==================="); - onboardSessionBootstrap.reportReadOnlyHostMounts(effectiveHostMounts, note); - const explicitSandboxGpuFlag = resolveSandboxGpuFlagFromOptions(opts); - const recordedGpuPassthroughBeforePreflight = session?.gpuPassthrough === true; - type InitialOnboardFlowContext = - import("./onboard/machine/initial-flow-composition").InitialOnboardFlowContext< - typeof agent, - ReturnType, - ReturnType - >; - const initialFlowContext: InitialOnboardFlowContext = { - resume, - fresh, - session, - agent, - recordedSandboxName, - requestedSandboxName, - sandboxName: recordedSandboxName || requestedSandboxName || checkpointedSandboxName || null, - fromDockerfile, - model: session?.model || null, - provider: session?.provider || null, - endpointUrl: session?.endpointUrl || null, - credentialEnv: session?.credentialEnv || null, - hermesAuthMethod: normalizeHermesAuthMethod(session?.hermesAuthMethod), - hermesToolGateways: normalizeHermesToolGatewaySelections(session?.hermesToolGateways), - preferredInferenceApi: session?.preferredInferenceApi || null, - ...reasoningMode.getCompatibleEndpointReasoningSessionState(session), - nimContainer: session?.nimContainer || null, - webSearchConfig: session?.webSearchConfig || null, - webSearchSupported: false, - selectedMessagingChannels, - gpu: null, - sandboxGpuConfig: null, - gpuPassthrough: false, - resumeHasResolvedGpuIntent: false, - requestedGpuPassthrough: opts.gpu === true, - }; - - const [preflightPhase, gatewayPhase]: readonly [ - import("./onboard/machine/sequence-runner").OnboardSequencePhase, - import("./onboard/machine/sequence-runner").OnboardSequencePhase, - ] = createInitialOnboardFlowPhases({ - explicitSandboxGpuFlag, - sandboxGpuDevice: opts.sandboxGpuDevice ?? null, - gpuRequested: opts.gpu === true, - noGpu: opts.noGpu === true, - allowDeferredN1xManagedVllm: opts.allowDeferredN1xManagedVllm, - env: process.env, - recordedGpuPassthroughBeforePreflight, - commitSelectedAgentTransition: selectedAgentTransition.commit, - ensureResumePreflightDashboardPortAvailable: () => { - if (_preflightDashboardPort === null) preflightDashboardPortRangeAvailability(); - }, - preflightDeps: { - getSandbox: registry.getSandbox.bind(registry), - getResumeSandboxGpuOverrides, - detectGpuForReadiness: () => nim.detectGpu({ proveArm64WslDockerDesktopGpu: null }), - detectGpu: nim.detectGpu, - runPreflight: (preflightOptions) => preflight({ ...opts, ...preflightOptions }), - assessHost, - assertOnboardHostReadiness: (host, gpu, options) => - fatalRuntimePreflight.assertOnboardHostReadiness(host, gpu ?? null, { - ...options, - allowStorageRemediation: !isGatewayExternallySupervised(), - }), - assertDockerBridgeAndContainerDnsHealthy, - resolveSandboxGpuConfig, - validateSandboxGpuPreflight, - skippedStepMessage, - recordStateSkipped, - startRecordedStep, - recordStepComplete, - updateSession: onboardSession.updateSession, - }, - getInitialGatewayReuseState: () => - selectNamedGatewayForReuseIfNeeded(getGatewayReuseSnapshot()).gatewayReuseState, - assertGatewayReadiness: async () => { - await onboardPreflightGatewayAuthority.collectGatewayReadiness(); - }, - gatewayName: GATEWAY_NAME, - recreateSandbox: isRecreateSandbox, - requiresBindMounts: effectiveHostMounts.length > 0, - gatewayDeps: { - ...machineGatewayOwnerDeps, - refreshDockerDriverGatewayReuseState, - gatewayCliSupportsLifecycleCommands: () => - gatewayCliSupportsLifecycleCommands(runCaptureOpenshell), - waitForGatewayHttpReady, - recoverGatewayRuntime, - getGatewayLocalEndpoint, - stopDashboardForward: () => bestEffortForwardStop(runOpenshell, getOnboardDashboardPort()), - destroyGateway, - getGatewayClusterImageDrift, - stopAllDashboardForwards, - reconcileGatewayGpuReuseForGpuIntent, - isLinuxDockerDriverGatewayEnabled, - retireLegacyGatewayForDockerDriverUpgrade, - destroyGatewayRuntimeForGpuReuse: () => - destroyGateway( - () => undefined, - () => false, - ), - skippedStepMessage, - recordStateSkipped, + const onboardGateway = gatewayBinding.resolveCoreOnboardGatewayBinding({ + authoritativeGateway, + currentGateway: { name: GATEWAY_NAME, port: GATEWAY_PORT }, + resume, + sandbox: entryDecisions.readSandboxForGatewayBinding( + gatewaySandboxName, + registry.getSandbox, + ), + }); + ({ name: GATEWAY_NAME, port: GATEWAY_PORT } = onboardGateway); + process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; + const resolvedGatewayOwner = getGatewayOwner(); + let checkpointedGatewayOwner = resolvedGatewayOwner; + session = onboardSession.updateSession((currentSession) => { + checkpointedGatewayOwner = gatewayAuthorityCheckpoint.bindGatewayAuthorityToCheckpoint( + currentSession, + resolvedGatewayOwner, + ); + }); + bindGatewayOwner(checkpointedGatewayOwner); + const selectedAgentTransition = runtimeControlFlow.planSelectedAgentTransition({ + resume, + session, + selectedAgentName: agent?.name, + routerPort: loadBlueprintProfile("routed")?.router.port || 4000, note, - startRecordedStep, - startGateway, - recordStepComplete, - exitProcess: (code) => process.exit(code), - }, - note, - }); - const initialFlowResult = await runInitialOnboardFlowSlice({ - context: initialFlowContext, - runtime: onboardRuntimeBoundary.getRuntime(), - phases: [preflightPhase, gatewayPhase], - resume, - recordRepairEvent, - }); + }); + setOnboardBrandingAgent(agent?.name || "openclaw"); + session = selectedAgentTransition.session; + const resumeAgentChanged = selectedAgentTransition.resumeAgentChanged; + const forceProviderSelectionForAgentChange = resumeAgentChanged; + console.log(""); + console.log(` ${cliDisplayName()} Onboarding`); + if (isNonInteractive()) note(" (non-interactive mode)"); + if (resume) note(" (resume mode)"); + console.log(" ==================="); + onboardSessionBootstrap.reportReadOnlyHostMounts(effectiveHostMounts, note); + const explicitSandboxGpuFlag = resolveSandboxGpuFlagFromOptions(opts); + const recordedGpuPassthroughBeforePreflight = session?.gpuPassthrough === true; + type InitialOnboardFlowContext = + import("./onboard/machine/initial-flow-composition").InitialOnboardFlowContext< + typeof agent, + ReturnType, + ReturnType + >; + const initialFlowContext: InitialOnboardFlowContext = { + resume, + fresh, + session, + agent, + recordedSandboxName, + requestedSandboxName, + sandboxName: recordedSandboxName || requestedSandboxName || checkpointedSandboxName || null, + fromDockerfile, + model: session?.model || null, + provider: session?.provider || null, + endpointUrl: session?.endpointUrl || null, + credentialEnv: session?.credentialEnv || null, + hermesAuthMethod: normalizeHermesAuthMethod(session?.hermesAuthMethod), + hermesToolGateways: normalizeHermesToolGatewaySelections(session?.hermesToolGateways), + preferredInferenceApi: session?.preferredInferenceApi || null, + ...reasoningMode.getCompatibleEndpointReasoningSessionState(session), + nimContainer: session?.nimContainer || null, + webSearchConfig: session?.webSearchConfig || null, + webSearchSupported: false, + selectedMessagingChannels, + gpu: null, + sandboxGpuConfig: null, + gpuPassthrough: false, + resumeHasResolvedGpuIntent: false, + requestedGpuPassthrough: opts.gpu === true, + }; - // #2753: for an unfinished sandbox, an explicit requested name precedes - // the checkpointed name from the interrupted session. - const coreFlowContext = prepareCoreOnboardFlowContext({ - initial: initialFlowResult, - recordedSandboxName, - requestedSandboxName, - checkpointedSandboxName, - selectedMessagingChannels, - assertSandboxNameAllowed: onboardEntryOptions.assertDefaultSandboxNameAllowed, - }); - const runCoreGatewayOpenshell = setupInferenceFactory.createGatewayScopedOpenshellRunner( - runOpenshell, - GATEWAY_NAME, - ); - const endpointProvenance = { endpointSource: opts.endpointSource, endpointSourceProvider: opts.rebuildRegistryInferenceRoute?.route.provider ?? null, endpointSourceEndpointUrl: opts.rebuildRegistryInferenceRoute?.route.endpointUrl ?? null, getSandboxRegistryEntry: registry.getSandbox }; - const providerReviewDeps = setupInferenceFactory.createDefaultProviderReviewDeps( - onboardSession.updateSession, - onboardSessionBootstrap.checkpointSandboxName, - ); - const coreFlowPhases = createCoreOnboardFlowPhases< - InitialOnboardFlowContext, - unknown, - MessagingChannelConfig, - import("./resources-cmd").ResourceProfile - >({ - resumeProvider: { isNonInteractive, isRoutedInferenceProvider, providerExistsInGateway, replaceNamedCredential, resumeManagedLlamaCppRuntime: (sandboxName) => setupNimFlow.resumeManagedLlamaCppRuntime(sandboxName, { gatewayPort: GATEWAY_PORT, runtimeProvider: setupNimFlow.resolveCurrentRuntimeProviderBundle() }) }, - providerInference: { - gatewayName: GATEWAY_NAME, - forceProviderSelection: forceProviderSelectionForAgentChange, - ...authoritativeRebuildTarget.rebuildProviderFlowOptions(opts, coreFlowContext), - endpointProvenance, + const [preflightPhase, gatewayPhase]: readonly [ + import("./onboard/machine/sequence-runner").OnboardSequencePhase, + import("./onboard/machine/sequence-runner").OnboardSequencePhase, + ] = createInitialOnboardFlowPhases({ + explicitSandboxGpuFlag, + sandboxGpuDevice: opts.sandboxGpuDevice ?? null, + gpuRequested: opts.gpu === true, + noGpu: opts.noGpu === true, + allowDeferredN1xManagedVllm: opts.allowDeferredN1xManagedVllm, env: process.env, - constants: { - hermesProviderName: hermesProviderAuth.HERMES_PROVIDER_NAME, - hermesApiKeyAuthMethod: HERMES_AUTH_METHOD_API_KEY, - hermesApiKeyCredentialEnv: HERMES_NOUS_API_KEY_CREDENTIAL_ENV, + recordedGpuPassthroughBeforePreflight, + commitSelectedAgentTransition: selectedAgentTransition.commit, + ensureResumePreflightDashboardPortAvailable: () => { + if (_preflightDashboardPort === null) preflightDashboardPortRangeAvailability(); }, - deps: { - checkGatewayRouteCompatibility, - preflightGatewayRouteDiscovery, - getSandboxRecoveryAuthority: providerRecovery.getSandboxRecoveryAuthority, - withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock, - normalizeHermesAuthMethod, - setupNim: (g, s, a, recover, gateway, assertRouteCompatible, canProbeRoute, recoverySessionId) => setupNim(g, s, a, recover, opts.rebuildRegistryInferenceRoute, gateway, assertRouteCompatible, canProbeRoute, recoverySessionId), - setupInference, resolveHostLocalInferenceStartupSelection: () => null, + preflightDeps: { + getSandbox: registry.getSandbox.bind(registry), + getResumeSandboxGpuOverrides, + detectGpuForReadiness: () => nim.detectGpu({ proveArm64WslDockerDesktopGpu: null }), + detectGpu: nim.detectGpu, + runPreflight: (preflightOptions) => preflight({ ...opts, ...preflightOptions }), + assessHost, + assertOnboardHostReadiness: (host, gpu, options) => + fatalRuntimePreflight.assertOnboardHostReadiness(host, gpu ?? null, { + ...options, + allowStorageRemediation: !isGatewayExternallySupervised(), + }), + assertDockerBridgeAndContainerDnsHealthy, + resolveSandboxGpuConfig, + validateSandboxGpuPreflight, + skippedStepMessage, + recordStateSkipped, startRecordedStep, recordStepComplete, - recordStepRejected, - toSessionUpdates: (updates) => - toSessionUpdates(updates as Parameters[0]), + updateSession: onboardSession.updateSession, + }, + getInitialGatewayReuseState: () => + selectNamedGatewayForReuseIfNeeded(getGatewayReuseSnapshot()).gatewayReuseState, + assertGatewayReadiness: async () => { + await onboardPreflightGatewayAuthority.collectGatewayReadiness(); + }, + gatewayName: GATEWAY_NAME, + recreateSandbox: isRecreateSandbox, + requiresBindMounts: effectiveHostMounts.length > 0, + gatewayDeps: { + ...machineGatewayOwnerDeps, + refreshDockerDriverGatewayReuseState, + gatewayCliSupportsLifecycleCommands: () => + gatewayCliSupportsLifecycleCommands(runCaptureOpenshell), + waitForGatewayHttpReady, + recoverGatewayRuntime, + getGatewayLocalEndpoint, + stopDashboardForward: () => + bestEffortForwardStop(runOpenshell, getOnboardDashboardPort()), + destroyGateway, + getGatewayClusterImageDrift, + stopAllDashboardForwards, + reconcileGatewayGpuReuseForGpuIntent, + isLinuxDockerDriverGatewayEnabled, + retireLegacyGatewayForDockerDriverUpgrade, + destroyGatewayRuntimeForGpuReuse: () => + destroyGateway( + () => undefined, + () => false, + ), skippedStepMessage, recordStateSkipped, - recordRepairEvent, - hydrateCredentialEnv, - ...reasoningMode.compatibleEndpointReasoningConfigureDeps, - ...reasoningMode.compatibleEndpointReasoningClearDeps, - repairLocalInferenceSystemdOverrideOrExit, + note, + startRecordedStep, + startGateway, + recordStepComplete, + exitProcess: (code) => process.exit(code), + }, + note, + }); + const initialFlowResult = await runInitialOnboardFlowSlice({ + context: initialFlowContext, + runtime: onboardRuntimeBoundary.getRuntime(), + phases: [preflightPhase, gatewayPhase], + resume, + recordRepairEvent, + }); + + // #2753: for an unfinished sandbox, an explicit requested name precedes + // the checkpointed name from the interrupted session. + const coreFlowContext = prepareCoreOnboardFlowContext({ + initial: initialFlowResult, + recordedSandboxName, + requestedSandboxName, + checkpointedSandboxName, + selectedMessagingChannels, + assertSandboxNameAllowed: onboardEntryOptions.assertDefaultSandboxNameAllowed, + }); + const runCoreGatewayOpenshell = setupInferenceFactory.createGatewayScopedOpenshellRunner( + runOpenshell, + GATEWAY_NAME, + ); + const endpointProvenance = { + endpointSource: opts.endpointSource, + endpointSourceProvider: opts.rebuildRegistryInferenceRoute?.route.provider ?? null, + endpointSourceEndpointUrl: opts.rebuildRegistryInferenceRoute?.route.endpointUrl ?? null, + getSandboxRegistryEntry: registry.getSandbox, + }; + const providerReviewDeps = setupInferenceFactory.createDefaultProviderReviewDeps( + onboardSession.updateSession, + onboardSessionBootstrap.checkpointSandboxName, + ); + const coreFlowPhases = createCoreOnboardFlowPhases< + InitialOnboardFlowContext, + unknown, + MessagingChannelConfig, + import("./resources-cmd").ResourceProfile + >({ + resumeProvider: { isNonInteractive, - getOpenshellBinary, - needsBedrockRuntimeAdapter: (providerName, url) => providerName === "compatible-anthropic-endpoint" && bedrockRuntimeOnboard.needsBedrockRuntimeAdapter(url), - isInferenceRouteReady, isRoutedInferenceProvider, - reconcileModelRouter, - reupsertRoutedProvider: (gatewayName, p, url, ce) => { - const r = routedInference.upsertRoutedProvider(p, url, ce, { - upsertProvider: setupInferenceFactory.bindGatewayUpsertProvider(upsertProvider, gatewayName), - hydrateCredentialEnv, - }); - return { - ok: r.ok, - endpointUrl: r.endpointUrl, - message: r.result.message, - status: r.result.status, - }; + providerExistsInGateway, + replaceNamedCredential, + resumeManagedLlamaCppRuntime: (sandboxName) => + setupNimFlow.resumeManagedLlamaCppRuntime(sandboxName, { + gatewayPort: GATEWAY_PORT, + runtimeProvider: setupNimFlow.resolveCurrentRuntimeProviderBundle(), + }), + }, + providerInference: { + gatewayName: GATEWAY_NAME, + forceProviderSelection: forceProviderSelectionForAgentChange, + ...authoritativeRebuildTarget.rebuildProviderFlowOptions(opts, coreFlowContext), + endpointProvenance, + env: process.env, + constants: { + hermesProviderName: hermesProviderAuth.HERMES_PROVIDER_NAME, + hermesApiKeyAuthMethod: HERMES_AUTH_METHOD_API_KEY, + hermesApiKeyCredentialEnv: HERMES_NOUS_API_KEY_CREDENTIAL_ENV, }, - reserveSandboxInferenceRoute: registry.reserveSandboxInferenceRoute, - registryUpdateSandbox: (name, updates) => registry.updateSandbox(name, updates), - ...providerReviewDeps, - promptValidatedSandboxName, - assessHost, - formatSandboxBuildEstimateNote, - formatOnboardConfigSummary, - prompt, - cliName, - log: (message) => console.log(message), - error: (message) => console.error(message), - exitProcess: (code) => process.exit(code), - deleteEnv: (name) => { - delete process.env[name]; + deps: { + checkGatewayRouteCompatibility, + preflightGatewayRouteDiscovery, + getSandboxRecoveryAuthority: providerRecovery.getSandboxRecoveryAuthority, + withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock, + normalizeHermesAuthMethod, + setupNim: ( + g, + s, + a, + recover, + gateway, + assertRouteCompatible, + canProbeRoute, + recoverySessionId, + ) => + setupNim( + g, + s, + a, + recover, + opts.rebuildRegistryInferenceRoute, + gateway, + assertRouteCompatible, + canProbeRoute, + recoverySessionId, + ), + setupInference, + resolveHostLocalInferenceStartupSelection: () => null, + startRecordedStep, + recordStepComplete, + recordStepRejected, + toSessionUpdates: (updates) => + toSessionUpdates(updates as Parameters[0]), + skippedStepMessage, + recordStateSkipped, + recordRepairEvent, + hydrateCredentialEnv, + ...reasoningMode.compatibleEndpointReasoningConfigureDeps, + ...reasoningMode.compatibleEndpointReasoningClearDeps, + repairLocalInferenceSystemdOverrideOrExit, + isNonInteractive, + getOpenshellBinary, + needsBedrockRuntimeAdapter: (providerName, url) => + providerName === "compatible-anthropic-endpoint" && + bedrockRuntimeOnboard.needsBedrockRuntimeAdapter(url), + isInferenceRouteReady, + isRoutedInferenceProvider, + reconcileModelRouter, + reupsertRoutedProvider: (gatewayName, p, url, ce) => { + const r = routedInference.upsertRoutedProvider(p, url, ce, { + upsertProvider: setupInferenceFactory.bindGatewayUpsertProvider( + upsertProvider, + gatewayName, + ), + hydrateCredentialEnv, + }); + return { + ok: r.ok, + endpointUrl: r.endpointUrl, + message: r.result.message, + status: r.result.status, + }; + }, + reserveSandboxInferenceRoute: registry.reserveSandboxInferenceRoute, + registryUpdateSandbox: (name, updates) => registry.updateSandbox(name, updates), + ...providerReviewDeps, + promptValidatedSandboxName, + assessHost, + formatSandboxBuildEstimateNote, + formatOnboardConfigSummary, + prompt, + cliName, + log: (message) => console.log(message), + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + deleteEnv: (name) => { + delete process.env[name]; + }, }, }, - }, - sandbox: { - gatewayName: GATEWAY_NAME, - authoritativeResumeConfig: opts.authoritativeResumeConfig === true, - authoritativePolicyTier: - opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : undefined, - recreateJournalTargetIntentFingerprint: opts.recreateJournalTargetIntentFingerprint ?? null, - resumeAgentChanged, - requestedObservabilityEnabled: runtimeControlRequests.requestedObservabilityEnabled, - requestedDcodeAutoApprovalMode: runtimeControlRequests.requestedDcodeAutoApprovalMode, - rebuildPreservedEnv: opts.rebuildPreservedEnv, - hostMounts: effectiveHostMounts, - endpointProvenance, - recreateSandbox: isRecreateSandbox, - controlUiPort: _preflightDashboardPort, - rootDir: ROOT, - env: process.env, - deps: { - checkGatewayRouteCompatibility, - withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock, - resolvePath: preparedDcodeRuntime.resolveDockerfileProbePath, - agentSupportsWebSearch, - agentSupportsWebSearchProvider, - ...{ note, cliName }, - updateSession: onboardSession.updateSession, - getStoredMessagingChannelConfig, - hydrateMessagingChannelConfig, - messagingChannelConfigsEqual, - getSandboxReuseState, - getSandboxRecreateObservation, - getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => - getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { - runCaptureOpenshell, + sandbox: { + gatewayName: GATEWAY_NAME, + hermesPortableLifecycle: + lockedRuntime.portableRuntimeContext !== null && agent?.name === "hermes", + authoritativeResumeConfig: opts.authoritativeResumeConfig === true, + authoritativePolicyTier: + opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : undefined, + recreateJournalTargetIntentFingerprint: + opts.recreateJournalTargetIntentFingerprint ?? null, + resumeAgentChanged, + requestedObservabilityEnabled: runtimeControlRequests.requestedObservabilityEnabled, + requestedDcodeAutoApprovalMode: runtimeControlRequests.requestedDcodeAutoApprovalMode, + rebuildPreservedEnv: opts.rebuildPreservedEnv, + hostMounts: effectiveHostMounts, + endpointProvenance, + recreateSandbox: isRecreateSandbox, + controlUiPort: _preflightDashboardPort, + rootDir: ROOT, + env: process.env, + deps: { + checkGatewayRouteCompatibility, + withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock, + resolvePath: preparedDcodeRuntime.resolveDockerfileProbePath, + agentSupportsWebSearch, + agentSupportsWebSearchProvider, + ...{ note, cliName }, + updateSession: onboardSession.updateSession, + getStoredMessagingChannelConfig, + hydrateMessagingChannelConfig, + messagingChannelConfigsEqual, + getSandboxReuseState, + getSandboxRecreateObservation, + getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => + getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { + runCaptureOpenshell, + }), + hasSandboxGpuDrift, + getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways, + getSandboxRegistryEntry: registry.getSandbox, + normalizeHermesToolGatewaySelections, + stringSetsEqual, + removeSandboxFromRegistry: registry.removeSandboxWithReceipt.bind(registry), + restoreSandboxRegistryEntryIfMissing: + registry.restoreSandboxEntryIfMissing.bind(registry), + ensureValidatedWebSearchCredential, + isBackToSelection, + configureWebSearch, + startRecordedStep, + getRecordedMessagingChannelsForResume, + showMessagingStage: () => step(5, 8, "Messaging channels"), + setupMessagingChannels: messagingChannelSetup.createSetupMessagingChannels({ + step, + note, + isNonInteractive, + prompt, + googlechatTunnelRuntime: opts.googlechatTunnelRuntime, }), - hasSandboxGpuDrift, - getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways, - getSandboxRegistryEntry: registry.getSandbox, - normalizeHermesToolGatewaySelections, - stringSetsEqual, - removeSandboxFromRegistry: registry.removeSandboxWithReceipt.bind(registry), - restoreSandboxRegistryEntryIfMissing: - registry.restoreSandboxEntryIfMissing.bind(registry), - ensureValidatedWebSearchCredential, - isBackToSelection, - configureWebSearch, - startRecordedStep, - getRecordedMessagingChannelsForResume, - showMessagingStage: () => step(5, 8, "Messaging channels"), - setupMessagingChannels: messagingChannelSetup.createSetupMessagingChannels({ - step, - note, - isNonInteractive, - prompt, - googlechatTunnelRuntime: opts.googlechatTunnelRuntime, - }), - readMessagingPlanFromEnv: messagingChannelSetup.readMessagingPlanFromEnv, - writePlanToEnv: messagingChannelSetup.writePlanToEnv, - clearPlanEnv: messagingChannelSetup.clearPlanEnv, - getRegistrySandboxMessagingAuthority: - messagingChannelSetup.getRegistrySandboxMessagingAuthority, - providerMatchesGatewayCredential, - stageSandboxCredentialProviders, - promptValidatedSandboxName, - selectResourceProfileForSandbox: () => - selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), - listRegistrySandboxes: registry.listSandboxes, - planRegisteredExtraProviders: (gatewayName) => - planRegisteredExtraProviders(gatewayName, { runOpenshell }), - resolveSandboxCreateIntent: sandboxCreateIntentResolver.resolve, - createSandbox: preparedDcodeRuntime.bindCreateSandbox((...createArgs) => - withSandboxPortReservationScope((dashboardPortReservationScope) => - createSandboxWithBaseImageResolution( - baseImageResolutionContext, - lockedRuntime.preparedPortableAuthority, - onboardingComputePlan, - opts.managedWorkloadRebuild ?? null, - opts.tempManagedRuntime === true, - opts.tempManagedRuntimeCatalog ?? null, - dashboardPortReservationScope, - hermesApiPortReservationScope, - ...createArgs, + readMessagingPlanFromEnv: messagingChannelSetup.readMessagingPlanFromEnv, + writePlanToEnv: messagingChannelSetup.writePlanToEnv, + clearPlanEnv: messagingChannelSetup.clearPlanEnv, + getRegistrySandboxMessagingAuthority: + messagingChannelSetup.getRegistrySandboxMessagingAuthority, + providerMatchesGatewayCredential, + stageSandboxCredentialProviders, + promptValidatedSandboxName, + selectResourceProfileForSandbox: () => + selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), + listRegistrySandboxes: registry.listSandboxes, + planRegisteredExtraProviders: (gatewayName) => + planRegisteredExtraProviders(gatewayName, { runOpenshell }), + resolveSandboxCreateIntent: sandboxCreateIntentResolver.resolve, + createSandbox: preparedDcodeRuntime.bindCreateSandbox((...createArgs) => + withSandboxPortReservationScope((dashboardPortReservationScope) => + createSandboxWithBaseImageResolution( + baseImageResolutionContext, + lockedRuntime.portableRuntimeContext, + onboardingComputePlan, + opts.managedWorkloadRebuild ?? null, + opts.tempManagedRuntime === true, + opts.tempManagedRuntimeCatalog ?? null, + dashboardPortReservationScope, + hermesApiPortReservationScope, + ...createArgs, + ), ), ), - ), - updateSandboxRegistry: (name, updates) => registry.updateSandbox(name, updates), - getSandboxAgentRegistryFields, + updateSandboxRegistry: (name, updates) => registry.updateSandbox(name, updates), + getSandboxAgentRegistryFields, + recordStepComplete, + toSessionUpdates: (updates) => + toSessionUpdates(updates as Parameters[0]), + skippedStepMessage, + recordStateSkipped, + recordRepairEvent, + withSandboxMutationLock: sandboxMutationLock.withSandboxMutationLock, + error: (message) => console.error(message), + exitProcess: (code) => process.exit(code), + }, + }, + }); + const coreFlowResult = await runCoreOnboardFlowSlice({ + context: coreFlowContext, + runtime: onboardRuntimeBoundary.getRuntime(), + phases: coreFlowPhases, + resume, + recordRepairEvent, + }); + setupInferenceFactory.selectGatewayForFollowupOrExit(GATEWAY_NAME, runOpenshell); + const finalFlowContext = prepareFinalOnboardFlowContext(coreFlowResult); + let liveFinalFlowContext: InitialOnboardFlowContext = finalFlowContext; + const finalFlowPhases = createFinalOnboardFlowPhases< + InitialOnboardFlowContext, + import("./dashboard/contract").DashboardDeliveryChain, + import("./verify-deployment").VerifyDeploymentResult + >({ + branchState: agent ? "agent_setup" : "openclaw", + authoritativePolicyTier: + opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : undefined, + agentSetupDeps: { + handleAgentSetup: agentOnboard.handleAgentSetup, + agentSetupContext: () => ({ + ...{ step, runCaptureOpenshell, captureOpenshell }, + openshellShellCommand, + openshellBinary: getOpenshellBinary(), + buildSandboxConfigSyncScript, + writeSandboxConfigSyncFile, + cleanupTempDir, + startRecordedStep, + recordStepComplete, + recordStepFailed, + skippedStepMessage, + cuaRegistry: registry, + }), + ensureAgentDashboardForward: (name, selectedAgent) => + selectedAgent + ? ensureAgentDashboardForward(name, selectedAgent, { + beforeForwardPort: (port) => + hermesApiPortReservationScope.releaseBeforeForward(selectedAgent.name, port), + }) + : 0, + persistDashboardPort: (name, port) => + registry.updateSandbox(name, { dashboardPort: port }), + recordStepSkipped, + isOpenclawReady, + skippedStepMessage, + recordStateSkipped, + startRecordedStep, + setupOpenclaw, + syncNemoClawConfigInSandbox, recordStepComplete, toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), + }, + policiesDeps: { + loadSession: onboardSession.loadSession, + getActiveSandbox: (name) => registry.getSandbox(name), + mergePolicyMessagingChannels, + detectUnconfiguredMessagingChannels: + messagingChannelSetup.detectUnconfiguredMessagingChannels, + verifyCompatibleEndpointSandboxSmoke: (options) => + verifyCompatibleEndpointSandboxSmoke({ + ...options, + runOpenshell: runCoreGatewayOpenshell, + redact, + }), + preparePolicyPresetResumeSelection, + arePolicyPresetsApplied, skippedStepMessage, recordStateSkipped, - recordRepairEvent, - withSandboxMutationLock: sandboxMutationLock.withSandboxMutationLock, - error: (message) => console.error(message), - exitProcess: (code) => process.exit(code), - }, - }, - }); - const coreFlowResult = await runCoreOnboardFlowSlice({ - context: coreFlowContext, - runtime: onboardRuntimeBoundary.getRuntime(), - phases: coreFlowPhases, - resume, - recordRepairEvent, - }); - setupInferenceFactory.selectGatewayForFollowupOrExit(GATEWAY_NAME, runOpenshell); - const finalFlowContext = prepareFinalOnboardFlowContext(coreFlowResult); - let liveFinalFlowContext: InitialOnboardFlowContext = finalFlowContext; - const finalFlowPhases = createFinalOnboardFlowPhases< - InitialOnboardFlowContext, - import("./dashboard/contract").DashboardDeliveryChain, - import("./verify-deployment").VerifyDeploymentResult - >({ - branchState: agent ? "agent_setup" : "openclaw", - authoritativePolicyTier: - opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : undefined, - agentSetupDeps: { - handleAgentSetup: agentOnboard.handleAgentSetup, - agentSetupContext: () => ({ - ...{ step, runCaptureOpenshell, captureOpenshell }, - openshellShellCommand, - openshellBinary: getOpenshellBinary(), - buildSandboxConfigSyncScript, - writeSandboxConfigSyncFile, - cleanupTempDir, startRecordedStep, + setupPoliciesWithSelection, + updateSession: onboardSession.updateSession, recordStepComplete, - recordStepFailed, - skippedStepMessage, - cuaRegistry: registry, - }), - ensureAgentDashboardForward: (name, selectedAgent) => selectedAgent ? ensureAgentDashboardForward(name, selectedAgent, { beforeForwardPort: (port) => hermesApiPortReservationScope.releaseBeforeForward(selectedAgent.name, port) }) : 0, - persistDashboardPort: (name, port) => registry.updateSandbox(name, { dashboardPort: port }), - recordStepSkipped, - isOpenclawReady, - skippedStepMessage, - recordStateSkipped, - startRecordedStep, - setupOpenclaw, - syncNemoClawConfigInSandbox, - recordStepComplete, - toSessionUpdates: (updates) => - toSessionUpdates(updates as Parameters[0]), - }, - policiesDeps: { - loadSession: onboardSession.loadSession, - getActiveSandbox: (name) => registry.getSandbox(name), - mergePolicyMessagingChannels, - detectUnconfiguredMessagingChannels: - messagingChannelSetup.detectUnconfiguredMessagingChannels, - verifyCompatibleEndpointSandboxSmoke: (options) => - verifyCompatibleEndpointSandboxSmoke({ - ...options, - runOpenshell: runCoreGatewayOpenshell, - redact, - }), - preparePolicyPresetResumeSelection, - arePolicyPresetsApplied, - skippedStepMessage, - recordStateSkipped, - startRecordedStep, - setupPoliciesWithSelection, - updateSession: onboardSession.updateSession, - recordStepComplete, - toSessionUpdates: (updates) => - toSessionUpdates(updates as Parameters[0]), - persistAppliedPolicyPresets: policyPresetCarry.persistFinalizedPolicyPresets, - }, - finalization: { - stagedLegacyKeys, - migratedLegacyKeys, - webSearchEnabled: (config) => braveProviderProfile.shouldEnableBraveWebSearch(config), - webSearchProvider: (config) => webSearchProviderForConfig(config), - }, - finalizationDeps: { - ensureAgentDashboardForward: (name, selectedAgent) => selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : ensureFinalizationDashboardForward(name), - setDefaultSandbox: registry.setDefault, - verifyWebSearchInsideSandbox, - toSessionUpdates: (updates) => - toSessionUpdates(updates as Parameters[0]), - removeLegacyCredentialsFile, - cleanupStaleHostFiles, - getChatUiUrl: () => process.env.CHAT_UI_URL || `http://127.0.0.1:${DASHBOARD_PORT}`, - buildVerifyChain: (chatUiUrl, name) => buildAgentVerifyChain(chatUiUrl, name, agent), - verifyDeployment: async (name, chain) => { - const verifyDeploymentModule: typeof import("./verify-deployment") = - require("./verify-deployment"); - return verifyDeploymentModule.verifyDeployment(name, chain, { - executeSandboxCommand: (sandbox: string, script: string) => - executeSandboxCommandForVerification(sandbox, script), - probeHostPort: (port: number, probePath: string) => { - const result = runCapture( - [ - "curl", - "-so", - "/dev/null", - "-w", - "%{http_code}", - "--max-time", - "3", - `http://127.0.0.1:${port}${probePath}`, - ], - { ignoreError: true }, - ); - return parseInt(result.trim(), 10) || 0; - }, - captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, - getMessagingChannels: () => liveFinalFlowContext.selectedMessagingChannels || [], - providerExistsInGateway: (providerName: string) => providerExistsInGateway(providerName), - }, { diagnoseCustomOpenClawRuntime: verifyDeploymentModule.shouldDiagnoseCustomOpenClawRuntime(liveFinalFlowContext.fromDockerfile, agent?.name) }); + toSessionUpdates: (updates) => + toSessionUpdates(updates as Parameters[0]), + persistAppliedPolicyPresets: policyPresetCarry.persistFinalizedPolicyPresets, }, - formatVerificationDiagnostics: (result) => { - const verifyDeploymentModule: typeof import("./verify-deployment") = - require("./verify-deployment"); - return verifyDeploymentModule.formatVerificationDiagnostics(result); + finalization: { + stagedLegacyKeys, + migratedLegacyKeys, + webSearchEnabled: (config) => braveProviderProfile.shouldEnableBraveWebSearch(config), + webSearchProvider: (config) => webSearchProviderForConfig(config), }, - printDashboard, - error: (message) => console.error(message), - log: (message) => console.log(message), - }, - }); - const finalFlowResult = await runFinalOnboardFlowSlice({ - context: finalFlowContext, - runtime: onboardRuntimeBoundary.getRuntime(), - phases: finalFlowPhases, - recordRepairEvent, - afterPoliciesReady: () => { - sandboxCancelRollback.disarm(); - }, - onContextUpdated: (context) => { - liveFinalFlowContext = context; - }, - }); - completed = finalFlowResult.session.machine.state === "complete"; - if (completed && finalFlowResult.session.sandboxName) { - await portableRetirementEntry.supersede(lockedRuntime.checkpointProfile); - } - process.exitCode = completed ? 0 : 1; + finalizationDeps: { + ensureAgentDashboardForward: (name, selectedAgent) => + selectedAgent + ? ensureAgentDashboardForward(name, selectedAgent) + : ensureFinalizationDashboardForward(name), + setDefaultSandbox: registry.setDefault, + verifyWebSearchInsideSandbox, + toSessionUpdates: (updates) => + toSessionUpdates(updates as Parameters[0]), + removeLegacyCredentialsFile, + cleanupStaleHostFiles, + getChatUiUrl: () => process.env.CHAT_UI_URL || `http://127.0.0.1:${DASHBOARD_PORT}`, + buildVerifyChain: (chatUiUrl, name) => buildAgentVerifyChain(chatUiUrl, name, agent), + verifyDeployment: async (name, chain) => { + const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment"); + return verifyDeploymentModule.verifyDeployment( + name, + chain, + { + executeSandboxCommand: (sandbox: string, script: string) => + executeSandboxCommandForVerification(sandbox, script), + probeHostPort: (port: number, probePath: string) => { + const result = runCapture( + [ + "curl", + "-so", + "/dev/null", + "-w", + "%{http_code}", + "--max-time", + "3", + `http://127.0.0.1:${port}${probePath}`, + ], + { ignoreError: true }, + ); + return parseInt(result.trim(), 10) || 0; + }, + captureForwardList: () => + runCaptureOpenshell(["forward", "list"], { ignoreError: true }) || null, + getMessagingChannels: () => liveFinalFlowContext.selectedMessagingChannels || [], + providerExistsInGateway: (providerName: string) => + providerExistsInGateway(providerName), + }, + { + diagnoseCustomOpenClawRuntime: + verifyDeploymentModule.shouldDiagnoseCustomOpenClawRuntime( + liveFinalFlowContext.fromDockerfile, + agent?.name, + ), + }, + ); + }, + formatVerificationDiagnostics: (result) => { + const verifyDeploymentModule: typeof import("./verify-deployment") = require("./verify-deployment"); + return verifyDeploymentModule.formatVerificationDiagnostics(result); + }, + printDashboard, + error: (message) => console.error(message), + log: (message) => console.log(message), + }, + }); + const finalFlowResult = await runFinalOnboardFlowSlice({ + context: finalFlowContext, + runtime: onboardRuntimeBoundary.getRuntime(), + phases: finalFlowPhases, + recordRepairEvent, + afterPoliciesReady: () => { + sandboxCancelRollback.disarm(); + }, + onContextUpdated: (context) => { + liveFinalFlowContext = context; + }, + }); + completed = finalFlowResult.session.machine.state === "complete"; + if (completed && finalFlowResult.session.sandboxName) { + await portableRetirementEntry.supersede(lockedRuntime.checkpointProfile); + } + process.exitCode = completed ? 0 : 1; }); } finally { try { diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index b04f8f72f21..ad7ed9bef4d 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -8,9 +8,16 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SandboxEntry } from "../state/registry"; import * as sandboxState from "../state/sandbox"; -import { finalizeCreatedSandbox } from "./created-sandbox-finalization"; +import { + createCreatedSandboxCompletionActions, + finalizeCreatedSandbox, +} from "./created-sandbox-finalization"; import { getDcodeSelectionDrift } from "./dcode-selection-drift"; +import type { SandboxGpuCreateFlowResult } from "./sandbox-gpu-create-flow"; +import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; +import type { CreatedSandboxRegistrationInput } from "./sandbox-registration"; const fixtures: string[] = []; @@ -355,7 +362,9 @@ describe("created DCode sandbox finalization", () => { return sandboxState.restoreRecreatedSandboxState(name, backup, options); }, getDcodeSelectionDrift: vi.fn(), - register: () => registeredConfigs.push(fs.readFileSync(fixture.currentPath, "utf8")), + register: () => { + registeredConfigs.push(fs.readFileSync(fixture.currentPath, "utf8")); + }, note: vi.fn(), error: vi.fn(), exitProcess: (code): never => { @@ -419,7 +428,9 @@ describe("created OpenClaw sandbox finalization", () => { it("captures and registers a fresh image plugin baseline without a restore", () => { const order: string[] = []; const restoreRecreatedSandboxState = vi.fn(); - const register = vi.fn(() => order.push("register")); + const register = vi.fn(() => { + order.push("register"); + }); finalizeCreatedSandbox( { @@ -456,7 +467,9 @@ describe("created OpenClaw sandbox finalization", () => { it("preserves the fresh image plugin baseline across recreation before registration", () => { const order: string[] = []; - const register = vi.fn(() => order.push("register")); + const register = vi.fn(() => { + order.push("register"); + }); const restoreRecreatedSandboxState = vi.fn(() => { order.push("restore"); return { @@ -652,3 +665,187 @@ describe("created OpenClaw sandbox finalization", () => { expect(error).toHaveBeenCalledWith(" Manual recovery: /tmp/openclaw-backup"); }); }); + +describe("created sandbox completion actions", () => { + it.each([ + ["ordinary", true], + ["schema-5", false], + ] as const)( + "keeps %s dashboard completion ordered and bounded (#9203)", + async (_route, manageDashboard) => { + const order: string[] = []; + const gpuProof = { + status: "verified" as const, + cudaVerified: true, + at: "2026-08-17T00:00:00.000Z", + }; + const gpuConfig: SandboxGpuConfig = { + mode: "1" as const, + hostGpuDetected: true, + hostGpuPlatform: "linux" as const, + sandboxGpuEnabled: true, + sandboxGpuDevice: null, + errors: [], + }; + const registerCreatedSandbox = vi.fn((input: CreatedSandboxRegistrationInput) => { + order.push("registry"); + return input as unknown as SandboxEntry; + }); + const completion = createCreatedSandboxCompletionActions( + { + finalization: { + sandboxName: "hermes", + restoreBackupPath: null, + preUpgradeBackup: false, + targetAgentType: "hermes", + validateManagedDcode: false, + provider: "ollama", + model: "qwen3-vl:4b", + preferredInferenceApi: "openai-completions", + }, + registration: { + sandboxName: "hermes", + inferenceSelection: { + provider: "ollama", + model: "qwen3-vl:4b", + endpointUrl: null, + endpointSource: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + }, + runtimeFields: { + gpuEnabled: true, + hostGpuDetected: true, + sandboxGpuEnabled: true, + sandboxGpuMode: "1", + sandboxGpuDevice: null, + sandboxGpuProof: null, + openshellDriver: "docker", + openshellVersion: "0.0.101", + }, + agent: null, + agentVersionKnown: true, + appliedPolicies: ["personal-open-internet"], + plannedMessagingState: undefined, + hermesToolGateways: [], + gatewayName: "nemoclaw", + gatewayPort: 8080, + }, + gpu: { + config: gpuConfig, + provider: "ollama", + dockerDriverGateway: true, + verifyDirectSandboxGpu: () => { + order.push("gpu"); + return gpuProof; + }, + runCaptureOpenshell: vi.fn(), + }, + dashboard: { + chatUiUrl: "http://127.0.0.1:8643", + initialHermesState: { config: null, enabled: false }, + releasePort: async () => { + order.push("dashboard-release"); + }, + ensureForward: () => { + order.push("dashboard-forward"); + return 8644; + }, + getForwardPort: () => "8643", + resolveHermesState: () => ({ config: null, enabled: false }), + ensureHermesForward: () => order.push("dashboard-hermes"), + }, + workload: { + runtime: { + runtimeProvider: null, + ensurePreparedWorkload: vi.fn(), + ensurePreparedProfile: vi.fn(), + }, + workload: { + source: { + kind: "legacy-dockerfile", + dockerfilePath: "/workspace/Dockerfile", + reason: "agent-not-managed", + }, + release: null, + fallbackDiagnostic: null, + }, + prebuildImageRef: null, + buildId: "build-1", + extractBuiltImageRef: () => { + order.push("workload"); + return "hermes:test"; + }, + resolveSandboxImageTagFromCreateOutput: vi.fn(), + }, + }, + { + discoverFreshOpenClawImagePluginInstalls: vi.fn(), + restoreRecreatedSandboxState: vi.fn(), + getDcodeSelectionDrift: vi.fn(), + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`unexpected exit ${code}`); + }, + registerCreatedSandbox, + }, + ); + const created = { + createResult: { status: 0, output: "", sawProgress: true }, + route: "native", + firstCreateOutput: "", + registryImageRef: null, + lifecycleRegistrationFields: { lifecycleGeneration: "generation-1" }, + } as SandboxGpuCreateFlowResult; + const lifecycle = { + generation: "generation-1", + capture: () => { + order.push("lifecycle-capture"); + return { + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: "a".repeat(64), + }; + }, + revalidate: (registration: { + lifecycleGeneration: string; + lifecycleLiveIdentityFingerprint: string; + }) => { + order.push("lifecycle-revalidate"); + return registration; + }, + }; + + await completion.complete( + created, + null, + "hermes", + manageDashboard, + () => ({ lifecycleGeneration: "generation-1" }), + lifecycle, + ); + + expect(order).toEqual([ + "gpu", + ...(manageDashboard ? ["dashboard-release", "dashboard-forward", "dashboard-hermes"] : []), + "workload", + "lifecycle-capture", + "lifecycle-revalidate", + "registry", + ]); + expect(gpuConfig.sandboxGpuProof).toEqual(gpuProof); + expect(registerCreatedSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + imageTag: "hermes:test", + appliedPolicies: ["personal-open-internet"], + dashboardPort: manageDashboard ? 8644 : 0, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: "a".repeat(64), + }), + ); + }, + ); +}); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index c0efd29d416..f61a5de0d0d 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -1,17 +1,46 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + import type { OpenClawImagePluginInstall, OpenClawManagedExtensionDiscoveryResult, } from "../state/openclaw-plugin-restore"; +import * as openClawPluginRestore from "../state/openclaw-plugin-restore"; +import type { SandboxEntry, SandboxGpuProofResult } from "../state/registry"; +import type { QualifiedSandboxInferenceRouteReservation } from "../state/registry/route-reservation"; +import type { SandboxWorkloadReceipt } from "../state/registry/types"; import { MANAGED_SNAPSHOT_RESTORE_AUTHORITY_ERROR, OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR, type RecreatedSandboxRestoreOptions, type RestoreResult, } from "../state/sandbox"; +import * as sandboxState from "../state/sandbox"; +import * as buildContext from "../build-context"; +import { resolveSandboxImageTagFromCreateOutput } from "../domain/sandbox/image-tag"; +import { restoreDefaultAfterRecreate } from "./default-preservation"; +import { getDcodeSelectionDrift } from "./dcode-selection-drift"; +import * as dockerGpuLocalInference from "./docker-gpu-local-inference"; +import type { HermesDashboardOnboardState } from "./hermes-dashboard"; +import type { HermesPortableConfiguredReceipt } from "./experimental/hermes-portable-receipt"; +import { warnIfLandlockUnsupported } from "./landlock-warning"; +import * as managedWorkloadOnboard from "./managed-workload/onboard-orchestration"; +import { printMessagingProviderMissing } from "./preflight-messages"; +import type { SandboxGpuCreateFlowResult } from "./sandbox-gpu-create-flow"; import type { SelectionDrift } from "./selection-drift"; +import { applyOnboardVmDnsMonkeypatch } from "./vm-dns-monkeypatch"; +import { + creationFidelity, + registerCreatedSandbox, + selection, + type CreatedSandboxRegistrationInput, +} from "./sandbox-registration"; +import type { + CreatedSandboxLifecycle, + CreatedSandboxLifecycleRegistration, +} from "./sandbox-recreate-transaction"; export type CreatedSandboxFinalizationOptions = { sandboxName: string; @@ -41,17 +70,490 @@ export type CreatedSandboxFinalizationDeps = { model: string, preferredInferenceApi: string | null, ): SelectionDrift; - register(openclawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]): void; + register( + openclawImagePluginInstalls?: readonly OpenClawImagePluginInstall[], + ): SandboxEntry | void; note(message: string): void; error(message: string): void; exitProcess(code: number): never; }; +type WorkloadResolutionInput = Parameters< + typeof managedWorkloadOnboard.resolveOnboardSandboxWorkloadReceipt +>[0]; +type RegistrationSeed = Omit< + CreatedSandboxRegistrationInput, + | "imageTag" + | "workload" + | "openclawImagePluginInstalls" + | "hermesDashboardState" + | "dashboardPort" + | "lifecycleGeneration" + | "lifecycleLiveIdentityFingerprint" + | "inferenceRouteReservation" +>; + +export interface CreatedSandboxCompletionOptions { + readonly finalization: CreatedSandboxFinalizationOptions; + readonly registration: RegistrationSeed; + readonly gpu: { + readonly config: Parameters< + typeof dockerGpuLocalInference.verifyGpuSandboxLocalInferenceAndCommitAfterReady + >[0]; + readonly provider: string; + readonly dockerDriverGateway: boolean; + readonly verifyDirectSandboxGpu: (sandboxName: string) => SandboxGpuProofResult; + readonly runCaptureOpenshell: NonNullable< + Parameters< + typeof dockerGpuLocalInference.verifyGpuSandboxLocalInferenceAndCommitAfterReady + >[2]["runCaptureOpenshell"] + >; + }; + readonly dashboard: { + readonly chatUiUrl: string; + readonly initialHermesState: HermesDashboardOnboardState; + readonly releasePort: () => Promise; + readonly ensureForward: ( + sandboxName: string, + chatUiUrl: string, + options: { rollbackSandboxOnFailure: true }, + ) => number; + readonly getForwardPort: (chatUiUrl: string) => string; + readonly resolveHermesState: (port: number) => HermesDashboardOnboardState; + readonly ensureHermesForward: ( + state: HermesDashboardOnboardState, + sandboxName: string, + rollback: true, + ) => void; + }; + readonly workload: Omit< + WorkloadResolutionInput, + "registryImageRef" | "firstCreateOutput" | "createOutput" + >; +} + +export interface CreatedSandboxCompletionDeps extends Omit< + CreatedSandboxFinalizationDeps, + "register" +> { + readonly registerCreatedSandbox?: typeof registerCreatedSandbox; +} + +export interface CreatedSandboxCompletionActions { + complete( + created: SandboxGpuCreateFlowResult | null, + configuredReceipt: HermesPortableConfiguredReceipt | null, + providerGpuDisposition: "disabled" | "created" | "hermes", + manageDashboard: boolean, + resolveLifecycleRegistrationFields: () => Pick, + lifecycle: CreatedSandboxLifecycle, + inferenceRouteReservation?: QualifiedSandboxInferenceRouteReservation, + ): Promise; +} + +type OnboardCreatedSandboxRegistration = ( + created: SandboxGpuCreateFlowResult | null, + configuredReceipt: HermesPortableConfiguredReceipt | null, + configuredLiveIdentityFingerprint?: string, + revalidateHermesAuthority?: () => string, + inferenceRouteReservation?: QualifiedSandboxInferenceRouteReservation, +) => Promise; + +/** Bind the post-create registration callback to its finalization authorities. */ +export function createOnboardCreatedSandboxRegistration(input: { + readonly completion: CreatedSandboxCompletionActions; + readonly createdLifecycle: CreatedSandboxLifecycle; + readonly cleanupBuildContext: () => void; + readonly manageDashboard: boolean; + readonly sandboxGpuEnabled: boolean; +}): OnboardCreatedSandboxRegistration { + return async ( + created, + configuredReceipt, + configuredLiveIdentityFingerprint, + revalidate, + inferenceRouteReservation, + ) => { + if (!created && !configuredReceipt) { + throw new Error("Sandbox registration requires create or Hermes receipt authority."); + } + input.cleanupBuildContext(); + const providerGpuDisposition = !input.sandboxGpuEnabled + ? "disabled" + : configuredReceipt + ? "hermes" + : "created"; + const lifecycle = configuredReceipt + ? createHermesPortableCreatedSandboxLifecycle( + configuredReceipt, + revalidate ?? + (() => { + throw new Error( + "Hermes portable registry publication has no revalidation authority.", + ); + }), + ) + : input.createdLifecycle; + await input.completion.complete( + created, + configuredReceipt, + providerGpuDisposition, + input.manageDashboard, + () => + configuredReceipt + ? { + lifecycleGeneration: configuredReceipt.lifecycleGeneration, + lifecycleLiveIdentityFingerprint: configuredLiveIdentityFingerprint, + } + : created!.lifecycleRegistrationFields, + lifecycle, + inferenceRouteReservation, + ); + }; +} + +/** Finish ordinary post-registration actions after portable onboarding has returned. */ +export function completeOrdinaryOnboardSandboxCreation( + input: { + readonly sandboxName: string; + readonly sandboxWasLiveDefault: boolean; + readonly runtimeFields: RegistrationSeed["runtimeFields"]; + readonly messagingProviders: readonly string[]; + readonly liveExists: boolean; + }, + deps: { + readonly setDefault: (sandboxName: string) => void; + readonly runFile: (command: string, args: string[], options: { ignoreError: true }) => unknown; + readonly scriptsDir: string; + readonly gatewayName: string; + readonly providerExistsInGateway: (providerName: string) => boolean; + readonly armCancelRollback: (sandboxName: string) => void; + readonly dockerInfoFormat: Parameters[0]["dockerInfoFormat"]; + readonly runCapture: Parameters[0]["runCapture"]; + }, +): string { + restoreDefaultAfterRecreate(deps.setDefault, input.sandboxName, input.sandboxWasLiveDefault); + if (input.runtimeFields.openshellDriver === "kubernetes") { + console.log(" Setting up sandbox DNS proxy..."); + deps.runFile( + "bash", + [path.join(deps.scriptsDir, "setup-dns-proxy.sh"), deps.gatewayName, input.sandboxName], + { ignoreError: true }, + ); + } + applyOnboardVmDnsMonkeypatch(input.sandboxName, input.runtimeFields); + for (const provider of input.messagingProviders) { + if (!deps.providerExistsInGateway(provider)) printMessagingProviderMissing(provider); + } + console.log(` ✓ Sandbox '${input.sandboxName}' created`); + warnIfLandlockUnsupported(deps); + if (!input.liveExists) deps.armCancelRollback(input.sandboxName); + return input.sandboxName; +} + +/** Revalidate the configuring receipt at both registry-publication checks. */ +export function createHermesPortableCreatedSandboxLifecycle( + receipt: HermesPortableConfiguredReceipt, + revalidate: () => string, +): CreatedSandboxLifecycle { + const requireCurrent = () => ({ + lifecycleGeneration: receipt.lifecycleGeneration, + lifecycleLiveIdentityFingerprint: revalidate(), + }); + return { + generation: receipt.lifecycleGeneration, + capture: (fields) => { + if (fields.lifecycleGeneration !== receipt.lifecycleGeneration) { + throw new Error("Hermes portable registry generation disagrees with receipt authority."); + } + return requireCurrent(); + }, + revalidate: (registration) => { + const current = requireCurrent(); + if ( + registration.lifecycleGeneration !== current.lifecycleGeneration || + registration.lifecycleLiveIdentityFingerprint !== current.lifecycleLiveIdentityFingerprint + ) { + throw new Error("Hermes portable registry publication disagrees with receipt authority."); + } + return current; + }, + }; +} + +/** Keep post-Ready action bodies with the finalization owner while onboarding retains decisions. */ +export function createCreatedSandboxCompletionActions( + options: CreatedSandboxCompletionOptions, + deps: CreatedSandboxCompletionDeps, +): CreatedSandboxCompletionActions { + let chatUiUrl = options.dashboard.chatUiUrl; + let dashboardPort = 0; + let hermesDashboardState = options.dashboard.initialHermesState; + async function verifyCreatedProviderGpu(created: SandboxGpuCreateFlowResult): Promise { + await dockerGpuLocalInference.verifyGpuSandboxLocalInferenceAndCommitAfterReady( + options.gpu.config, + options.gpu.provider, + { + sandboxName: options.finalization.sandboxName, + dockerDriverGateway: options.gpu.dockerDriverGateway, + selectedRoute: created.route, + verifyDirectSandboxGpu: options.gpu.verifyDirectSandboxGpu, + runCaptureOpenshell: options.gpu.runCaptureOpenshell, + log: console.log, + }, + created.runtimePatch, + ); + } + function recordHermesGpuProof(): void { + options.gpu.config.sandboxGpuProof = options.gpu.verifyDirectSandboxGpu( + options.finalization.sandboxName, + ); + } + async function finalizeDashboard(): Promise { + await options.dashboard.releasePort(); + dashboardPort = options.dashboard.ensureForward(options.finalization.sandboxName, chatUiUrl, { + rollbackSandboxOnFailure: true, + }); + if (dashboardPort !== Number(options.dashboard.getForwardPort(chatUiUrl))) { + chatUiUrl = `http://127.0.0.1:${dashboardPort}`; + } + process.env.CHAT_UI_URL = chatUiUrl; + hermesDashboardState = options.dashboard.resolveHermesState(dashboardPort); + options.dashboard.ensureHermesForward( + hermesDashboardState, + options.finalization.sandboxName, + true, + ); + } + return { + complete: async ( + created, + configuredReceipt, + providerGpuDisposition, + manageDashboard, + resolveLifecycleRegistrationFields, + lifecycle, + inferenceRouteReservation, + ) => { + if (providerGpuDisposition === "created") { + await verifyCreatedProviderGpu(created!); + } else if (providerGpuDisposition === "hermes") { + recordHermesGpuProof(); + } + if (manageDashboard) await finalizeDashboard(); + const resolved = managedWorkloadOnboard.resolveOnboardSandboxWorkloadReceipt({ + ...options.workload, + registryImageRef: created?.registryImageRef ?? configuredReceipt?.container.imageId ?? null, + firstCreateOutput: created?.firstCreateOutput ?? "", + createOutput: created?.createResult.output ?? "", + }); + const verifiedLifecycle = lifecycle.revalidate( + lifecycle.capture(resolveLifecycleRegistrationFields()), + ); + return finalizeCreatedSandbox(options.finalization, { + ...deps, + register: (openclawImagePluginInstalls) => + (deps.registerCreatedSandbox ?? registerCreatedSandbox)({ + ...options.registration, + runtimeFields: { + ...options.registration.runtimeFields, + openshellVersion: + configuredReceipt?.openshellExecutableAuthority.version ?? + options.registration.runtimeFields.openshellVersion, + }, + hermesPortableLifecycle: configuredReceipt !== null, + imageTag: resolved.resolvedImageTag, + workload: resolved.workloadReceipt, + openclawImagePluginInstalls, + hermesDashboardState, + dashboardPort, + ...verifiedLifecycle, + inferenceRouteReservation, + }), + }); + }, + }; +} + +type OnboardCreateIntent = { + readonly endpointSource?: RegistrationSeed["inferenceSelection"]["endpointSource"]; + readonly observabilityEnabled?: boolean; +} | null; +type OnboardResolvedCreateIntent = { + readonly policy: { + readonly options: { + readonly baselineExclusions: NonNullable; + }; + }; + readonly hostMounts?: RegistrationSeed["hostMounts"]; +}; +type OnboardCreateContext = { + readonly createIntent: OnboardCreateIntent; + readonly resolvedCreateIntent: OnboardResolvedCreateIntent; +}; +type OnboardAgentFlags = { + readonly customOpenClawImage: boolean; + readonly isManagedDcodeAgent: boolean; +}; +type OnboardInferenceSelection = { + readonly provider: string; + readonly model: string; + readonly preferredInferenceApi: string | null; +}; +type OnboardMessagingRegistration = { + readonly plannedMessagingState: RegistrationSeed["plannedMessagingState"]; + readonly preservedMcpState: RegistrationSeed["preservedMcpState"]; + readonly hermesToolGateways: string[]; +}; +type OnboardCreationFidelity = { + readonly webSearchConfig: Parameters[0]; + readonly hermesAuthMethod: Parameters[2]; +}; +type OnboardPolicyRegistration = { + readonly toolDisclosure: RegistrationSeed["toolDisclosure"]; + readonly dcodeAutoApprovalMode: RegistrationSeed["dcodeAutoApprovalMode"]; +}; +type OnboardGatewayBinding = { + readonly gatewayName: string; + readonly gatewayPort: number; +}; +type OnboardPreparedPolicy = Pick< + managedWorkloadOnboard.PreparedOnboardSandboxWorkloadLaunch, + "initialSandboxPolicy" | "policyTier" | "dashboardRemoteBindPrepared" +>; + +/** Assemble the exact post-Ready owners without adding an onboarding decision. */ +export function createOnboardCreatedSandboxCompletion( + sandboxName: string, + restoreBackupPath: string | null, + pendingStateRestoreBackupPath: string | null, + agent: RegistrationSeed["agent"], + fromDockerfile: string | null, + agentFlags: OnboardAgentFlags, + inference: OnboardInferenceSelection, + createContext: OnboardCreateContext, + runtimeFields: RegistrationSeed["runtimeFields"], + portableLifecycle: boolean, + policyRegistration: OnboardPolicyRegistration, + creation: OnboardCreationFidelity, + messaging: OnboardMessagingRegistration, + hermesApiPort: number | null, + gateway: OnboardGatewayBinding, + preparedPolicy: OnboardPreparedPolicy, + prebuildImageRef: string | null, + buildId: string, + gpuConfig: CreatedSandboxCompletionOptions["gpu"]["config"], + dockerDriverGateway: boolean, + verifyDirectSandboxGpu: CreatedSandboxCompletionOptions["gpu"]["verifyDirectSandboxGpu"], + runCaptureOpenshell: CreatedSandboxCompletionOptions["gpu"]["runCaptureOpenshell"], + chatUiUrl: string, + initialHermesDashboardState: HermesDashboardOnboardState, + releaseDashboardPort: CreatedSandboxCompletionOptions["dashboard"]["releasePort"], + ensureDashboardForward: CreatedSandboxCompletionOptions["dashboard"]["ensureForward"], + getDashboardForwardPort: CreatedSandboxCompletionOptions["dashboard"]["getForwardPort"], + resolveHermesDashboardState: CreatedSandboxCompletionOptions["dashboard"]["resolveHermesState"], + ensureHermesDashboardForward: CreatedSandboxCompletionOptions["dashboard"]["ensureHermesForward"], + workloadRuntime: WorkloadResolutionInput["runtime"], + workload: WorkloadResolutionInput["workload"], + note: (message: string) => void, +): CreatedSandboxCompletionActions { + const { provider, model, preferredInferenceApi } = inference; + const { createIntent, resolvedCreateIntent } = createContext; + return createCreatedSandboxCompletionActions( + { + finalization: { + sandboxName, + restoreBackupPath, + preUpgradeBackup: pendingStateRestoreBackupPath !== null, + targetAgentType: agent?.name ?? "openclaw", + customImage: Boolean(fromDockerfile), + discoverOpenClawImagePluginInstalls: agentFlags.customOpenClawImage, + validateManagedDcode: agentFlags.isManagedDcodeAgent, + provider, + model, + preferredInferenceApi, + }, + registration: { + sandboxName, + inferenceSelection: selection( + sandboxName, + provider, + model, + preferredInferenceApi, + createIntent?.endpointSource ?? null, + ), + runtimeFields, + agent, + agentVersionKnown: !fromDockerfile, + portableLifecycle, + appliedPolicies: preparedPolicy.initialSandboxPolicy.appliedPresets, + toolDisclosure: policyRegistration.toolDisclosure, + observabilityEnabled: createIntent?.observabilityEnabled === true, + ...(agentFlags.isManagedDcodeAgent + ? { dcodeAutoApprovalMode: policyRegistration.dcodeAutoApprovalMode } + : {}), + policyTier: preparedPolicy.policyTier, + ...creationFidelity( + creation.webSearchConfig, + fromDockerfile, + creation.hermesAuthMethod, + preparedPolicy.dashboardRemoteBindPrepared, + resolvedCreateIntent.policy.options.baselineExclusions, + ), + ...messaging, + hermesApiPort, + ...gateway, + hostMounts: resolvedCreateIntent.hostMounts, + }, + gpu: { + config: gpuConfig, + provider, + dockerDriverGateway, + verifyDirectSandboxGpu, + runCaptureOpenshell, + }, + dashboard: { + chatUiUrl, + initialHermesState: initialHermesDashboardState, + releasePort: releaseDashboardPort, + ensureForward: ensureDashboardForward, + getForwardPort: getDashboardForwardPort, + resolveHermesState: resolveHermesDashboardState, + ensureHermesForward: ensureHermesDashboardForward, + }, + workload: { + runtime: workloadRuntime, + workload, + prebuildImageRef, + buildId, + extractBuiltImageRef: buildContext.extractBuiltImageRef, + resolveSandboxImageTagFromCreateOutput, + }, + }, + { + discoverFreshOpenClawImagePluginInstalls: (name) => + openClawPluginRestore.discoverFreshOpenClawImagePluginInstalls( + name, + sandboxState, + agent?.configPaths.dir, + ), + restoreRecreatedSandboxState: sandboxState.restoreRecreatedSandboxState, + getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => + getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { + runCaptureOpenshell, + }), + note, + error: console.error, + exitProcess: (code) => process.exit(code), + }, + ); +} + /** Restore state and validate the live managed DCode route before registry publication. */ export function finalizeCreatedSandbox( options: CreatedSandboxFinalizationOptions, deps: CreatedSandboxFinalizationDeps, -): void { +): SandboxEntry | void { let freshOpenClawImagePluginInstalls: readonly OpenClawImagePluginInstall[] | undefined; if (options.discoverOpenClawImagePluginInstalls === true) { const discovery = deps.discoverFreshOpenClawImagePluginInstalls(options.sandboxName); @@ -148,5 +650,5 @@ export function finalizeCreatedSandbox( } } - deps.register(freshOpenClawImagePluginInstalls); + return deps.register(freshOpenClawImagePluginInstalls); } diff --git a/src/lib/onboard/dashboard-port.test.ts b/src/lib/onboard/dashboard-port.test.ts index f170156ea66..4d2e983cba6 100644 --- a/src/lib/onboard/dashboard-port.test.ts +++ b/src/lib/onboard/dashboard-port.test.ts @@ -289,7 +289,7 @@ describe("dashboard port reservation", () => { return { fresh: false }; }, createSandboxWithBaseImageResolution, - resolvePortableRuntimeAuthority: () => ({ socketPath: "/run/user/1001/podman.sock" }), + resolvePortableRuntimeContext: () => ({ socketPath: "/run/user/1001/podman.sock" }), resolveComputePlan: () => { events.push("resolve compute plan"); return { sequence: ++sequence }; @@ -343,7 +343,7 @@ describe("dashboard port reservation", () => { >({ createBaseImageResolutionContext: () => ({ fresh: false }), createSandboxWithBaseImageResolution: async () => "unreachable", - resolvePortableRuntimeAuthority: () => null, + resolvePortableRuntimeContext: () => null, resolveComputePlan: () => { throw setupFailure; }, diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 41ff60cd968..5e35400facc 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -627,13 +627,13 @@ interface DashboardPortScopedSandboxEntryPointDeps< Args extends unknown[], Result, BaseImageResolutionContext, - PortableRuntimeAuthority, + PortableRuntimeContext, ComputePlan, > { createBaseImageResolutionContext(): BaseImageResolutionContext; createSandboxWithBaseImageResolution( baseImageResolutionContext: BaseImageResolutionContext, - portableRuntimeAuthority: PortableRuntimeAuthority, + portableRuntimeContext: PortableRuntimeContext, computePlan: ComputePlan, managedWorkloadRebuild: null, temporaryManagedRuntime: boolean, @@ -641,7 +641,7 @@ interface DashboardPortScopedSandboxEntryPointDeps< dashboardPortReservationScope: DashboardPortReservationScope, ...args: Args ): Promise; - resolvePortableRuntimeAuthority(): PortableRuntimeAuthority; + resolvePortableRuntimeContext(): PortableRuntimeContext; resolveComputePlan(): ComputePlan; } @@ -649,14 +649,14 @@ export function createDashboardPortScopedSandboxEntryPoints< Args extends unknown[], Result, BaseImageResolutionContext, - PortableRuntimeAuthority, + PortableRuntimeContext, ComputePlan, >( deps: DashboardPortScopedSandboxEntryPointDeps< Args, Result, BaseImageResolutionContext, - PortableRuntimeAuthority, + PortableRuntimeContext, ComputePlan >, ): { @@ -668,7 +668,7 @@ export function createDashboardPortScopedSandboxEntryPoints< return withDashboardPortReservationScope((dashboardPortReservationScope) => deps.createSandboxWithBaseImageResolution( deps.createBaseImageResolutionContext(), - deps.resolvePortableRuntimeAuthority(), + deps.resolvePortableRuntimeContext(), computePlan, null, temporaryManagedRuntime, diff --git a/src/lib/onboard/docker-startup-command-env.ts b/src/lib/onboard/docker-startup-command-env.ts index 947c3f4eb9f..7ee46110829 100644 --- a/src/lib/onboard/docker-startup-command-env.ts +++ b/src/lib/onboard/docker-startup-command-env.ts @@ -1,7 +1,170 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { getRegisteredAgent } from "../agent/runtime"; +import type { AgentDefinition } from "../agent/definition-types"; +import { formatEnvAssignment } from "../core/url-utils"; +import { isValidProxyHost, isValidProxyPort } from "./dockerfile-patch"; +import { appendExtraPlaceholderKeysEnvArg } from "./extra-placeholder-keys"; +import { HERMES_API_PORT_ENV, resolveOnboardHermesApiPort } from "./hermes-api-port"; +import { + appendHermesDashboardEnvArgs, + type HermesDashboardOnboardState, +} from "./hermes-dashboard"; +import { appendHostProxyEnvArgs } from "./host-proxy-env"; +import { appendOpenClawRuntimeEnvArgs } from "./openclaw-runtime-env"; + const STARTUP_COMMAND_TOKEN = /^[A-Za-z0-9_./:=,@%+\-\[\]]+$/u; +const OPENCLAW_AUTO_PAIR_RUNTIME_ENV_KEYS = [ + "NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", + "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", + "NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS", + "NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS", +] as const; +const OPENCLAW_DIAGNOSTIC_RUNTIME_ENV_KEYS = ["NEMOCLAW_MCP_SHADOW_DIAGNOSTICS"] as const; +const OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV = "NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS"; +const OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS = 1500; +const OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS = 10_000; + +function appendOpenClawAutoPairRuntimeEnvArgs( + envArgs: string[], + agent: AgentDefinition | null, + env: NodeJS.ProcessEnv, +): void { + if (agent && agent.name !== "openclaw") return; + for (const key of OPENCLAW_AUTO_PAIR_RUNTIME_ENV_KEYS) { + const value = env[key]?.trim(); + if (value) envArgs.push(formatEnvAssignment(key, value)); + } +} + +function appendOpenClawDiagnosticRuntimeEnvArgs( + envArgs: string[], + agent: AgentDefinition | null, + env: NodeJS.ProcessEnv, +): void { + if (agent && agent.name !== "openclaw") return; + for (const key of OPENCLAW_DIAGNOSTIC_RUNTIME_ENV_KEYS) { + if (env[key]?.trim() === "1") envArgs.push(formatEnvAssignment(key, "1")); + } +} + +function appendOpenClawMcpToolsListTimeoutRuntimeEnvArg( + envArgs: string[], + agent: AgentDefinition | null, + env: NodeJS.ProcessEnv, +): void { + if (agent && agent.name !== "openclaw") return; + const raw = env[OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV]; + if (raw === undefined || raw.trim() === "") return; + const value = raw.trim(); + if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) { + throw new Error( + `${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV} must be an integer from ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS} to ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS} milliseconds.`, + ); + } + const timeoutMs = Number(value); + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS || + timeoutMs > OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS + ) { + throw new Error( + `${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV} must be an integer from ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS} to ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS} milliseconds.`, + ); + } + envArgs.push(formatEnvAssignment(OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV, String(timeoutMs))); +} + +export interface SandboxRuntimeEnvArgsInput { + agent: AgentDefinition | null; + chatUiUrl: string; + manageDashboard: boolean; + getDashboardForwardPort(chatUiUrl: string): string; + hermesDashboardState: HermesDashboardOnboardState; + hermesApiPort?: number | null; + extraPlaceholderKeys: readonly string[]; + allowHermesApiPortOverride?: boolean; + observabilityEnabled?: boolean; + sandboxName?: string; + env: NodeJS.ProcessEnv; + omitCredentialEnv?: boolean; +} + +export function buildSandboxRuntimeEnvArgs(input: SandboxRuntimeEnvArgsInput): { + envArgs: string[]; + effectiveDashboardPort: string; +} { + const { agent, env, manageDashboard } = input; + const envArgs = manageDashboard ? [formatEnvAssignment("CHAT_UI_URL", input.chatUiUrl)] : []; + const effectiveDashboardPort = manageDashboard + ? input.getDashboardForwardPort(input.chatUiUrl) + : "0"; + if (manageDashboard) { + envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); + if (env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0") { + envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0")); + } + } + + appendOpenClawRuntimeEnvArgs(envArgs, agent); + appendOpenClawAutoPairRuntimeEnvArgs(envArgs, agent, env); + appendOpenClawDiagnosticRuntimeEnvArgs(envArgs, agent, env); + appendOpenClawMcpToolsListTimeoutRuntimeEnvArg(envArgs, agent, env); + appendHermesDashboardEnvArgs(envArgs, input.hermesDashboardState, formatEnvAssignment); + if (agent?.name === "hermes" && input.sandboxName) { + const apiPort = + input.hermesApiPort ?? + resolveOnboardHermesApiPort(input.sandboxName, { + env, + warn: console.warn, + allowRegisteredOverride: input.allowHermesApiPortOverride, + }); + envArgs.push(formatEnvAssignment(HERMES_API_PORT_ENV, String(apiPort))); + } + appendHostProxyEnvArgs(envArgs, env, { + dropCredentialBearingProxyUrls: + agent?.name === "langchain-deepagents-code" || input.omitCredentialEnv === true, + }); + + const sandboxProxyHost = env.NEMOCLAW_PROXY_HOST; + if (sandboxProxyHost && isValidProxyHost(sandboxProxyHost)) { + envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_HOST", sandboxProxyHost)); + } + const sandboxProxyPort = env.NEMOCLAW_PROXY_PORT; + if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { + envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); + } + if (input.sandboxName) { + envArgs.push(formatEnvAssignment("NEMOCLAW_SANDBOX_NAME", input.sandboxName)); + } + if (agent?.name === "langchain-deepagents-code") { + envArgs.push( + formatEnvAssignment( + "NEMOCLAW_OBSERVABILITY", + input.observabilityEnabled === true ? "1" : "0", + ), + ); + } + if (!input.omitCredentialEnv) { + appendExtraPlaceholderKeysEnvArg(envArgs, input.extraPlaceholderKeys, formatEnvAssignment); + } + return { envArgs, effectiveDashboardPort }; +} + +export function buildCurrentHermesPortableRuntimeEnvArgs( + input: Omit, +): ReturnType { + return buildSandboxRuntimeEnvArgs({ ...input, agent: currentHermesPortableAgentDefinition() }); +} + +export function currentHermesPortableAgentDefinition(): AgentDefinition { + const agent = getRegisteredAgent({ agent: "hermes" }); + if (!agent) throw new Error("The current Hermes agent manifest is unavailable."); + return agent; +} export function openshellSandboxCommandEnvValue( command: readonly string[] | null | undefined, diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 01e4292e1ac..325e1248279 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getSandboxInferenceConfig } from "../inference/config"; +import { getSandboxInferenceConfig, isSafeModelId } from "../inference/config"; import { MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context"; import { isWebSearchEnabled, @@ -67,6 +67,68 @@ function sanitizeDockerArg(value: unknown): string { return String(value ?? "").replace(/[\r\n]/g, ""); } +export interface HermesPortableDockerfileBuildSettings { + readonly model: string; + readonly provider: string | null; + readonly preferredInferenceApi: string | null; + readonly toolDisclosure: ToolDisclosure; +} + +function replaceExactHermesPortableDockerArg(source: string, name: string, value: string): string { + const sanitized = sanitizeDockerArg(value); + if (sanitized !== value || /[\p{Cc}\p{Cf}]/u.test(value)) { + throw new Error(`Hermes portable ${name} build setting is invalid.`); + } + const pattern = new RegExp(`^ARG ${name}=.*$`, "gmu"); + if ((source.match(pattern) ?? []).length !== 1) { + throw new Error(`Hermes Dockerfile must declare exactly one ${name} build argument.`); + } + return source.replace(pattern, `ARG ${name}=${sanitized}`); +} + +/** Render the reviewed non-secret schema-5 Hermes image settings from the shared route owner. */ +export function renderHermesPortableDockerfileBuildSettings( + source: string, + input: HermesPortableDockerfileBuildSettings, +): string { + if (!input.model || input.model.length > 4096 || !isSafeModelId(input.model)) { + throw new Error("Hermes portable model build setting is invalid."); + } + if (input.provider !== null && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(input.provider)) { + throw new Error("Hermes portable provider build setting is invalid."); + } + if ( + input.preferredInferenceApi !== null && + !["anthropic-messages", "openai-completions", "openai-responses"].includes( + input.preferredInferenceApi, + ) + ) { + throw new Error("Hermes portable inference API build setting is invalid."); + } + const toolDisclosure = normalizeToolDisclosure(input.toolDisclosure); + if (toolDisclosure !== input.toolDisclosure) { + throw new Error("Hermes portable tool disclosure build setting is invalid."); + } + const inference = getSandboxInferenceConfig( + input.model, + input.provider, + input.preferredInferenceApi, + ); + const replacements = [ + ["NEMOCLAW_MODEL", input.model], + ["NEMOCLAW_INFERENCE_PROVIDER_ID", inference.providerKey], + ["NEMOCLAW_UPSTREAM_PROVIDER", input.provider ?? inference.providerKey], + ["NEMOCLAW_INFERENCE_BASE_URL", inference.inferenceBaseUrl], + ["NEMOCLAW_INFERENCE_API", inference.inferenceApi], + ["NEMOCLAW_TOOL_DISCLOSURE", toolDisclosure], + ["CHAT_UI_URL", ""], + ] as const; + return replacements.reduce( + (rendered, [name, value]) => replaceExactHermesPortableDockerArg(rendered, name, value), + source, + ); +} + function encodeSanitizedDockerJsonArg(value: unknown): string { return sanitizeDockerArg(encodeDockerJsonArg(value)); } diff --git a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts new file mode 100644 index 00000000000..54cfeb53e48 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts @@ -0,0 +1,419 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Exact shipped files admitted by the schema-5 Hermes Dockerfile COPY contract. */ +export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ + { path: "agents/hermes/build-mcp-digest.py", mode: "100644" }, + { path: "agents/hermes/config/build-env.ts", mode: "100644" }, + { path: "agents/hermes/config/generate.ts", mode: "100644" }, + { path: "agents/hermes/config/hermes-env.ts", mode: "100644" }, + { path: "agents/hermes/config/managed-policy.ts", mode: "100644" }, + { path: "agents/hermes/config/managed-tool-gateway.ts", mode: "100644" }, + { path: "agents/hermes/config/model-specific-setup.ts", mode: "100644" }, + { path: "agents/hermes/config/object-record.ts", mode: "100644" }, + { path: "agents/hermes/config/upstream-header.ts", mode: "100644" }, + { path: "agents/hermes/config/write-config.ts", mode: "100644" }, + { path: "agents/hermes/config/yaml.ts", mode: "100644" }, + { path: "agents/hermes/cron-restore-control.py", mode: "100644" }, + { path: "agents/hermes/Dockerfile", mode: "100644" }, + { path: "agents/hermes/finalize-tirith-marker.py", mode: "100755" }, + { path: "agents/hermes/generate-config.ts", mode: "100644" }, + { path: "agents/hermes/hermes-cli-adapter-v1.json", mode: "100644" }, + { path: "agents/hermes/hermes-wrapper.py", mode: "100755" }, + { path: "agents/hermes/host/managed-tool-gateway-matrix.json", mode: "100644" }, + { path: "agents/hermes/image-build-probes.py", mode: "100644" }, + { path: "agents/hermes/managed_policy.py", mode: "100644" }, + { path: "agents/hermes/mcp-config-transaction.py", mode: "100755" }, + { path: "agents/hermes/patch-cron-execution-runtime.py", mode: "100755" }, + { path: "agents/hermes/patch-cron-restore-drain.py", mode: "100755" }, + { path: "agents/hermes/patch-discord-recovery-permissions.py", mode: "100755" }, + { path: "agents/hermes/patch-gateway-process-identity.py", mode: "100755" }, + { path: "agents/hermes/patch-gateway-runtime-metadata.py", mode: "100755" }, + { path: "agents/hermes/patch-hermes-sqlite-temp-store.py", mode: "100755" }, + { path: "agents/hermes/patch-langfuse-credentials.mts", mode: "100644" }, + { path: "agents/hermes/patch-neutral-platform-env-activation.py", mode: "100755" }, + { path: "agents/hermes/patch-profile-policy-defaults.py", mode: "100755" }, + { path: "agents/hermes/patch-session-list-preview.py", mode: "100755" }, + { path: "agents/hermes/plugin/__init__.py", mode: "100644" }, + { path: "agents/hermes/plugin/plugin.yaml", mode: "100644" }, + { path: "agents/hermes/plugin/test_private_url_opt_in.py", mode: "100644" }, + { path: "agents/hermes/plugin/test_register_tools.py", mode: "100644" }, + { path: "agents/hermes/runtime-config-guard.py", mode: "100755" }, + { path: "agents/hermes/runtime-state-mutation-publisher-v1.json", mode: "100644" }, + { path: "agents/hermes/security-dependencies.patch", mode: "100644" }, + { path: "agents/hermes/seed-dashboard-config.py", mode: "100755" }, + { path: "agents/hermes/start.sh", mode: "100755" }, + { path: "agents/hermes/state-lock-plan.json", mode: "100644" }, + { path: "agents/hermes/validate-cli-adapter.py", mode: "100755" }, + { path: "agents/hermes/validate-env-secret-boundary.py", mode: "100755" }, + { path: "nemoclaw-blueprint/blueprint.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/model-specific-setup/hermes/README.md", mode: "100644" }, + { + path: "nemoclaw-blueprint/model-specific-setup/openclaw/gemini-3-managed-inference.json", + mode: "100644", + }, + { + path: "nemoclaw-blueprint/model-specific-setup/openclaw/gpt-5-o-series-managed-inference.json", + mode: "100644", + }, + { + path: "nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json", + mode: "100644", + }, + { + path: "nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-super-120b-managed-inference.json", + mode: "100644", + }, + { + path: "nemoclaw-blueprint/model-specific-setup/openclaw/nemotron-3-ultra-managed-inference.json", + mode: "100644", + }, + { path: "nemoclaw-blueprint/model-specific-setup/README.md", mode: "100644" }, + { path: "nemoclaw-blueprint/model-specific-setup/schema.json", mode: "100644" }, + { path: "nemoclaw-blueprint/openclaw-plugins/gemini-inference-compat/index.ts", mode: "100644" }, + { + path: "nemoclaw-blueprint/openclaw-plugins/gemini-inference-compat/openclaw.plugin.json", + mode: "100644", + }, + { path: "nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js", mode: "100644" }, + { + path: "nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/openclaw.plugin.json", + mode: "100644", + }, + { path: "nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/openclaw-sandbox.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/brave.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/brew.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/claude-code.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/github.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/gmail.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/huggingface.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/jira.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/local-inference.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/local-memory.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/nous-audio.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/nous-browser.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/nous-code.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/nous-image.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/nous-web.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/npm.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/observability-otlp-local.yaml", mode: "100644" }, + { + path: "nemoclaw-blueprint/policies/presets/openclaw-diagnostics-otel-local.yaml", + mode: "100644", + }, + { path: "nemoclaw-blueprint/policies/presets/openclaw-pricing.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/outlook.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/personal-open-internet.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/public-reference.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/pypi.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/tavily.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/presets/weather.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/policies/tiers.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/private-networks.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/provider-profiles/brave.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/provider-profiles/entra-runtime-v1.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/provider-profiles/okta-runtime-v1.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/provider-profiles/tavily.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/router/llm-router", mode: "160000" }, + { path: "nemoclaw-blueprint/router/pool-config.yaml", mode: "100644" }, + { path: "nemoclaw-blueprint/scripts/ciao-network-guard.js", mode: "100644" }, + { path: "nemoclaw-blueprint/scripts/http-proxy-fix.js", mode: "100644" }, + { path: "nemoclaw-blueprint/scripts/nemotron-inference-fix.js", mode: "100644" }, + { path: "nemoclaw-blueprint/scripts/sandbox-safety-net.js", mode: "100644" }, + { path: "nemoclaw-blueprint/tsconfig.json", mode: "100644" }, + { path: "scripts/gateway-control.sh", mode: "100755" }, + { path: "scripts/lib/bundled-npm-package.mts", mode: "100644" }, + { path: "scripts/lib/corporate-ca-runtime.sh", mode: "100755" }, + { path: "scripts/lib/entrypoint-env-wrapper.sh", mode: "100755" }, + { path: "scripts/lib/gateway-supervisor.sh", mode: "100755" }, + { path: "scripts/lib/openclaw-npm-remediation.mts", mode: "100755" }, + { path: "scripts/lib/patch-bundled-npm-ip-address.mts", mode: "100755" }, + { path: "scripts/lib/reviewed-npm-archive.mts", mode: "100755" }, + { path: "scripts/lib/sandbox-init.sh", mode: "100755" }, + { path: "scripts/lib/sandbox-rlimits.sh", mode: "100644" }, + { path: "scripts/managed-bootstrap-entrypoint.c", mode: "100644" }, + { path: "scripts/managed-bootstrap-trampoline.sh", mode: "100644" }, + { path: "scripts/managed-gateway-control.py", mode: "100755" }, + { path: "scripts/managed-startup-hold.sh", mode: "100755" }, + { path: "scripts/patch-bundled-npm-brace-expansion.mts", mode: "100755" }, + { path: "scripts/patch-bundled-npm-tar.mts", mode: "100755" }, + { path: "scripts/runtime_state_mutation_hermes_publisher.py", mode: "100755" }, + { path: "scripts/runtime-state-mutation-control.py", mode: "100755" }, + { path: "scripts/runtime-state-mutation-startup-gate.py", mode: "100755" }, + { path: "scripts/state-dir-guard.py", mode: "100755" }, + { + path: "src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.101.json", + mode: "100644", + }, + { path: "src/lib/hermes-managed-route.ts", mode: "100644" }, + { path: "src/lib/messaging/AGENTS.md", mode: "100644" }, + { path: "src/lib/messaging/applier/agent-config.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/build/messaging-build-applier.mts", mode: "100755" }, + { path: "src/lib/messaging/applier/conflict-detection-entry.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection-multi-credential.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection-overlap.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection-plan.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection-slack-gateway.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection/entries.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection/index.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection/manifest-metadata.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection/plan.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection/registry.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection/slack-socket-mode.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/conflict-detection/types.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/hook-phases.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/hook-phases.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/host-state-applier.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/host-state-applier.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/index.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/openclaw-plugin-allow.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/openclaw-plugin-allow.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/openshell-provider.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/plan-filter.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/policy.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/setup-applier.test.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/setup-applier.ts", mode: "100644" }, + { path: "src/lib/messaging/applier/types.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/built-ins.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/channel-health.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/hooks/openclaw-bridge-health.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/manifest.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/policy/hermes.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/policy/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/rendered-config-parser.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/rendered-config-parser.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/discord/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/hooks/runtime-deps.ts", mode: "100644" }, + { + path: "src/lib/messaging/channels/googlechat/hooks/service-account-token-paste.test.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/googlechat/hooks/service-account-token-paste.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts", + mode: "100644", + }, + { path: "src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/manifest.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/policy/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/rendered-config-parser.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/runtime-contract.test.ts", mode: "100644" }, + { + path: "src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.test.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts", + mode: "100644", + }, + { path: "src/lib/messaging/channels/googlechat/template-resolver.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/tunnel/lifecycle.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/tunnel/lifecycle.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/tunnel/pid-dir.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/tunnel/proxy.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/googlechat/tunnel/proxy.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/manifests.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/metadata.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/metadata.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/openclaw-bridge-health.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/openclaw-bridge-health.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/policy.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/policy.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/rendered-config-parser-utils.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/rendered-config-parser.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/credential-validation.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/credential-validation.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/openclaw-bridge-health.ts", mode: "100644" }, + { + path: "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.test.ts", + mode: "100644", + }, + { path: "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/status-health.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/status-health.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/manifest.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/policy/hermes.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/policy/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/rendered-config-parser.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/slack/template-resolver.ts", mode: "100644" }, + { + path: "src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.test.ts", + mode: "100644", + }, + { path: "src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/manifest.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/policy/hermes.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/policy/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/rendered-config-parser.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/runtime/msteams-message-hints.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts", mode: "100644" }, + { + path: "src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.test.ts", + mode: "100644", + }, + { path: "src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/openclaw-bridge-health.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/status-health-eval.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/status-health-eval.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/status-health.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/hooks/status-health.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/manifest.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/policy/hermes.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/policy/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/rendered-config-parser.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/rendered-config-parser.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/template-resolver.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/telegram/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/template-resolver-utils.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/contract.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/hooks/health-check.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/hooks/ilink-login.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/hooks/implementations.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/ilink-base-url.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/login.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/login.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/manifest.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/policy/hermes.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/policy/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/qr.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/qr.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/rendered-config-parser.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/wechat/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/hooks/status-health-eval.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/hooks/status-health-eval.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/hooks/status-health.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/hooks/status-health.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/manifest.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/policy/hermes.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/policy/openclaw.yaml", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/rendered-config-parser.ts", mode: "100644" }, + { + path: "src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact-test-helpers.ts", + mode: "100644", + }, + { + path: "src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts", + mode: "100644", + }, + { path: "src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/template-resolver.test.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/whatsapp/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/clone-rebind.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/agent-render-engine.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/build-step-engine.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/credential-binding-engine.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/health-check-engine.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/host-forward-engine.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/policy-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/runtime-setup-engine.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/state-update-engine.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/engines/template.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/index.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/manifest-compiler.test.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/manifest-compiler.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/types.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/workflow-planner-hermes-slack.test.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/workflow-planner.test.ts", mode: "100644" }, + { path: "src/lib/messaging/compiler/workflow-planner.ts", mode: "100644" }, + { path: "src/lib/messaging/diagnostics.test.ts", mode: "100644" }, + { path: "src/lib/messaging/diagnostics.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/builtins.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/common/config-prompt.test.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/common/config-prompt.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/common/index.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/common/static-outputs.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/common/token-paste.test.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/common/token-paste.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/errors.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/hook-runner.test.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/hook-runner.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/index.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/registry.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/status-runner.test.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/status-runner.ts", mode: "100644" }, + { path: "src/lib/messaging/hooks/types.ts", mode: "100644" }, + { path: "src/lib/messaging/host-forward.ts", mode: "100644" }, + { path: "src/lib/messaging/hydration.ts", mode: "100644" }, + { path: "src/lib/messaging/index.ts", mode: "100644" }, + { path: "src/lib/messaging/manifest/index.ts", mode: "100644" }, + { path: "src/lib/messaging/manifest/registry.test.ts", mode: "100644" }, + { path: "src/lib/messaging/manifest/registry.ts", mode: "100644" }, + { path: "src/lib/messaging/manifest/types.test.ts", mode: "100644" }, + { path: "src/lib/messaging/manifest/types.ts", mode: "100644" }, + { path: "src/lib/messaging/managed-startup-placeholders.ts", mode: "100644" }, + { path: "src/lib/messaging/persisted-placeholders.test.ts", mode: "100644" }, + { path: "src/lib/messaging/persisted-placeholders.ts", mode: "100644" }, + { path: "src/lib/messaging/persistence.ts", mode: "100644" }, + { path: "src/lib/messaging/plan-authority.test.ts", mode: "100644" }, + { path: "src/lib/messaging/plan-authority.ts", mode: "100644" }, + { path: "src/lib/messaging/plan-validation.test.ts", mode: "100644" }, + { path: "src/lib/messaging/plan-validation.ts", mode: "100644" }, + { path: "src/lib/messaging/post-agent-install-selection.test.ts", mode: "100644" }, + { path: "src/lib/messaging/post-agent-install-selection.ts", mode: "100644" }, + { path: "src/lib/messaging/provider-placeholders.ts", mode: "100644" }, + { path: "src/lib/messaging/README.md", mode: "100644" }, + { path: "src/lib/messaging/utils.test.ts", mode: "100644" }, + { path: "src/lib/messaging/utils.ts", mode: "100644" }, + { path: "src/lib/tool-disclosure.ts", mode: "100644" }, + { + path: "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle", + mode: "100644", + }, + { + path: "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json", + mode: "100644", + }, + { + path: "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle", + mode: "100644", + }, + { + path: "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/THIRD_PARTY_LICENSES.txt", + mode: "100644", + }, +] as const; diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts new file mode 100644 index 00000000000..02328071aaf --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { testTimeoutOptions } from "../../../../test/helpers/timeouts"; + +import { ROOT } from "../../runner"; +import { createHermesPortableBuildContextPlan } from "./hermes-portable-build-context"; +import { HERMES_PORTABLE_BUILD_CONTEXT_FILES } from "./hermes-portable-build-context-files"; + +const TRANSACTION_ID = "11111111-1111-4111-8111-111111111111"; +const CREATE_INTENT = "a".repeat(64); +const BUILD_SETTINGS = { + model: "qwen3-vl:4b", + provider: "ollama-local", + preferredInferenceApi: "openai-completions", + toolDisclosure: "direct", +} as const; + +let stateDir: string; + +function emulatePrivateSourceAncestor(): void { + const original = fs.lstatSync; + const sharedTemporaryRoots = new Set([path.resolve("/tmp"), fs.realpathSync("/tmp")]); + vi.spyOn(fs, "lstatSync").mockImplementation(((target, options) => { + const stat = original(target, options as never); + return sharedTemporaryRoots.has(path.resolve(String(target))) + ? new Proxy(stat, { + get(value, property) { + const mode = BigInt(Reflect.get(value, "mode", value)); + return property === "mode" ? mode & ~0o22n : Reflect.get(value, property, value); + }, + }) + : stat; + }) as typeof fs.lstatSync); +} + +function contextInput() { + return { + sandboxName: "alpha", + transactionId: TRANSACTION_ID, + createIntentSha256: CREATE_INTENT, + stateDir, + }; +} + +function transactionDirectory(): string { + const root = path.join(stateDir, "hermes-portable-build-context"); + const sandboxDirectories = fs.readdirSync(root); + expect(sandboxDirectories).toHaveLength(1); + return path.join(root, sandboxDirectories[0]!); +} + +function transactionArtifact(prefix: string): string { + const directory = transactionDirectory(); + const match = fs.readdirSync(directory).find((entry) => entry.startsWith(prefix)); + expect(match, `missing ${prefix} artifact`).toBeDefined(); + return path.join(directory, match!); +} + +function copyFixtureFile( + root: string, + relativePath: string, + mode: "100644" | "100755", + privateMode = false, +): void { + const target = path.join(root, relativePath); + fs.copyFileSync(path.join(ROOT, relativePath), target); + fs.chmodSync( + target, + mode === "100755" ? (privateMode ? 0o700 : 0o755) : privateMode ? 0o600 : 0o644, + ); +} + +function primaryCloneFixture(privateFileModes = false): string { + const requested = fs.mkdtempSync(path.join(stateDir, "primary-clone-")); + const root = fs.realpathSync(requested); + fs.chmodSync(root, 0o700); + for (const entry of HERMES_PORTABLE_BUILD_CONTEXT_FILES) { + const target = path.join(root, entry.path); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 }); + entry.mode === "160000" + ? fs.mkdirSync(target, { mode: 0o755 }) + : copyFixtureFile(root, entry.path, entry.mode, privateFileModes); + } + const git = path.join(root, ".git"); + const ref = path.join(git, "refs/heads"); + fs.mkdirSync(ref, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(git, "HEAD"), "ref: refs/heads/main\n", { mode: 0o600 }); + fs.writeFileSync(path.join(ref, "main"), `${"b".repeat(40)}\n`, { mode: 0o600 }); + return root; +} + +describe("Hermes portable staged build context", testTimeoutOptions(30_000), () => { + beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-context-test-")); + fs.chmodSync(stateDir, 0o700); + emulatePrivateSourceAncestor(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + it("publishes, reuses, and retires only the reviewed exact source context (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const first = plan.materialize(contextInput()); + + expect(first.dockerfilePath).toBe( + path.join(first.buildContextPath, "agents/hermes/Dockerfile"), + ); + expect(plan.authority.sourceRevision).toMatch(/^[a-f0-9]{40,64}$/u); + expect(plan.authority.contextManifestSha256).toMatch(/^[a-f0-9]{64}$/u); + const stagedDockerfile = fs.readFileSync(first.dockerfilePath, "utf8"); + expect(stagedDockerfile).toContain("ARG NEMOCLAW_MODEL=qwen3-vl:4b"); + expect(stagedDockerfile).toContain("ARG NEMOCLAW_INFERENCE_PROVIDER_ID=inference"); + expect(stagedDockerfile).toContain("ARG NEMOCLAW_UPSTREAM_PROVIDER=ollama-local"); + expect(stagedDockerfile).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=direct"); + expect(stagedDockerfile).toContain("ARG CHAT_UI_URL="); + expect(stagedDockerfile).not.toContain("ARG CHAT_UI_URL=http://127.0.0.1:18789"); + expect(fs.existsSync(path.join(first.buildContextPath, ".git"))).toBe(false); + expect(fs.existsSync(path.join(first.buildContextPath, "node_modules"))).toBe(false); + expect( + fs.existsSync( + path.join(first.buildContextPath, "src/lib/messaging/channels/wechat/contract.ts"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(first.buildContextPath, "src/lib/messaging/managed-startup-placeholders.ts"), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(first.buildContextPath, "agents/hermes/plugin/__pycache__")), + ).toBe(false); + + const reused = plan.materialize(contextInput()); + expect(reused.buildContextPath).toBe(first.buildContextPath); + reused.assertCurrent(); + + expect(plan.retire(contextInput())).toBe(true); + expect(fs.existsSync(first.buildContextPath)).toBe(false); + expect(plan.retire(contextInput())).toBe(true); + }); + + it("preserves and rejects a replaced or extended staged generation (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const staged = plan.materialize(contextInput()); + const foreign = path.join(staged.buildContextPath, "foreign.txt"); + fs.writeFileSync(foreign, "do not delete", { mode: 0o600 }); + + expect(() => staged.assertCurrent()).toThrow("membership changed"); + expect(() => plan.retire(contextInput())).toThrow(); + expect(fs.readFileSync(foreign, "utf8")).toBe("do not delete"); + }); + + it("reconciles a canonical authority hard-link crash without replacing context (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const staged = plan.materialize(contextInput()); + const authority = transactionArtifact("authority."); + const next = path.join( + path.dirname(authority), + `.${path.basename(authority).replace(/\.json$/u, ".next")}`, + ); + fs.linkSync(authority, next); + + const resumed = plan.materialize(contextInput()); + + expect(resumed.buildContextPath).toBe(staged.buildContextPath); + expect(fs.existsSync(next)).toBe(false); + resumed.assertCurrent(); + }); + + it("rebuilds only an exact same-transaction partial staged prefix (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const staged = plan.materialize(contextInput()); + fs.unlinkSync(transactionArtifact("authority.")); + const dockerfile = staged.dockerfilePath; + const original = fs.readFileSync(dockerfile); + fs.truncateSync(dockerfile, Math.max(1, Math.floor(original.byteLength / 2))); + + const resumed = plan.materialize(contextInput()); + + expect(fs.readFileSync(resumed.dockerfilePath)).toEqual(original); + resumed.assertCurrent(); + }); + + it("resumes after a partial staged-file write before authority publication (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const originalWrite = fs.writeSync; + let interrupted = false; + const writeSpy = vi.spyOn(fs, "writeSync").mockImplementation((( + descriptor: number, + buffer: Uint8Array, + offset: number, + length: number, + position: number, + ) => { + return !interrupted && length > 1 + ? (() => { + interrupted = true; + originalWrite(descriptor, buffer, offset, Math.floor(length / 2), position); + throw new Error("simulated staged write exit"); + })() + : originalWrite(descriptor, buffer, offset, length, position); + }) as typeof fs.writeSync); + expect(() => plan.materialize(contextInput())).toThrow("simulated staged write exit"); + writeSpy.mockRestore(); + + const resumed = plan.materialize(contextInput()); + resumed.assertCurrent(); + }); + + it("resumes retirement after the exact context was detached (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const staged = plan.materialize(contextInput()); + const retiring = path.join( + path.dirname(staged.buildContextPath), + path.basename(staged.buildContextPath).replace(/^context\./u, "retiring."), + ); + fs.renameSync(staged.buildContextPath, retiring); + + expect(plan.retire(contextInput())).toBe(true); + expect(fs.existsSync(staged.buildContextPath)).toBe(false); + expect(fs.existsSync(retiring)).toBe(false); + }); + + it("resumes exact per-entry retirement after an interrupted unlink (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const staged = plan.materialize(contextInput()); + const originalUnlink = fs.unlinkSync; + let interrupted = false; + const unlinkSpy = vi.spyOn(fs, "unlinkSync").mockImplementation((target) => { + return !interrupted && String(target).includes("retiring.") + ? (() => { + interrupted = true; + originalUnlink(target); + throw new Error("simulated retirement exit"); + })() + : originalUnlink(target); + }); + expect(() => plan.retire(contextInput())).toThrow("simulated retirement exit"); + unlinkSpy.mockRestore(); + + expect(plan.retire(contextInput())).toBe(true); + expect(fs.existsSync(staged.buildContextPath)).toBe(false); + }); + + it("preserves a staged symlink replacement during validation and retirement (#9203)", () => { + const plan = createHermesPortableBuildContextPlan(ROOT, BUILD_SETTINGS); + const staged = plan.materialize(contextInput()); + const target = staged.dockerfilePath; + const foreign = path.join(stateDir, "foreign-Dockerfile"); + fs.writeFileSync(foreign, "do not delete\n", { mode: 0o600 }); + fs.unlinkSync(target); + fs.symlinkSync(foreign, target); + + expect(() => staged.assertCurrent()).toThrow(); + expect(() => plan.retire(contextInput())).toThrow(); + expect(fs.readFileSync(foreign, "utf8")).toBe("do not delete\n"); + }); + + it("captures the exact allowlist from an ordinary primary Git clone (#9203)", () => { + const source = primaryCloneFixture(); + + const plan = createHermesPortableBuildContextPlan(source, BUILD_SETTINGS); + + expect(plan.authority.sourceRevision).toBe("b".repeat(40)); + expect(plan.authority.contextManifestSha256).toMatch(/^[a-f0-9]{64}$/u); + }); + + it("accepts a private installer checkout created under umask 077 (#9203)", () => { + const source = primaryCloneFixture(true); + + const plan = createHermesPortableBuildContextPlan(source, BUILD_SETTINGS); + + expect(plan.authority.sourceRevision).toBe("b".repeat(40)); + expect(plan.authority.contextManifestSha256).toMatch(/^[a-f0-9]{64}$/u); + }); + + it.each([ + { + relativePath: ".git", + mode: 0o775, + modeLabel: "0775", + error: "Git directory authority is unsafe", + }, + { + relativePath: ".git/HEAD", + mode: 0o664, + modeLabel: "0664", + error: "source revision evidence is unsafe", + }, + ])( + "rejects group-writable Git authority at $relativePath with mode $modeLabel (#9203)", + ({ relativePath, mode, error }) => { + const source = primaryCloneFixture(); + fs.chmodSync(path.join(source, relativePath), mode); + + expect(() => createHermesPortableBuildContextPlan(source, BUILD_SETTINGS)).toThrow(error); + }, + ); + + it.each([ + { access: "group", mode: 0o620 }, + { access: "other", mode: 0o602 }, + ])("rejects $access-write access on a source file (#9203)", ({ mode }) => { + const source = primaryCloneFixture(); + fs.chmodSync(path.join(source, "agents/hermes/Dockerfile"), mode); + + expect(() => createHermesPortableBuildContextPlan(source, BUILD_SETTINGS)).toThrow( + "source file authority is unsafe: agents/hermes/Dockerfile", + ); + }); + + it("rejects lowercase Dockerfile copy opcodes before reservation (#9203)", () => { + const source = primaryCloneFixture(); + const dockerfile = path.join(source, "agents/hermes/Dockerfile"); + fs.writeFileSync(dockerfile, fs.readFileSync(dockerfile, "utf8").replace(/^COPY /mu, "copy "), { + mode: 0o644, + }); + + expect(() => createHermesPortableBuildContextPlan(source, BUILD_SETTINGS)).toThrow( + "noncanonical COPY or ADD opcode", + ); + }); + + it("rejects source symlinks, hardlinks, and unreviewed secret paths (#9203)", () => { + const source = primaryCloneFixture(); + const script = path.join(source, "agents/hermes/start.sh"); + const original = fs.readFileSync(script); + fs.unlinkSync(script); + fs.symlinkSync(path.join(source, "agents/hermes/Dockerfile"), script); + expect(() => createHermesPortableBuildContextPlan(source, BUILD_SETTINGS)).toThrow( + "symlink or special entry", + ); + + fs.unlinkSync(script); + fs.writeFileSync(script, original, { mode: 0o755 }); + const link = path.join(stateDir, "start-link.sh"); + fs.linkSync(script, link); + expect(() => createHermesPortableBuildContextPlan(source, BUILD_SETTINGS)).toThrow(); + fs.unlinkSync(link); + + const secret = path.join(source, "nemoclaw-blueprint/secrets/token.json"); + fs.mkdirSync(path.dirname(secret), { recursive: true, mode: 0o755 }); + fs.writeFileSync(secret, "do not stage\n", { mode: 0o600 }); + expect(() => createHermesPortableBuildContextPlan(source, BUILD_SETTINGS)).toThrow(); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.ts b/src/lib/onboard/experimental/hermes-portable-build-context.ts new file mode 100644 index 00000000000..98a70c3a618 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-build-context.ts @@ -0,0 +1,1385 @@ +// 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 path from "node:path"; +import { TextDecoder } from "node:util"; + +import { + renderHermesPortableDockerfileBuildSettings, + type HermesPortableDockerfileBuildSettings, +} from "../dockerfile-patch"; +import { HERMES_PORTABLE_BUILD_CONTEXT_FILES } from "./hermes-portable-build-context-files"; + +const CONTEXT_SCHEMA_VERSION = 1 as const; +const MAX_CONTEXT_ENTRIES = 1024; +const MAX_CONTEXT_FILE_BYTES = 2 * 1024 * 1024; +const MAX_CONTEXT_TOTAL_BYTES = 16 * 1024 * 1024; +const MAX_RELATIVE_PATH_BYTES = 512; +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u; +const SHA256 = /^[a-f0-9]{64}$/u; +const REVISION = /^[a-f0-9]{40,64}$/u; +const TRANSACTION = /^[a-f0-9-]{36}$/u; +const UTF8 = new TextDecoder("utf-8", { fatal: true }); +const OPEN_READ_FLAGS = + fs.constants.O_RDONLY | + (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0) | + (typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0); + +const LOCAL_COPY_SOURCES = [ + "agents/hermes/build-mcp-digest.py", + "agents/hermes/config/", + "agents/hermes/cron-restore-control.py", + "agents/hermes/finalize-tirith-marker.py", + "agents/hermes/generate-config.ts", + "agents/hermes/hermes-cli-adapter-v1.json", + "agents/hermes/hermes-wrapper.py", + "agents/hermes/host/managed-tool-gateway-matrix.json", + "agents/hermes/image-build-probes.py", + "agents/hermes/managed_policy.py", + "agents/hermes/mcp-config-transaction.py", + "agents/hermes/patch-cron-execution-runtime.py", + "agents/hermes/patch-cron-restore-drain.py", + "agents/hermes/patch-discord-recovery-permissions.py", + "agents/hermes/patch-gateway-process-identity.py", + "agents/hermes/patch-gateway-runtime-metadata.py", + "agents/hermes/patch-hermes-sqlite-temp-store.py", + "agents/hermes/patch-langfuse-credentials.mts", + "agents/hermes/patch-neutral-platform-env-activation.py", + "agents/hermes/patch-profile-policy-defaults.py", + "agents/hermes/patch-session-list-preview.py", + "agents/hermes/plugin/", + "agents/hermes/runtime-config-guard.py", + "agents/hermes/runtime-state-mutation-publisher-v1.json", + "agents/hermes/security-dependencies.patch", + "agents/hermes/seed-dashboard-config.py", + "agents/hermes/start.sh", + "agents/hermes/state-lock-plan.json", + "agents/hermes/validate-cli-adapter.py", + "agents/hermes/validate-env-secret-boundary.py", + "nemoclaw-blueprint/", + "nemoclaw-blueprint/scripts/*.js", + "scripts/gateway-control.sh", + "scripts/lib/bundled-npm-package.mts", + "scripts/lib/corporate-ca-runtime.sh", + "scripts/lib/entrypoint-env-wrapper.sh", + "scripts/lib/gateway-supervisor.sh", + "scripts/lib/openclaw-npm-remediation.mts", + "scripts/lib/patch-bundled-npm-ip-address.mts", + "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/sandbox-init.sh", + "scripts/lib/sandbox-rlimits.sh", + "scripts/managed-bootstrap-entrypoint.c", + "scripts/managed-bootstrap-trampoline.sh", + "scripts/managed-gateway-control.py", + "scripts/managed-startup-hold.sh", + "scripts/patch-bundled-npm-brace-expansion.mts", + "scripts/patch-bundled-npm-tar.mts", + "scripts/runtime-state-mutation-control.py", + "scripts/runtime-state-mutation-startup-gate.py", + "scripts/runtime_state_mutation_hermes_publisher.py", + "scripts/state-dir-guard.py", + "src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.101.json", + "src/lib/hermes-managed-route.ts", + "src/lib/messaging/", + "src/lib/tool-disclosure.ts", + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle", + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json", + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/THIRD_PARTY_LICENSES.txt", + "tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/mcp-tool-discovery.bundle", +] as const; + +type SourceEntry = { + readonly kind: "directory" | "file"; + readonly relativePath: string; + readonly mode: number; + readonly device: string; + readonly inode: string; + readonly modifiedTimeNanoseconds: string; + readonly changedTimeNanoseconds: string; + readonly size?: number; + readonly sha256?: string; + readonly bytes?: Buffer; +}; + +type RevisionEvidence = { + readonly revision: string; + readonly files: readonly { + readonly pathSha256: string; + readonly bytesSha256: string; + readonly device: string; + readonly inode: string; + readonly size: string; + readonly modifiedTimeNanoseconds: string; + readonly changedTimeNanoseconds: string; + }[]; +}; + +type DirectoryEvidence = { + readonly pathSha256: string; + readonly device: string; + readonly inode: string; + readonly mode: string; + readonly ownerUid: string; +}; + +export interface HermesPortableBuildContextAuthority { + readonly schemaVersion: typeof CONTEXT_SCHEMA_VERSION; + readonly sourceRevision: string; + readonly dockerfileRelativePath: "agents/hermes/Dockerfile"; + readonly sourceManifestSha256: string; + readonly contextManifestSha256: string; +} + +export type HermesPortableBuildContextSettings = HermesPortableDockerfileBuildSettings; + +export interface HermesPortableBuildContextPlan { + readonly authority: HermesPortableBuildContextAuthority; + readonly sourceDockerfilePath: string; + assertCurrentSource(): void; + materialize(input: { + readonly sandboxName: string; + readonly transactionId: string; + readonly createIntentSha256: string; + readonly stateDir: string; + }): HermesPortableStagedBuildContext; + retire(input: { + readonly sandboxName: string; + readonly transactionId: string; + readonly createIntentSha256: string; + readonly stateDir: string; + }): boolean; +} + +export interface HermesPortableStagedBuildContext { + readonly buildContextPath: string; + readonly dockerfilePath: string; + assertCurrent(): void; +} + +type StagedEntry = Omit< + SourceEntry, + "bytes" | "modifiedTimeNanoseconds" | "changedTimeNanoseconds" +>; + +type StagedAuthority = { + readonly schemaVersion: typeof CONTEXT_SCHEMA_VERSION; + readonly transactionId: string; + readonly createIntentSha256: string; + readonly contextManifestSha256: string; + readonly contextPath: string; + readonly entries: readonly StagedEntry[]; +}; + +function fail(message: string): never { + throw new Error(`Hermes portable build context ${message}`); +} + +function currentUid(): bigint { + const uid = process.getuid?.(); + if (uid === undefined) fail("requires current-user filesystem authority"); + return BigInt(uid); +} + +function digest(value: Buffer | string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function canonicalDigest(value: unknown): string { + return digest(JSON.stringify(value)); +} + +function requireSafeRelativePath(relativePath: string): void { + if ( + !relativePath || + Buffer.byteLength(relativePath) > MAX_RELATIVE_PATH_BYTES || + CONTROL.test(relativePath) || + path.posix.normalize(relativePath) !== relativePath || + relativePath.startsWith("/") || + relativePath.split("/").includes("..") + ) { + fail("contains an invalid source path"); + } + const parts = relativePath.split("/"); + const basename = parts.at(-1)!; + if ( + parts.some((part) => + [".git", ".hg", ".svn", "node_modules", "dist", "coverage"].includes(part), + ) || + parts.some((part) => part === ".env" || part.startsWith(".env.")) || + parts.some((part) => [".direnv", ".ssh", "secrets"].includes(part)) || + [".envrc", ".npmrc", ".netrc", ".pypirc", ".credentials"].includes(basename) || + [".key", ".pem", ".pfx", ".p12", ".jks", ".keystore", ".tfvars"].some((suffix) => + basename.endsWith(suffix), + ) || + ["_ecdsa", "_ed25519", "_rsa"].some((suffix) => basename.endsWith(suffix)) || + ["credentials.json", "key.json", "secrets.json", "secrets.yaml", "token.json"].includes( + basename, + ) || + /^service-account.*\.json$/u.test(basename) + ) { + fail("contains a prohibited generated or environment path"); + } +} + +function isIgnoredCachePath(relativePath: string): boolean { + const parts = relativePath.split("/"); + return parts.includes("__pycache__") || parts.includes(".cache") || relativePath.endsWith(".pyc"); +} + +function sourceTokenMatches(relativePath: string, token: string): boolean { + if (token.endsWith("/")) return relativePath.startsWith(token); + if (token === "nemoclaw-blueprint/scripts/*.js") { + return /^nemoclaw-blueprint\/scripts\/[^/]+\.js$/u.test(relativePath); + } + return relativePath === token; +} + +function parseDockerfileSources(bytes: Buffer): readonly string[] { + let text: string; + try { + text = UTF8.decode(bytes); + } catch { + fail("Dockerfile is not strict UTF-8"); + } + const local: string[] = []; + for (const rawLine of text.split("\n")) { + const line = rawLine.trim(); + if (!/^(?:COPY|ADD)\s/iu.test(line)) continue; + if (!/^(?:COPY|ADD)\s/u.test(line)) { + fail("Dockerfile uses a noncanonical COPY or ADD opcode"); + } + if (line.endsWith("\\") || line.includes("[")) { + fail("Dockerfile uses an unsupported COPY or ADD grammar"); + } + const tokens = line.split(/\s+/u); + const command = tokens.shift(); + const options: string[] = []; + while (tokens[0]?.startsWith("--")) options.push(tokens.shift()!); + if (tokens.length < 2) fail("Dockerfile has an incomplete COPY or ADD instruction"); + const sources = tokens.slice(0, -1); + if (command === "ADD") { + if ( + sources.length !== 1 || + !sources[0]!.startsWith("https://files.pythonhosted.org/") || + options.length !== 2 || + options[0] !== "--chmod=0444" || + !/^--checksum=sha256:[a-f0-9]{64}$/u.test(options[1]!) + ) { + fail("Dockerfile has an unsupported local or unpinned ADD instruction"); + } + continue; + } + const from = options.find((option) => option.startsWith("--from=")); + if (from) { + if (options.length !== 1 || !/^--from=[a-z0-9][a-z0-9-]*$/u.test(from)) { + fail("Dockerfile has an unsupported cross-stage COPY instruction"); + } + continue; + } + if (options.some((option) => option !== "--chmod=0444")) { + fail("Dockerfile has an unsupported local COPY option"); + } + local.push(...sources); + } + const expected = [...LOCAL_COPY_SOURCES].sort(); + const actual = [...local].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail("Dockerfile local COPY sources disagree with the reviewed allowlist"); + } + return local; +} + +function readRevisionFile(filePath: string): { + readonly bytes: Buffer; + readonly evidence: RevisionEvidence["files"][number]; +} { + const named = fs.lstatSync(filePath, { bigint: true }); + const descriptor = fs.openSync(filePath, OPEN_READ_FLAGS); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + named.isSymbolicLink() || + before.uid !== currentUid() || + before.nlink !== 1n || + (before.mode & 0o22n) !== 0n || + before.size < 1n || + before.size > 4096n || + named.dev !== before.dev || + named.ino !== before.ino + ) { + fail("source revision evidence is unsafe"); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + fail("source revision evidence changed while reading"); + } + return { + bytes, + evidence: { + pathSha256: digest(path.resolve(filePath)), + bytesSha256: digest(bytes), + device: String(before.dev), + inode: String(before.ino), + size: String(before.size), + modifiedTimeNanoseconds: String(before.mtimeNs), + changedTimeNanoseconds: String(before.ctimeNs), + }, + }; + } finally { + fs.closeSync(descriptor); + } +} + +function strictText(bytes: Buffer, label: string): string { + try { + return UTF8.decode(bytes).trim(); + } catch { + fail(`${label} is not strict UTF-8`); + } +} + +function requireGitDirectory(directory: string): void { + const stat = fs.lstatSync(directory, { bigint: true }); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== currentUid() || + (stat.mode & 0o22n) !== 0n || + fs.realpathSync(directory) !== directory + ) { + fail("Git directory authority is unsafe"); + } +} + +function readPackedRevision( + commonDirectory: string, + reference: string, +): { readonly revision: string; readonly evidence: RevisionEvidence["files"][number] } { + const packed = readRevisionFile(path.join(commonDirectory, "packed-refs")); + const text = strictText(packed.bytes, "Git packed references"); + let revision: string | null = null; + for (const line of text.split("\n")) { + if (!line || line.startsWith("#") || line.startsWith("^")) continue; + const match = /^([a-f0-9]{40,64}) (refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+)$/u.exec(line); + if (!match) fail("Git packed references are invalid"); + if (match[2] === reference) { + if (revision) fail("Git packed reference is ambiguous"); + revision = match[1]!; + } + } + if (!revision) fail("Git revision is unavailable"); + return { revision, evidence: packed.evidence }; +} + +function captureGitRevision( + gitDirectory: string, + initialEvidence: readonly RevisionEvidence["files"][number][], +): RevisionEvidence { + requireGitDirectory(gitDirectory); + const head = readRevisionFile(path.join(gitDirectory, "HEAD")); + const headText = strictText(head.bytes, "Git HEAD"); + if (REVISION.test(headText)) { + return { revision: headText, files: [...initialEvidence, head.evidence] }; + } + const ref = /^ref: (refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+)$/u.exec(headText)?.[1]; + if (!ref || ref.includes("..")) fail("Git HEAD reference is invalid"); + const commonDirectoryFile = path.join(gitDirectory, "commondir"); + const commonDirectoryRead = fs.existsSync(commonDirectoryFile) + ? readRevisionFile(commonDirectoryFile) + : null; + const commonDirectory = commonDirectoryRead + ? path.resolve(gitDirectory, strictText(commonDirectoryRead.bytes, "Git common directory")) + : gitDirectory; + requireGitDirectory(commonDirectory); + const loosePath = path.join(commonDirectory, ref); + if (fs.existsSync(loosePath)) { + const loose = readRevisionFile(loosePath); + const revision = strictText(loose.bytes, "Git revision"); + if (!REVISION.test(revision)) fail("Git revision is invalid"); + return { + revision, + files: [ + ...initialEvidence, + head.evidence, + ...(commonDirectoryRead ? [commonDirectoryRead.evidence] : []), + loose.evidence, + ], + }; + } + const packed = readPackedRevision(commonDirectory, ref); + return { + revision: packed.revision, + files: [ + ...initialEvidence, + head.evidence, + ...(commonDirectoryRead ? [commonDirectoryRead.evidence] : []), + packed.evidence, + ], + }; +} + +function captureSourceRevision(rootPath: string): RevisionEvidence { + const stamped = path.join(rootPath, ".source-revision"); + if (fs.existsSync(stamped)) { + const read = readRevisionFile(stamped); + const revision = strictText(read.bytes, "source revision"); + if (!REVISION.test(revision)) fail("source revision is invalid"); + return { revision, files: [read.evidence] }; + } + const gitPointerPath = path.join(rootPath, ".git"); + const gitPointerStat = fs.lstatSync(gitPointerPath, { bigint: true }); + if (gitPointerStat.isDirectory() && !gitPointerStat.isSymbolicLink()) { + return captureGitRevision(gitPointerPath, []); + } + const pointer = readRevisionFile(gitPointerPath); + const pointerText = strictText(pointer.bytes, "Git directory pointer"); + const match = /^gitdir: (.+)$/u.exec(pointerText); + if (!match) fail("Git directory pointer is invalid"); + const gitDirectory = path.resolve(rootPath, match[1]!); + return captureGitRevision(gitDirectory, [pointer.evidence]); +} + +function stableDirectoryMembers(absolutePath: string, relativePath: string): readonly string[] { + const before = fs.lstatSync(absolutePath, { bigint: true }); + if ( + !before.isDirectory() || + before.isSymbolicLink() || + before.uid !== currentUid() || + (before.mode & 0o22n) !== 0n + ) { + fail("source directory authority is unsafe"); + } + const members = fs.readdirSync(absolutePath).sort(); + const after = fs.lstatSync(absolutePath, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.uid !== after.uid || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + fail(`source directory changed while reading: ${relativePath}`); + } + return members; +} + +function captureDirectoryChain(directory: string): readonly DirectoryEvidence[] { + const uid = currentUid(); + const chain: DirectoryEvidence[] = []; + let current = directory; + for (;;) { + const named = fs.lstatSync(current, { bigint: true }); + if ( + !named.isDirectory() || + named.isSymbolicLink() || + (named.uid !== 0n && named.uid !== uid) || + (named.mode & 0o22n) !== 0n || + fs.realpathSync(current) !== current + ) { + fail("source root directory chain is unsafe"); + } + chain.push({ + pathSha256: digest(current), + device: String(named.dev), + inode: String(named.ino), + mode: String(named.mode), + ownerUid: String(named.uid), + }); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return chain; +} + +function sourceFileModeMatchesGitMode(mode: bigint, expectedMode: "100644" | "100755"): boolean { + const actualMode = Number(mode & 0o777n); + const checkoutMode = expectedMode === "100755" ? 0o755 : 0o644; + const privateCheckoutMode = expectedMode === "100755" ? 0o700 : 0o600; + return ( + (actualMode & 0o22) === 0 && (actualMode === checkoutMode || actualMode === privateCheckoutMode) + ); +} + +function readSourceFile( + rootPath: string, + relativePath: string, + expectedMode: "100644" | "100755", +): SourceEntry { + const absolutePath = path.join(rootPath, relativePath); + const named = fs.lstatSync(absolutePath, { bigint: true }); + const descriptor = fs.openSync(absolutePath, OPEN_READ_FLAGS); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !named.isFile() || + named.isSymbolicLink() || + !before.isFile() || + before.isSymbolicLink() || + named.dev !== before.dev || + named.ino !== before.ino || + before.uid !== currentUid() || + before.nlink !== 1n || + !sourceFileModeMatchesGitMode(before.mode, expectedMode) || + before.size < 1n || + before.size > BigInt(MAX_CONTEXT_FILE_BYTES) + ) { + fail(`source file authority is unsafe: ${relativePath}`); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const finalNamed = fs.lstatSync(absolutePath, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.uid !== after.uid || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + finalNamed.dev !== after.dev || + finalNamed.ino !== after.ino || + BigInt(bytes.byteLength) !== after.size + ) { + fail(`source file disagrees with the accepted revision: ${relativePath}`); + } + return { + kind: "file", + relativePath, + mode: Number(before.mode & 0o777n), + device: String(before.dev), + inode: String(before.ino), + modifiedTimeNanoseconds: String(before.mtimeNs), + changedTimeNanoseconds: String(before.ctimeNs), + size: bytes.byteLength, + sha256: digest(bytes), + bytes, + }; + } finally { + fs.closeSync(descriptor); + } +} + +function captureSourceEntries( + rootPath: string, + tracked: ReadonlyMap, +): readonly SourceEntry[] { + const selected = new Set(tracked.keys()); + const entries: SourceEntry[] = []; + let totalBytes = 0; + const addStructuralParents = (relativePath: string): void => { + const parts = relativePath.split("/").slice(0, -1); + let current = ""; + for (const part of parts) { + current = current ? `${current}/${part}` : part; + if (entries.some((entry) => entry.relativePath === current)) continue; + const absolutePath = path.join(rootPath, current); + stableDirectoryMembers(absolutePath, current); + const stat = fs.lstatSync(absolutePath, { bigint: true }); + entries.push({ + kind: "directory", + relativePath: current, + mode: Number(stat.mode & 0o777n), + device: String(stat.dev), + inode: String(stat.ino), + modifiedTimeNanoseconds: String(stat.mtimeNs), + changedTimeNanoseconds: String(stat.ctimeNs), + }); + } + }; + const visit = (relativePath: string): void => { + if (isIgnoredCachePath(relativePath)) return; + requireSafeRelativePath(relativePath); + addStructuralParents(relativePath); + const absolutePath = path.join(rootPath, relativePath); + const stat = fs.lstatSync(absolutePath, { bigint: true }); + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) { + fail(`source contains a symlink or special entry: ${relativePath}`); + } + if (stat.isDirectory()) { + const members = stableDirectoryMembers(absolutePath, relativePath); + entries.push({ + kind: "directory", + relativePath, + mode: Number(stat.mode & 0o777n), + device: String(stat.dev), + inode: String(stat.ino), + modifiedTimeNanoseconds: String(stat.mtimeNs), + changedTimeNanoseconds: String(stat.ctimeNs), + }); + for (const member of members) { + const child = `${relativePath}/${member}`; + if (isIgnoredCachePath(child)) continue; + const trackedHere = selected.has(child); + const trackedBelow = [...selected].some((entry) => entry.startsWith(`${child}/`)); + if (!trackedHere && !trackedBelow) + fail(`source directory contains an unreviewed entry: ${child}`); + visit(child); + } + return; + } + const expectedMode = tracked.get(relativePath); + if (!expectedMode || expectedMode === "160000") { + fail(`source file is not part of the accepted revision: ${relativePath}`); + } + const entry = readSourceFile(rootPath, relativePath, expectedMode); + totalBytes += entry.size!; + if (totalBytes > MAX_CONTEXT_TOTAL_BYTES) fail("exceeds the bounded total byte limit"); + entries.push(entry); + }; + + visit("agents/hermes/Dockerfile"); + for (const token of LOCAL_COPY_SOURCES) { + if (token.endsWith("/")) { + visit(token.slice(0, -1)); + } else if (token === "nemoclaw-blueprint/scripts/*.js") { + for (const relativePath of [...selected] + .filter((entry) => sourceTokenMatches(entry, token)) + .sort()) { + if (!entries.some((entry) => entry.relativePath === relativePath)) visit(relativePath); + } + } else if (!entries.some((entry) => entry.relativePath === token)) { + visit(token); + } + } + const unique = new Map(entries.map((entry) => [entry.relativePath, entry])); + const ordered = [...unique.values()].sort((left, right) => + left.relativePath.localeCompare(right.relativePath), + ); + if (ordered.length > MAX_CONTEXT_ENTRIES) fail("exceeds the bounded entry limit"); + return ordered; +} + +function sourceAuthority( + sourceEntries: readonly SourceEntry[], + contextEntries: readonly SourceEntry[], + revision: RevisionEvidence, + sourceDirectoryChain: readonly DirectoryEvidence[], +): HermesPortableBuildContextAuthority { + const serializable = sourceEntries.map(({ bytes: _bytes, ...entry }) => entry); + const sourceManifestSha256 = canonicalDigest({ + revision, + sourceDirectoryChain, + entries: serializable, + }); + const contextManifestSha256 = canonicalDigest({ + schemaVersion: CONTEXT_SCHEMA_VERSION, + dockerfileRelativePath: "agents/hermes/Dockerfile", + entries: contextEntries.map((entry) => ({ + kind: entry.kind, + relativePath: entry.relativePath, + mode: entry.mode, + ...(entry.kind === "file" ? { size: entry.size, sha256: entry.sha256 } : {}), + })), + }); + return { + schemaVersion: CONTEXT_SCHEMA_VERSION, + sourceRevision: revision.revision, + dockerfileRelativePath: "agents/hermes/Dockerfile", + sourceManifestSha256, + contextManifestSha256, + }; +} + +function renderContextEntries( + sourceEntries: readonly SourceEntry[], + settings: HermesPortableBuildContextSettings, +): readonly SourceEntry[] { + return sourceEntries.map((entry) => { + if (entry.kind !== "file" || entry.relativePath !== "agents/hermes/Dockerfile") return entry; + const bytes = Buffer.from( + renderHermesPortableDockerfileBuildSettings(UTF8.decode(entry.bytes!), settings), + "utf8", + ); + return { ...entry, bytes, size: bytes.byteLength, sha256: digest(bytes) }; + }); +} + +function capture( + rootPath: string, + settings: HermesPortableBuildContextSettings, +): { + readonly authority: HermesPortableBuildContextAuthority; + readonly sourceEntries: readonly SourceEntry[]; + readonly contextEntries: readonly SourceEntry[]; +} { + if (!path.isAbsolute(rootPath) || fs.realpathSync(rootPath) !== rootPath) + fail("source root is invalid"); + const revision = captureSourceRevision(rootPath); + const sourceDirectoryChain = captureDirectoryChain(rootPath); + const tracked = new Map( + HERMES_PORTABLE_BUILD_CONTEXT_FILES.map((entry) => [entry.path, entry.mode] as const), + ); + const sourceEntries = captureSourceEntries(rootPath, tracked); + const dockerfile = sourceEntries.find( + (entry) => entry.relativePath === "agents/hermes/Dockerfile", + ); + if (dockerfile?.kind !== "file" || !dockerfile.bytes) fail("Dockerfile source is unavailable"); + parseDockerfileSources(dockerfile.bytes); + const contextEntries = renderContextEntries(sourceEntries, settings); + return { + authority: sourceAuthority(sourceEntries, contextEntries, revision, sourceDirectoryChain), + sourceEntries, + contextEntries, + }; +} + +function contextPaths( + stateDir: string, + sandboxName: string, + transactionId: string, + intent: string, +) { + if (!TRANSACTION.test(transactionId) || !SHA256.test(intent)) + fail("transaction identity is invalid"); + const sandbox = digest(sandboxName); + const root = path.join(stateDir, "hermes-portable-build-context"); + const directory = path.join(root, sandbox); + const suffix = `${transactionId}.${intent}`; + return { + root, + directory, + context: path.join(directory, `context.${suffix}`), + retiring: path.join(directory, `retiring.${suffix}`), + authority: path.join(directory, `authority.${suffix}.json`), + authorityNext: path.join(directory, `.authority.${suffix}.next`), + retired: path.join(directory, `retired.${suffix}.json`), + }; +} + +function ensurePrivateDirectory(directory: string, create: boolean): fs.BigIntStats { + create && fs.mkdirSync(directory, { mode: 0o700 }); + const stat = fs.lstatSync(directory, { bigint: true }); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== currentUid() || + (stat.mode & 0o777n) !== 0o700n + ) { + fail("transaction directory authority is unsafe"); + } + return stat; +} + +function fsyncDirectory(directory: string): void { + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function prepareTransactionDirectory(paths: ReturnType): void { + const stateDirectory = path.dirname(paths.root); + const state = fs.lstatSync(stateDirectory, { bigint: true }); + if ( + !state.isDirectory() || + state.isSymbolicLink() || + state.uid !== currentUid() || + (state.mode & 0o22n) !== 0n + ) { + fail("state directory authority is unsafe"); + } + if (!fs.existsSync(paths.root)) ensurePrivateDirectory(paths.root, true); + else ensurePrivateDirectory(paths.root, false); + if (!fs.existsSync(paths.directory)) { + ensurePrivateDirectory(paths.directory, true); + fsyncDirectory(paths.root); + } else { + ensurePrivateDirectory(paths.directory, false); + } + if (fs.readdirSync(paths.directory).length > 6) + fail("transaction directory has ambiguous entries"); +} + +function writeAll(descriptor: number, bytes: Buffer): void { + let offset = 0; + while (offset < bytes.byteLength) { + const written = fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset, offset); + if (written < 1) fail("staged file write made no progress"); + offset += written; + } +} + +function stageEntries( + contextPath: string, + entries: readonly SourceEntry[], +): StagedAuthority["entries"] { + fs.mkdirSync(contextPath, { mode: 0o700 }); + const staged: StagedEntry[] = []; + for (const entry of entries) { + const target = path.join(contextPath, entry.relativePath); + if (entry.kind === "directory") { + fs.mkdirSync(target, { recursive: false, mode: entry.mode }); + fs.chmodSync(target, entry.mode); + } else { + const parent = path.dirname(target); + if (!fs.existsSync(parent)) fail("staged file parent is absent"); + const descriptor = fs.openSync( + target, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW, + entry.mode, + ); + try { + fs.fchmodSync(descriptor, entry.mode); + writeAll(descriptor, entry.bytes!); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + } + const stat = fs.lstatSync(target, { bigint: true }); + staged.push({ + kind: entry.kind, + relativePath: entry.relativePath, + mode: Number(stat.mode & 0o777n), + device: String(stat.dev), + inode: String(stat.ino), + ...(entry.kind === "file" ? { size: entry.size, sha256: entry.sha256 } : {}), + }); + } + for (const entry of [...entries].reverse()) { + if (entry.kind === "directory") fsyncDirectory(path.join(contextPath, entry.relativePath)); + } + fsyncDirectory(contextPath); + return staged; +} + +function readPrivateFile(filePath: string, allowedLinks = 1n): Buffer { + const named = fs.lstatSync(filePath, { bigint: true }); + const descriptor = fs.openSync(filePath, OPEN_READ_FLAGS); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + named.isSymbolicLink() || + before.uid !== currentUid() || + (before.mode & 0o777n) !== 0o600n || + before.nlink !== allowedLinks || + named.dev !== before.dev || + named.ino !== before.ino || + before.size > 1024n * 1024n + ) { + fail("transaction evidence file is unsafe"); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + fail("transaction evidence changed while reading"); + } + return bytes; + } finally { + fs.closeSync(descriptor); + } +} + +function publishPrivateEvidence( + canonicalPath: string, + nextPath: string, + bytes: Buffer, + parent: string, +): void { + if (!fs.existsSync(canonicalPath)) { + if (!fs.existsSync(nextPath)) { + const descriptor = fs.openSync( + nextPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW, + 0o600, + ); + try { + fs.fchmodSync(descriptor, 0o600); + writeAll(descriptor, bytes); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + } + if (!readPrivateFile(nextPath).equals(bytes)) fail("staged transaction evidence disagrees"); + fs.linkSync(nextPath, canonicalPath); + fsyncDirectory(parent); + } + const nextExists = fs.existsSync(nextPath); + const canonical = readPrivateFile(canonicalPath, nextExists ? 2n : 1n); + if (!canonical.equals(bytes)) fail("published transaction evidence disagrees"); + if (nextExists) { + const left = fs.lstatSync(nextPath, { bigint: true }); + const right = fs.lstatSync(canonicalPath, { bigint: true }); + if (left.dev !== right.dev || left.ino !== right.ino) fail("transaction evidence is ambiguous"); + fs.unlinkSync(nextPath); + fsyncDirectory(parent); + } +} + +function serializeStagedAuthority(authority: StagedAuthority): Buffer { + return Buffer.from(`${JSON.stringify(authority)}\n`, "utf8"); +} + +function parseStagedAuthority(bytes: Buffer): StagedAuthority { + let value: unknown; + try { + value = JSON.parse(UTF8.decode(bytes)); + } catch { + fail("staged authority is malformed"); + } + const authority = value as Partial; + if ( + authority.schemaVersion !== CONTEXT_SCHEMA_VERSION || + !TRANSACTION.test(String(authority.transactionId)) || + !SHA256.test(String(authority.createIntentSha256)) || + !SHA256.test(String(authority.contextManifestSha256)) || + typeof authority.contextPath !== "string" || + !Array.isArray(authority.entries) || + authority.entries.length < 2 || + authority.entries.length > MAX_CONTEXT_ENTRIES + 1 || + !path.isAbsolute(authority.contextPath) || + CONTROL.test(authority.contextPath) + ) { + fail("staged authority has invalid identity fields"); + } + const seen = new Set(); + for (const candidate of authority.entries) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { + fail("staged authority has an invalid entry"); + } + const entry = candidate as Partial; + const keys = Object.keys(entry).sort(); + const expectedKeys = [ + "device", + "inode", + "kind", + "mode", + "relativePath", + ...(entry.kind === "file" ? ["sha256", "size"] : []), + ].sort(); + if (typeof entry.relativePath === "string" && entry.relativePath !== ".") { + requireSafeRelativePath(entry.relativePath); + } + if ( + JSON.stringify(keys) !== JSON.stringify(expectedKeys) || + (entry.kind !== "directory" && entry.kind !== "file") || + typeof entry.relativePath !== "string" || + seen.has(entry.relativePath) || + !Number.isInteger(entry.mode) || + entry.mode! < 0 || + entry.mode! > 0o777 || + !/^[0-9]{1,40}$/u.test(String(entry.device)) || + !/^[0-9]{1,40}$/u.test(String(entry.inode)) || + (entry.kind === "file" && + (!Number.isSafeInteger(entry.size) || + entry.size! < 1 || + entry.size! > MAX_CONTEXT_FILE_BYTES || + !SHA256.test(String(entry.sha256)))) + ) { + fail("staged authority has an invalid entry"); + } + seen.add(entry.relativePath); + } + if (authority.entries[0]?.relativePath !== ".") fail("staged authority omits its root"); + return authority as StagedAuthority; +} + +function assertStagedContext( + contextPath: string, + expected: StagedAuthority, + sourceEntries: readonly SourceEntry[], +): void { + const root = ensurePrivateDirectory(contextPath, false); + const actualPaths = new Set(); + const visit = (relativePath: string): void => { + const directory = relativePath ? path.join(contextPath, relativePath) : contextPath; + for (const member of stableDirectoryMembers(directory, relativePath || "context")) { + const child = relativePath ? `${relativePath}/${member}` : member; + actualPaths.add(child); + const stat = fs.lstatSync(path.join(contextPath, child), { bigint: true }); + stat.isDirectory() && visit(child); + } + }; + visit(""); + const expectedEntries = expected.entries.filter((entry) => entry.relativePath !== "."); + if (expectedEntries.length !== sourceEntries.length) { + fail("staged authority does not cover the complete source manifest"); + } + for (const source of sourceEntries) { + const staged = expectedEntries.find((entry) => entry.relativePath === source.relativePath); + if ( + !staged || + staged.kind !== source.kind || + staged.mode !== source.mode || + (source.kind === "file" && (staged.size !== source.size || staged.sha256 !== source.sha256)) + ) { + fail("staged authority disagrees with the source manifest"); + } + } + if (actualPaths.size !== expectedEntries.length) fail("staged context membership changed"); + for (const expectedEntry of expectedEntries) { + if (!actualPaths.has(expectedEntry.relativePath)) fail("staged context omits an entry"); + const source = sourceEntries.find((entry) => entry.relativePath === expectedEntry.relativePath); + if (!source || source.kind !== expectedEntry.kind) + fail("staged context authority is incomplete"); + const target = path.join(contextPath, expectedEntry.relativePath); + const stat = fs.lstatSync(target, { bigint: true }); + if ( + stat.uid !== currentUid() || + String(stat.dev) !== expectedEntry.device || + String(stat.ino) !== expectedEntry.inode || + Number(stat.mode & 0o777n) !== expectedEntry.mode || + (stat.isFile() && stat.nlink !== 1n) || + (expectedEntry.kind === "directory" ? !stat.isDirectory() : !stat.isFile()) + ) { + fail("staged context identity changed"); + } + if (expectedEntry.kind === "file") { + const bytes = readPinnedStagedFile(target, expectedEntry); + if (!bytes.equals(source.bytes!)) fail("staged context bytes disagree with source authority"); + } + } + if ( + String(root.dev) !== pathRootIdentity(expected).device || + String(root.ino) !== pathRootIdentity(expected).inode + ) { + fail("staged context root identity changed"); + } +} + +function pathRootIdentity(authority: StagedAuthority): { + readonly device: string; + readonly inode: string; +} { + const root = authority.entries.find((entry) => entry.relativePath === "."); + if (!root) fail("staged authority omits its root identity"); + return { device: root.device, inode: root.inode }; +} + +function readPinnedStagedFile(filePath: string, expected: StagedEntry): Buffer { + const descriptor = fs.openSync(filePath, OPEN_READ_FLAGS); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + before.nlink !== 1n || + String(before.dev) !== expected.device || + String(before.ino) !== expected.inode || + before.size !== BigInt(expected.size!) + ) { + fail("staged file identity changed"); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + digest(bytes) !== expected.sha256 + ) { + fail("staged file bytes changed"); + } + return bytes; + } finally { + fs.closeSync(descriptor); + } +} + +function stagedAuthority( + contextPath: string, + entries: readonly StagedEntry[], + transactionId: string, + createIntentSha256: string, + contextManifestSha256: string, +): StagedAuthority { + const root = fs.lstatSync(contextPath, { bigint: true }); + return { + schemaVersion: CONTEXT_SCHEMA_VERSION, + transactionId, + createIntentSha256, + contextManifestSha256, + contextPath, + entries: [ + { + kind: "directory", + relativePath: ".", + mode: Number(root.mode & 0o777n), + device: String(root.dev), + inode: String(root.ino), + }, + ...entries, + ], + }; +} + +function removeExactContextTree( + contextPath: string, + sourceEntries: readonly SourceEntry[], + persisted: StagedAuthority | null, +): void { + const sources = new Map(sourceEntries.map((entry) => [entry.relativePath, entry])); + const staged = new Map( + (persisted?.entries ?? []) + .filter((entry) => entry.relativePath !== ".") + .map((entry) => [entry.relativePath, entry]), + ); + const rootBefore = fs.lstatSync(contextPath, { bigint: true }); + const expectedRoot = persisted ? pathRootIdentity(persisted) : null; + if ( + !rootBefore.isDirectory() || + rootBefore.isSymbolicLink() || + rootBefore.uid !== currentUid() || + (rootBefore.mode & 0o777n) !== 0o700n || + (expectedRoot && + (String(rootBefore.dev) !== expectedRoot.device || + String(rootBefore.ino) !== expectedRoot.inode)) + ) { + fail("context cleanup root identity is unsafe"); + } + const removeDirectory = (relativePath: string): void => { + const directory = relativePath ? path.join(contextPath, relativePath) : contextPath; + const directoryBefore = fs.lstatSync(directory, { bigint: true }); + const expectedDirectory = relativePath ? staged.get(relativePath) : null; + if ( + !directoryBefore.isDirectory() || + directoryBefore.isSymbolicLink() || + directoryBefore.uid !== currentUid() || + (expectedDirectory && + (expectedDirectory.kind !== "directory" || + String(directoryBefore.dev) !== expectedDirectory.device || + String(directoryBefore.ino) !== expectedDirectory.inode || + Number(directoryBefore.mode & 0o777n) !== expectedDirectory.mode)) + ) { + fail("context cleanup directory identity changed"); + } + const members = fs.readdirSync(directory).sort(); + for (const member of members) { + const child = relativePath ? `${relativePath}/${member}` : member; + const target = path.join(contextPath, child); + const source = sources.get(child); + const expected = staged.get(child); + const named = fs.lstatSync(target, { bigint: true }); + if ( + !source || + (persisted && !expected) || + named.isSymbolicLink() || + named.uid !== currentUid() + ) { + fail("context cleanup found foreign evidence"); + } + if (source.kind === "directory" && named.isDirectory()) { + removeDirectory(child); + continue; + } + if (source.kind !== "file" || !named.isFile() || named.nlink !== 1n) { + fail("context cleanup entry type disagrees"); + } + const descriptor = fs.openSync(target, OPEN_READ_FLAGS); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + const maximumSize = persisted + ? BigInt(source.bytes!.byteLength) + : BigInt(source.bytes!.byteLength); + if ( + !before.isFile() || + before.nlink !== 1n || + before.size > maximumSize || + named.dev !== before.dev || + named.ino !== before.ino || + (expected && + (expected.kind !== "file" || + String(before.dev) !== expected.device || + String(before.ino) !== expected.inode || + before.size !== BigInt(expected.size!))) + ) { + fail("context cleanup file identity changed"); + } + const bytes = fs.readFileSync(descriptor); + const fullMatch = bytes.equals(source.bytes!); + const prefixMatch = source.bytes!.subarray(0, bytes.byteLength).equals(bytes); + if ((persisted && !fullMatch) || (!persisted && !prefixMatch)) { + fail("context cleanup file bytes disagree"); + } + const after = fs.fstatSync(descriptor, { bigint: true }); + const finalNamed = fs.lstatSync(target, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + finalNamed.dev !== after.dev || + finalNamed.ino !== after.ino + ) { + fail("context cleanup file changed before removal"); + } + fs.unlinkSync(target); + } finally { + fs.closeSync(descriptor); + } + } + const directoryAfter = fs.lstatSync(directory, { bigint: true }); + if ( + directoryBefore.dev !== directoryAfter.dev || + directoryBefore.ino !== directoryAfter.ino || + fs.readdirSync(directory).length !== 0 + ) { + fail("context cleanup directory changed before removal"); + } + fs.rmdirSync(directory); + }; + removeDirectory(""); + fsyncDirectory(path.dirname(contextPath)); +} + +function materializeContext( + sourceEntries: readonly SourceEntry[], + authority: HermesPortableBuildContextAuthority, + input: Parameters[0], +): HermesPortableStagedBuildContext { + const paths = contextPaths( + input.stateDir, + input.sandboxName, + input.transactionId, + input.createIntentSha256, + ); + prepareTransactionDirectory(paths); + if (fs.existsSync(paths.retired)) + fail("retired build context cannot be reused for pending create"); + let persisted: StagedAuthority; + if (fs.existsSync(paths.authority) || fs.existsSync(paths.authorityNext)) { + const authorityPath = fs.existsSync(paths.authority) ? paths.authority : paths.authorityNext; + const expectedLinks = + fs.existsSync(paths.authority) && fs.existsSync(paths.authorityNext) ? 2n : 1n; + persisted = parseStagedAuthority(readPrivateFile(authorityPath, expectedLinks)); + assertStagedContext(paths.context, persisted, sourceEntries); + publishPrivateEvidence( + paths.authority, + paths.authorityNext, + serializeStagedAuthority(persisted), + paths.directory, + ); + } else { + if (fs.existsSync(paths.context)) removeExactContextTree(paths.context, sourceEntries, null); + const entries = stageEntries(paths.context, sourceEntries); + persisted = stagedAuthority( + paths.context, + entries, + input.transactionId, + input.createIntentSha256, + authority.contextManifestSha256, + ); + publishPrivateEvidence( + paths.authority, + paths.authorityNext, + serializeStagedAuthority(persisted), + paths.directory, + ); + } + if ( + persisted.transactionId !== input.transactionId || + persisted.createIntentSha256 !== input.createIntentSha256 || + persisted.contextManifestSha256 !== authority.contextManifestSha256 || + persisted.contextPath !== paths.context + ) { + fail("published context authority disagrees with the current transaction"); + } + const assertCurrent = (): void => assertStagedContext(paths.context, persisted, sourceEntries); + assertCurrent(); + return { + buildContextPath: paths.context, + dockerfilePath: path.join(paths.context, authority.dockerfileRelativePath), + assertCurrent, + }; +} + +function retireContext( + sourceEntries: readonly SourceEntry[], + authority: HermesPortableBuildContextAuthority, + input: Parameters[0], +): boolean { + const paths = contextPaths( + input.stateDir, + input.sandboxName, + input.transactionId, + input.createIntentSha256, + ); + prepareTransactionDirectory(paths); + const persisted = parseStagedAuthority(readPrivateFile(paths.authority)); + if ( + persisted.transactionId !== input.transactionId || + persisted.createIntentSha256 !== input.createIntentSha256 || + persisted.contextManifestSha256 !== authority.contextManifestSha256 + ) { + fail("cleanup authority disagrees with the current transaction"); + } + const retiredBytes = Buffer.from( + `${JSON.stringify({ schemaVersion: 1, authoritySha256: digest(serializeStagedAuthority(persisted)) })}\n`, + ); + const retiredNext = `${paths.retired}.next`; + const retiredExists = fs.existsSync(paths.retired) || fs.existsSync(retiredNext); + if (fs.existsSync(paths.context) && fs.existsSync(paths.retiring)) { + fail("context cleanup has ambiguous canonical and detached evidence"); + } + if (!retiredExists) { + if (fs.existsSync(paths.retiring)) { + const renamed = { ...persisted, contextPath: paths.retiring }; + assertStagedContext(paths.retiring, renamed, sourceEntries); + } else { + assertStagedContext(paths.context, persisted, sourceEntries); + fs.renameSync(paths.context, paths.retiring); + const renamed = { ...persisted, contextPath: paths.retiring }; + assertStagedContext(paths.retiring, renamed, sourceEntries); + } + publishPrivateEvidence(paths.retired, retiredNext, retiredBytes, paths.directory); + } else { + publishPrivateEvidence(paths.retired, retiredNext, retiredBytes, paths.directory); + if (fs.existsSync(paths.context)) fail("retired evidence has a canonical context"); + } + if (fs.existsSync(paths.retiring)) { + const renamed = { ...persisted, contextPath: paths.retiring }; + removeExactContextTree(paths.retiring, sourceEntries, renamed); + } + if (fs.existsSync(paths.context)) fail("canonical context remains after retirement"); + return true; +} + +/** Capture the exact shipped Hermes build inputs without creating filesystem state. */ +export function createHermesPortableBuildContextPlan( + rootPath: string, + settings: HermesPortableBuildContextSettings, +): HermesPortableBuildContextPlan { + const captured = capture(rootPath, settings); + const assertCurrentSource = (): void => { + const current = capture(rootPath, settings); + if (JSON.stringify(current.authority) !== JSON.stringify(captured.authority)) { + fail("source authority changed after reservation"); + } + }; + return { + authority: captured.authority, + sourceDockerfilePath: path.join(rootPath, captured.authority.dockerfileRelativePath), + assertCurrentSource, + materialize: (input) => { + assertCurrentSource(); + return materializeContext(captured.contextEntries, captured.authority, input); + }, + retire: (input) => retireContext(captured.contextEntries, captured.authority, input), + }; +} diff --git a/src/lib/onboard/experimental/hermes-portable-container.test.ts b/src/lib/onboard/experimental/hermes-portable-container.test.ts new file mode 100644 index 00000000000..51d92e113bc --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-container.test.ts @@ -0,0 +1,451 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; + +import { describe, expect, it, vi } from "vitest"; + +import type { + HermesPortableConfiguredReceipt, + HermesPortablePendingReceipt, +} from "./hermes-portable-receipt"; +import { + buildHermesPortablePodmanEnvironment, + configureHermesPortableRestartPolicy, + enrollHermesPortableContainer, + hermesPortableContainerInternals, + probeHermesPortableAuthenticatedHealth, + startHermesPortableContainer, + stopHermesPortableContainer, + type HermesPortablePodmanResult, +} from "./hermes-portable-container"; + +const ID = "a".repeat(64); +const IMAGE = "b".repeat(64); +const SANDBOX_ID = "sandbox-id-1"; +const LABELS = { + "openshell.managed": "true", + "openshell.ai/sandbox-id": SANDBOX_ID, + "openshell.ai/sandbox-name": "alpha", + "openshell.ai/sandbox-namespace": "", + "openshell.ai/sandbox-workspace": "default", +}; + +describe("Hermes portable Podman environment", () => { + it("uses only the receipt-owned current-user namespace", () => { + const authority = receipt().runtimeAuthority; + const environment = buildHermesPortablePodmanEnvironment(authority, { + HOME: authority.homeDir, + XDG_CONFIG_HOME: authority.configHome, + XDG_RUNTIME_DIR: authority.runtimeDir, + HTTP_PROXY: "https://user:secret@proxy.test", + KUBECONFIG: "/tmp/kubeconfig", + SSH_AUTH_SOCK: "/tmp/agent.sock", + }); + + expect(environment).toEqual({ + HOME: authority.homeDir, + XDG_CONFIG_HOME: authority.configHome, + XDG_RUNTIME_DIR: authority.runtimeDir, + }); + }); + + it("rejects namespace drift", () => { + const authority = receipt().runtimeAuthority; + + expect(() => + buildHermesPortablePodmanEnvironment(authority, { HOME: "/home/replacement" }), + ).toThrow("current-user namespace disagrees"); + }); + + it.each([ + ["CONTAINER_HOST", "ssh://remote.test/run/podman.sock"], + ["DOCKER_CONTEXT", "remote-context"], + ["DOCKER_HOST", "unix:///run/user/1000/other/podman.sock"], + ])("rejects ambient %s selection", (name, value) => { + const authority = receipt().runtimeAuthority; + + expect(() => + buildHermesPortablePodmanEnvironment(authority, { + HOME: authority.homeDir, + [name]: value, + }), + ).toThrow("connection selector is not allowed"); + }); +}); + +function receipt(): HermesPortablePendingReceipt { + const uid = process.getuid!(); + return { + schemaVersion: 5, + agent: "hermes", + phase: "pending", + transactionId: randomUUID(), + createIntentSha256: "c".repeat(64), + sandboxName: "alpha", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + runtimeAuthority: { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid, + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: `/run/user/${String(uid)}`, + socketPath: `/run/user/${String(uid)}/podman/podman.sock`, + }, + openshellExecutableAuthority: {} as never, + podmanExecutableAuthority: {} as never, + socketAuthority: { + device: "1", + inode: "2", + mode: "49536", + ownerUid: String(uid), + socketPath: `/run/user/${String(uid)}/podman/podman.sock`, + directoryChain: [], + }, + startup: {} as never, + policy: {} as never, + }; +} + +function inspect( + restartPolicy = "no", + labels = LABELS, + running = true, + status = running ? "running" : "exited", +): HermesPortablePodmanResult { + return { + status: 0, + stdout: JSON.stringify([ + { + Id: ID, + Image: IMAGE, + Name: `openshell-default--alpha-${SANDBOX_ID}`, + Config: { Labels: labels }, + State: { Running: running, Paused: false, Status: status }, + HostConfig: { RestartPolicy: { Name: restartPolicy } }, + }, + ]), + stderr: "", + }; +} + +function activeReceipt(running = true): HermesPortableConfiguredReceipt { + return { + ...receipt(), + phase: "active", + previousPhaseSha256: "c".repeat(64), + verifiedLivePolicySemanticSha256: "d".repeat(64), + startup: { health: { successStatus: 200 } } as never, + container: { + containerId: ID, + sandboxId: SANDBOX_ID, + imageId: `sha256:${IMAGE}`, + labelsSha256: hermesPortableContainerInternals.labelsDigest(LABELS), + name: `openshell-default--alpha-${SANDBOX_ID}`, + running, + restartPolicy: "unless-stopped", + }, + }; +} + +describe("Hermes portable container authority", () => { + it("enrolls exactly one running full-ID container with exact OpenShell labels (#9203)", () => { + const podman = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: `${ID}\n`, stderr: "" }) + .mockReturnValueOnce(inspect()); + const assertSocketAuthority = vi.fn(); + + const enrolled = enrollHermesPortableContainer(receipt(), SANDBOX_ID, { + podman, + assertSocketAuthority, + }); + + expect(enrolled.authority).toMatchObject({ + containerId: ID, + imageId: `sha256:${IMAGE}`, + running: true, + restartPolicy: "no", + sandboxId: SANDBOX_ID, + }); + expect(enrolled.authority.labelsSha256).toBe( + hermesPortableContainerInternals.labelsDigest(LABELS), + ); + expect(assertSocketAuthority).toHaveBeenCalledTimes(4); + }); + + it.each([ + ["no candidate", "", 0], + ["duplicate candidates", `${ID}\n${"c".repeat(64)}\n`, 2], + ["short candidate", "abc\n", 1], + ])("rejects %s before exact inspect (#9203)", (_label, stdout, count) => { + const podman = vi.fn(() => ({ status: 0, stdout, stderr: "" })); + + expect(() => + enrollHermesPortableContainer(receipt(), SANDBOX_ID, { + podman, + assertSocketAuthority: vi.fn(), + }), + ).toThrow(`requires exactly one full container ID; found ${String(count)}`); + expect(podman).toHaveBeenCalledTimes(1); + }); + + it("rejects label, image, and OpenShell identity disagreement (#9203)", () => { + const changedLabels = { ...LABELS, "openshell.ai/sandbox-id": "other" }; + const podman = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: `${ID}\n`, stderr: "" }) + .mockReturnValueOnce(inspect("no", changedLabels)); + + expect(() => + enrollHermesPortableContainer(receipt(), SANDBOX_ID, { + podman, + assertSocketAuthority: vi.fn(), + }), + ).toThrow("disagrees with OpenShell"); + }); + + it("updates one exact full ID and verifies running restart authority (#9203)", () => { + const pending = receipt(); + const container = { + containerId: ID, + sandboxId: SANDBOX_ID, + imageId: `sha256:${IMAGE}`, + labelsSha256: hermesPortableContainerInternals.labelsDigest(LABELS), + name: `openshell-default--alpha-${SANDBOX_ID}`, + running: true, + restartPolicy: "no", + }; + const configuring = { + ...pending, + phase: "configuring" as const, + previousPhaseSha256: "c".repeat(64), + verifiedLivePolicySemanticSha256: "d".repeat(64), + container, + }; + const podman = vi + .fn() + .mockReturnValueOnce(inspect()) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce(inspect("unless-stopped")); + + expect( + configureHermesPortableRestartPolicy(configuring, { + podman, + assertSocketAuthority: vi.fn(), + }).authority.restartPolicy, + ).toBe("unless-stopped"); + expect(podman.mock.calls[1]?.[0]).toEqual([ + "container", + "update", + "--restart=unless-stopped", + ID, + ]); + }); + + it("preserves configuring authority when update outcome is ambiguous (#9203)", () => { + const pending = receipt(); + const configuring = { + ...pending, + phase: "configuring" as const, + previousPhaseSha256: "c".repeat(64), + verifiedLivePolicySemanticSha256: "d".repeat(64), + container: { + ...enrollHermesPortableContainer(pending, SANDBOX_ID, { + podman: vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: `${ID}\n`, stderr: "" }) + .mockReturnValueOnce(inspect()), + assertSocketAuthority: vi.fn(), + }).authority, + }, + }; + const podman = vi + .fn() + .mockReturnValueOnce(inspect()) + .mockReturnValueOnce({ + status: null, + stdout: "", + stderr: "", + error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }), + }); + + expect(() => + configureHermesPortableRestartPolicy(configuring, { + podman, + assertSocketAuthority: vi.fn(), + }), + ).toThrow("restart-policy update failed"); + expect(podman).toHaveBeenCalledTimes(2); + }); + + it("proves Bearer-authenticated health inside the exact container without host credentials (#9203)", () => { + const podman = vi + .fn() + .mockReturnValueOnce(inspect("unless-stopped")) + .mockReturnValueOnce({ status: 0, stdout: "200\n", stderr: "" }) + .mockReturnValueOnce(inspect("unless-stopped")); + + probeHermesPortableAuthenticatedHealth(activeReceipt(), { + podman, + assertSocketAuthority: vi.fn(), + }); + + const argv = podman.mock.calls[1]?.[0] as string[]; + expect(argv.slice(0, 4)).toEqual(["container", "exec", ID, "python3"]); + expect(argv.join(" ")).toContain("API_SERVER_KEY"); + expect(argv.join(" ")).toContain("NoRedirect"); + expect(argv.join(" ")).toContain("ProxyHandler({})"); + expect(argv.join(" ")).toContain("redirect refused"); + expect(argv.join(" ")).not.toContain("Bearer test-token"); + }); + + it("rejects redirected authenticated health without exposing credentials (#9203)", () => { + const podman = vi + .fn() + .mockReturnValueOnce(inspect("unless-stopped")) + .mockReturnValueOnce({ status: 0, stdout: "302\n", stderr: "" }); + + expect(() => + probeHermesPortableAuthenticatedHealth(activeReceipt(), { + podman, + assertSocketAuthority: vi.fn(), + }), + ).toThrow("returned status '302'"); + + const serializedCalls = JSON.stringify(podman.mock.calls); + expect(serializedCalls).not.toContain("Bearer " + "a".repeat(64)); + expect(hermesPortableContainerInternals.authenticatedHealthScript).toContain("NoRedirect"); + expect(hermesPortableContainerInternals.authenticatedHealthScript).not.toContain( + "urllib.request.urlopen", + ); + }); + + it("does not accept unauthenticated health status (#9203)", () => { + const podman = vi + .fn() + .mockReturnValueOnce(inspect("unless-stopped")) + .mockReturnValueOnce({ status: 0, stdout: "401\n", stderr: "" }); + + expect(() => + probeHermesPortableAuthenticatedHealth(activeReceipt(), { + podman, + assertSocketAuthority: vi.fn(), + }), + ).toThrow("returned status '401'"); + }); + + it("does not expose inspect output or error text in command failures (#9203)", () => { + const podman = vi.fn(() => ({ + status: 1, + stdout: '{"Config":{"Env":["API_KEY=do-not-log"]}}', + stderr: "do-not-log", + error: Object.assign(new Error("do-not-log"), { code: "EIO" }), + })); + + expect(() => + enrollHermesPortableContainer(receipt(), SANDBOX_ID, { + podman, + assertSocketAuthority: vi.fn(), + }), + ).toThrow("status 1 (EIO)"); + try { + enrollHermesPortableContainer(receipt(), SANDBOX_ID, { + podman, + assertSocketAuthority: vi.fn(), + }); + } catch (error) { + expect(String(error)).not.toContain("do-not-log"); + expect(String(error)).not.toContain("Config"); + } + }); + + it("starts one exact full ID and never discovers by name (#9203)", () => { + const podman = vi + .fn() + .mockReturnValueOnce(inspect("unless-stopped", LABELS, false)) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce(inspect("unless-stopped")); + + expect( + startHermesPortableContainer(activeReceipt(false), { + podman, + assertSocketAuthority: vi.fn(), + }), + ).toBe("started"); + expect(podman.mock.calls[1]?.[0]).toEqual(["container", "start", ID]); + }); + + it("reconciles a timed-out exact stop without retry or kill (#9203)", () => { + let now = 0; + const podman = vi + .fn() + .mockReturnValueOnce(inspect("unless-stopped")) + .mockReturnValueOnce({ + status: null, + stdout: "", + stderr: "", + error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }), + }) + .mockReturnValueOnce(inspect("unless-stopped", LABELS, false)); + + expect( + stopHermesPortableContainer(activeReceipt(), { + podman, + assertSocketAuthority: vi.fn(), + now: () => now, + sleep: (milliseconds) => { + now += milliseconds; + }, + }), + ).toBe("stopped"); + expect(podman.mock.calls.filter(([args]) => args[1] === "stop")).toEqual([ + [["container", "stop", ID], 40_000], + ]); + }); + + it("waits for exited after a successful stop reports a transitional state (#9203)", () => { + let now = 0; + const podman = vi + .fn() + .mockReturnValueOnce(inspect("unless-stopped")) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce(inspect("unless-stopped", LABELS, false, "stopping")) + .mockReturnValueOnce(inspect("unless-stopped", LABELS, false, "exited")); + + expect( + stopHermesPortableContainer(activeReceipt(), { + podman, + assertSocketAuthority: vi.fn(), + now: () => now, + sleep: (milliseconds) => { + now += milliseconds; + }, + }), + ).toBe("stopped"); + expect(podman.mock.calls.filter(([args]) => args[1] === "stop")).toHaveLength(1); + }); + + it("reconciles an already-stopping container without another stop command (#9203)", () => { + let now = 0; + const podman = vi + .fn() + .mockReturnValueOnce(inspect("unless-stopped", LABELS, false, "stopping")) + .mockReturnValueOnce(inspect("unless-stopped", LABELS, false, "exited")); + + expect( + stopHermesPortableContainer(activeReceipt(), { + podman, + assertSocketAuthority: vi.fn(), + now: () => now, + sleep: (milliseconds) => { + now += milliseconds; + }, + }), + ).toBe("stopped"); + expect(podman.mock.calls.filter(([args]) => args[1] === "stop")).toEqual([]); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-container.ts b/src/lib/onboard/experimental/hermes-portable-container.ts new file mode 100644 index 00000000000..317bb6b4b1f --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-container.ts @@ -0,0 +1,488 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; + +import { assertPodmanSocketAuthority, type PodmanSocketAuthorityDeps } from "../../adapters/podman"; +import { + PODMAN_MANAGED_LABEL, + PODMAN_SANDBOX_CONTAINER_PREFIX, + PODMAN_SANDBOX_ID_LABEL, + PODMAN_SANDBOX_NAME_LABEL, + PODMAN_SANDBOX_NAMESPACE, + PODMAN_SANDBOX_NAMESPACE_LABEL, + PODMAN_SANDBOX_WORKSPACE, + PODMAN_SANDBOX_WORKSPACE_LABEL, +} from "../runtime-provider/podman-lifecycle"; +import type { + HermesPortableConfiguredReceipt, + HermesPortableContainerAuthority, + HermesPortableLifecycleReceipt, +} from "./hermes-portable-receipt"; +import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; + +const FULL_ID = /^[a-f0-9]{64}$/u; +const SAFE = /^[^\u0000-\u001f\u007f-\u009f]+$/u; +const INSPECT_TIMEOUT_MS = 5_000; +const MUTATION_TIMEOUT_MS = 40_000; +const STOP_RECONCILIATION_TIMEOUT_MS = 30_000; +const STOP_RECONCILIATION_INTERVAL_MS = 1_000; +const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4)); +const PODMAN_CONNECTION_SELECTORS = [ + "CONTAINER_CONNECTION", + "CONTAINER_CERT_PATH", + "CONTAINER_HOST", + "CONTAINER_SSHKEY", + "CONTAINER_TLS_VERIFY", + "CONTAINERS_CONF", + "CONTAINERS_STORAGE_CONF", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "PODMAN_CONNECTIONS_CONF", + "REGISTRY_AUTH_FILE", +] as const; +const AUTHENTICATED_HEALTH_SCRIPT = String.raw` +import pathlib, re, urllib.error, urllib.request +text = pathlib.Path("/sandbox/.hermes/.env").read_text(encoding="utf-8") +matches = re.findall(r"^(?:export\s+)?API_SERVER_KEY=([0-9a-f]{64})$", text, re.MULTILINE) +if len(matches) != 1: + raise SystemExit(2) +request = urllib.request.Request( + "http://127.0.0.1:8642/health", + headers={"Authorization": "Bearer " + matches[0]}, + method="GET", +) +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, file_pointer, code, message, headers, new_url): + raise urllib.error.HTTPError(request.full_url, code, "redirect refused", headers, file_pointer) +try: + response = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect).open( + request, timeout=5 + ) + print(response.status) +except urllib.error.HTTPError as error: + print(error.code) +except urllib.error.URLError: + print("unavailable") +`; + +export interface HermesPortablePodmanResult { + readonly status: number | null; + readonly stdout: string; + readonly stderr: string; + readonly error?: Error; +} + +export interface HermesPortablePodmanCapture { + (args: readonly string[], timeoutMs: number): HermesPortablePodmanResult; +} + +export interface HermesPortableContainerInspection { + readonly authority: HermesPortableContainerAuthority; + readonly labels: Readonly>; + readonly paused: boolean; + readonly status: string; +} + +export interface HermesPortableContainerDeps { + readonly podman: HermesPortablePodmanCapture; + readonly socketAuthority?: PodmanSocketAuthorityDeps; + readonly assertSocketAuthority?: typeof assertPodmanSocketAuthority; + readonly now?: () => number; + readonly sleep?: (milliseconds: number) => void; +} + +export type HermesPortableContainerStartResult = "already-running" | "started"; +export type HermesPortableContainerStopResult = "already-stopped" | "stopped"; + +/** Bind schema-5 Podman to the receipt-owned current-user namespace. */ +export function buildHermesPortablePodmanEnvironment( + runtimeAuthority: CheckpointPortableRuntimeAuthority, + sourceEnv: NodeJS.ProcessEnv = process.env, +): Readonly> { + const expected = { + HOME: runtimeAuthority.homeDir, + XDG_CONFIG_HOME: runtimeAuthority.configHome, + XDG_RUNTIME_DIR: runtimeAuthority.runtimeDir, + } as const; + for (const [name, value] of Object.entries(expected)) { + const ambient = sourceEnv[name]; + if (ambient !== undefined && ambient !== "" && ambient !== value) { + fail("Podman current-user namespace disagrees with receipt authority"); + } + } + for (const name of PODMAN_CONNECTION_SELECTORS) { + if (sourceEnv[name]?.trim()) { + fail("Podman connection selector is not allowed"); + } + } + return Object.freeze({ ...expected }); +} + +function fail(message: string): never { + throw new Error(`Hermes portable container authority ${message}`); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) + fail(`${label} is not a mapping`); + return value as Record; +} + +function text(value: unknown, label: string, allowEmpty = false): string { + if ( + typeof value !== "string" || + (!allowEmpty && value.length === 0) || + value.length > 4096 || + value !== value.trim() || + (value.length > 0 && !SAFE.test(value)) + ) { + fail(`${label} is invalid`); + } + return value; +} + +function fullId(value: unknown, label: string): string { + const raw = text(value, label).toLowerCase(); + const normalized = raw.startsWith("sha256:") ? raw.slice(7) : raw; + if (!FULL_ID.test(normalized)) fail(`${label} is not a full immutable ID`); + return normalized; +} + +function imageId(value: unknown): string { + return `sha256:${fullId(value, "image ID")}`; +} + +function labels(value: unknown): Readonly> { + const input = record(value, "labels"); + const output: Record = Object.create(null); + for (const [key, entry] of Object.entries(input)) { + output[text(key, "label key")] = text(entry, `label '${key}'`, true); + } + return output; +} + +function labelsDigest(value: Readonly>): string { + const sorted = Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, value[key]]), + ); + return createHash("sha256").update(JSON.stringify(sorted)).digest("hex"); +} + +function requireCommand(result: HermesPortablePodmanResult, operation: string): string { + if (result.status === 0 && !result.error) return result.stdout; + const code = (result.error as NodeJS.ErrnoException | undefined)?.code; + fail(`${operation} failed with status ${String(result.status)}${code ? ` (${code})` : ""}`); +} + +function isCommandTimeout(result: HermesPortablePodmanResult): boolean { + return (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"; +} + +function defaultSleep(milliseconds: number): void { + if (milliseconds > 0) Atomics.wait(SLEEP_BUFFER, 0, 0, milliseconds); +} + +function parseInspection( + output: string, + expected: { + readonly sandboxName: string; + readonly sandboxId: string; + readonly containerId: string; + }, +): HermesPortableContainerInspection { + let decoded: unknown; + try { + decoded = JSON.parse(output); + } catch { + fail("inspect returned malformed JSON"); + } + if (!Array.isArray(decoded) || decoded.length !== 1) + fail("inspect did not return exactly one row"); + const row = record(decoded[0], "inspect row"); + const containerId = fullId(row.Id, "container ID"); + if (containerId !== expected.containerId) fail("inspect returned another container ID"); + const config = record(row.Config, "inspect Config"); + const containerLabels = labels(config.Labels); + const required = { + [PODMAN_MANAGED_LABEL]: "true", + [PODMAN_SANDBOX_ID_LABEL]: expected.sandboxId, + [PODMAN_SANDBOX_NAME_LABEL]: expected.sandboxName, + [PODMAN_SANDBOX_NAMESPACE_LABEL]: PODMAN_SANDBOX_NAMESPACE, + [PODMAN_SANDBOX_WORKSPACE_LABEL]: PODMAN_SANDBOX_WORKSPACE, + }; + for (const [key, value] of Object.entries(required)) { + if (containerLabels[key] !== value) fail(`inspect label '${key}' disagrees with OpenShell`); + } + const expectedName = `${PODMAN_SANDBOX_CONTAINER_PREFIX}${expected.sandboxName}-${expected.sandboxId}`; + const name = text(row.Name, "container name"); + if (name !== expectedName) fail("inspect container name disagrees with OpenShell identity"); + const state = record(row.State, "inspect State"); + if (typeof state.Running !== "boolean" || typeof state.Status !== "string") { + fail("inspect state is incomplete"); + } + if (state.Paused !== undefined && typeof state.Paused !== "boolean") { + fail("inspect paused state is invalid"); + } + const hostConfig = record(row.HostConfig, "inspect HostConfig"); + const restart = record(hostConfig.RestartPolicy, "inspect restart policy"); + return { + authority: { + containerId, + sandboxId: expected.sandboxId, + imageId: imageId(row.Image), + labelsSha256: labelsDigest(containerLabels), + name, + running: state.Running, + restartPolicy: text(restart.Name, "restart policy", true), + }, + labels: containerLabels, + paused: state.Paused === true, + status: text(state.Status, "container status").toLowerCase(), + }; +} + +function assertSocket( + receipt: HermesPortableLifecycleReceipt, + deps: HermesPortableContainerDeps, +): void { + (deps.assertSocketAuthority ?? assertPodmanSocketAuthority)( + receipt.socketAuthority, + deps.socketAuthority, + ); +} + +function inspectExact( + receipt: HermesPortableLifecycleReceipt, + sandboxId: string, + containerId: string, + deps: HermesPortableContainerDeps, +): HermesPortableContainerInspection { + assertSocket(receipt, deps); + const output = requireCommand( + deps.podman(["container", "inspect", containerId], INSPECT_TIMEOUT_MS), + "exact inspect", + ); + assertSocket(receipt, deps); + return parseInspection(output, { + sandboxName: receipt.sandboxName, + sandboxId, + containerId, + }); +} + +/** Enroll exactly one live OpenShell-managed container after Ready. */ +export function enrollHermesPortableContainer( + receipt: HermesPortableLifecycleReceipt, + sandboxId: string, + deps: HermesPortableContainerDeps, +): HermesPortableContainerInspection { + assertSocket(receipt, deps); + const output = requireCommand( + deps.podman( + [ + "ps", + "--all", + "--no-trunc", + "--filter", + `label=${PODMAN_MANAGED_LABEL}=true`, + "--filter", + `label=${PODMAN_SANDBOX_NAME_LABEL}=${receipt.sandboxName}`, + "--filter", + `label=${PODMAN_SANDBOX_WORKSPACE_LABEL}=${PODMAN_SANDBOX_WORKSPACE}`, + "--format", + "{{.ID}}", + ], + INSPECT_TIMEOUT_MS, + ), + "container enrollment lookup", + ); + assertSocket(receipt, deps); + const ids = output + .split(/\r?\n/u) + .map((line) => line.trim().toLowerCase()) + .filter(Boolean); + if (ids.length !== 1 || !FULL_ID.test(ids[0]!)) { + fail(`enrollment requires exactly one full container ID; found ${String(ids.length)}`); + } + const inspected = inspectExact(receipt, sandboxId, ids[0]!, deps); + if (!inspected.authority.running || inspected.paused) { + fail("enrollment requires the exact container to be running and unpaused"); + } + return inspected; +} + +/** Re-read one receipt-owned full ID and reject immutable identity drift. */ +export function assertCurrentHermesPortableContainer( + receipt: HermesPortableConfiguredReceipt, + deps: HermesPortableContainerDeps, +): HermesPortableContainerInspection { + const inspected = inspectExact( + receipt, + receipt.container.sandboxId, + receipt.container.containerId, + deps, + ); + const { + restartPolicy: _recordedRestartPolicy, + running: _recordedEnrollmentState, + ...recorded + } = receipt.container; + const { + restartPolicy: _currentRestartPolicy, + running: _currentState, + ...current + } = inspected.authority; + if (!isDeepStrictEqual(current, recorded)) fail("live immutable identity disagrees with receipt"); + return inspected; +} + +/** Apply and verify the only enrollment-time Podman mutation by exact full ID. */ +export function configureHermesPortableRestartPolicy( + receipt: HermesPortableConfiguredReceipt, + deps: HermesPortableContainerDeps, +): HermesPortableContainerInspection { + if (receipt.phase !== "configuring") + fail("restart-policy configuration requires configuring authority"); + const before = assertCurrentHermesPortableContainer(receipt, deps); + if (before.authority.restartPolicy !== "unless-stopped") { + assertSocket(receipt, deps); + requireCommand( + deps.podman( + ["container", "update", "--restart=unless-stopped", receipt.container.containerId], + MUTATION_TIMEOUT_MS, + ), + "restart-policy update", + ); + assertSocket(receipt, deps); + } + const after = assertCurrentHermesPortableContainer(receipt, deps); + if ( + !after.authority.running || + after.paused || + after.authority.restartPolicy !== "unless-stopped" + ) { + fail("restart-policy configuration did not leave the exact container ready"); + } + return after; +} + +/** Read authenticated health without exposing the generated Bearer credential to the host. */ +export function observeHermesPortableAuthenticatedHealth( + receipt: HermesPortableConfiguredReceipt, + deps: HermesPortableContainerDeps, +): "ready" | "unavailable" { + const before = assertCurrentHermesPortableContainer(receipt, deps); + if (!before.authority.running || before.paused) { + fail("authenticated health requires the exact container to be running and unpaused"); + } + assertSocket(receipt, deps); + const output = requireCommand( + deps.podman( + [ + "container", + "exec", + receipt.container.containerId, + "python3", + "-c", + AUTHENTICATED_HEALTH_SCRIPT, + ], + MUTATION_TIMEOUT_MS, + ), + "authenticated Hermes health probe", + ); + assertSocket(receipt, deps); + const status = output.trim(); + if (status !== String(receipt.startup.health.successStatus) && status !== "unavailable") { + fail(`authenticated Hermes health returned status '${status || "missing"}'`); + } + const after = assertCurrentHermesPortableContainer(receipt, deps); + if (!after.authority.running || after.paused) { + fail("container authority changed during authenticated health"); + } + return status === "unavailable" ? "unavailable" : "ready"; +} + +/** Prove Hermes' exact receipt-owned API accepts its generated Bearer credential. */ +export function probeHermesPortableAuthenticatedHealth( + receipt: HermesPortableConfiguredReceipt, + deps: HermesPortableContainerDeps, +): void { + if (observeHermesPortableAuthenticatedHealth(receipt, deps) !== "ready") { + fail("authenticated Hermes health is unavailable"); + } +} + +/** Start only the receipt's exact full container ID and verify its immutable identity. */ +export function startHermesPortableContainer( + receipt: HermesPortableConfiguredReceipt, + deps: HermesPortableContainerDeps, +): HermesPortableContainerStartResult { + if (receipt.phase !== "active") fail("start requires active receipt authority"); + const before = assertCurrentHermesPortableContainer(receipt, deps); + if (before.paused) fail("start will not reinterpret a paused container"); + if (before.authority.running) return "already-running"; + assertSocket(receipt, deps); + requireCommand( + deps.podman(["container", "start", receipt.container.containerId], MUTATION_TIMEOUT_MS), + "exact container start", + ); + assertSocket(receipt, deps); + const after = assertCurrentHermesPortableContainer(receipt, deps); + if (!after.authority.running || after.paused) fail("exact container did not enter running state"); + return "started"; +} + +/** Stop one exact full ID; a timed-out client permits read-only reconciliation only. */ +export function stopHermesPortableContainer( + receipt: HermesPortableConfiguredReceipt, + deps: HermesPortableContainerDeps, +): HermesPortableContainerStopResult { + if (receipt.phase !== "active") fail("stop requires active receipt authority"); + const before = assertCurrentHermesPortableContainer(receipt, deps); + if (before.paused) fail("stop will not reinterpret a paused container"); + const settled = (inspection: HermesPortableContainerInspection): boolean => { + if (inspection.paused) fail("container became paused while stopping"); + return !inspection.authority.running && inspection.status === "exited"; + }; + if (settled(before)) return "already-stopped"; + const waitForSettled = (): boolean => { + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? defaultSleep; + const deadline = now() + STOP_RECONCILIATION_TIMEOUT_MS; + do { + if (settled(assertCurrentHermesPortableContainer(receipt, deps))) return true; + sleep(Math.min(STOP_RECONCILIATION_INTERVAL_MS, Math.max(1, deadline - now()))); + } while (now() < deadline); + return settled(assertCurrentHermesPortableContainer(receipt, deps)); + }; + if (!before.authority.running) { + if (waitForSettled()) return "stopped"; + fail("exact container did not settle in exited state"); + } + assertSocket(receipt, deps); + const result = deps.podman( + ["container", "stop", receipt.container.containerId], + MUTATION_TIMEOUT_MS, + ); + assertSocket(receipt, deps); + if (isCommandTimeout(result)) { + if (waitForSettled()) return "stopped"; + requireCommand(result, "exact container stop"); + } + requireCommand(result, "exact container stop"); + if (!waitForSettled()) fail("exact container did not settle in exited state"); + return "stopped"; +} + +export const hermesPortableContainerInternals = { + authenticatedHealthScript: AUTHENTICATED_HEALTH_SCRIPT, + labelsDigest, + parseInspection, +}; diff --git a/src/lib/onboard/experimental/hermes-portable-contract.test.ts b/src/lib/onboard/experimental/hermes-portable-contract.test.ts new file mode 100644 index 00000000000..a78267e3e2f --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-contract.test.ts @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { loadAgent } from "../../agent/defs"; +import type { AgentDefinition } from "../../agent/definition-types"; +import { + assertCurrentHermesPortableStoredStartupContract, + assertCurrentHermesPortableStartupContract, + resolveHermesPortableStartupContract, +} from "./hermes-portable-contract"; + +const SANDBOX = "alpha"; +const temporaryDirectories: string[] = []; + +function startupArgv(...extra: string[]): string[] { + return [ + "env", + "NEMOCLAW_HERMES_API_PORT=8642", + `NEMOCLAW_SANDBOX_NAME=${SANDBOX}`, + ...extra, + "/usr/local/bin/nemoclaw-start", + ]; +} + +function copyAgent(): AgentDefinition { + const source = loadAgent("hermes"); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-contract-")); + temporaryDirectories.push(directory); + const manifestPath = path.join(directory, "manifest.yaml"); + fs.copyFileSync(source.manifestPath, manifestPath); + return { ...source, manifestPath }; +} + +function setExpectedManifestVersion( + agent: AgentDefinition, + expectedVersion: string | undefined, +): void { + const source = fs.readFileSync(agent.manifestPath, "utf8"); + const replacement = + expectedVersion === undefined ? "" : `expected_version: ${JSON.stringify(expectedVersion)}`; + fs.writeFileSync(agent.manifestPath, source.replace(/^expected_version:.*$/mu, replacement), { + mode: 0o644, + }); + agent.expected_version = expectedVersion; +} + +function expectStartupCandidatesRejected( + contract: ReturnType, + agent: AgentDefinition, + candidates: readonly string[][], +): void { + candidates.forEach((candidateArgv) => { + expect(() => + assertCurrentHermesPortableStartupContract(contract, { + agent, + sandboxName: SANDBOX, + startupArgv: candidateArgv, + }), + ).toThrow("current startup authority disagrees"); + }); +} + +afterEach(() => { + temporaryDirectories.splice(0).forEach((directory) => { + fs.rmSync(directory, { recursive: true, force: true }); + }); +}); + +describe("Hermes portable startup contract", () => { + it("derives Hermes startup, interactive, authenticated health, pairing, and state authority (#9203)", () => { + const agent = copyAgent(); + const contract = resolveHermesPortableStartupContract({ + agent, + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }); + + expect(contract).toMatchObject({ + startupDescriptorSha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + gatewayCommand: "hermes gateway run", + interactiveCommand: "hermes", + health: { + url: "http://localhost:8642/health", + port: 8642, + auth: "bearer_token", + credentialEnv: "API_SERVER_KEY", + successStatus: 200, + }, + devicePairing: false, + configDir: "/sandbox/.hermes", + }); + expect(agent.expected_version).toBe("0.19.0"); + }); + + it.each([undefined, "", "0.19.1"])( + "rejects Hermes manifest version %j outside the accepted portable matrix (#9203)", + (expectedVersion) => { + const agent = copyAgent(); + setExpectedManifestVersion(agent, expectedVersion); + + expect(() => + resolveHermesPortableStartupContract({ + agent, + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }), + ).toThrow("current Hermes manifest does not match the accepted lifecycle contract"); + }, + ); + + it("rejects an accepted receipt when the current Hermes matrix version drifts (#9203)", () => { + const accepted = copyAgent(); + const contract = resolveHermesPortableStartupContract({ + agent: accepted, + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }); + setExpectedManifestVersion(accepted, "0.19.1"); + + expect(() => + assertCurrentHermesPortableStartupContract(contract, { + agent: accepted, + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }), + ).toThrow("current Hermes manifest does not match the accepted lifecycle contract"); + }); + + it("rejects current manifest byte drift before reusing a receipt (#9203)", () => { + const agent = copyAgent(); + const input = { + agent, + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }; + const contract = resolveHermesPortableStartupContract(input); + fs.appendFileSync(agent.manifestPath, "\nfuture_required_startup_field: enabled\n"); + + expect(() => assertCurrentHermesPortableStartupContract(contract, input)).toThrow( + "current startup authority disagrees", + ); + }); + + it("rejects startup field addition, removal, or change during recovery (#9203)", () => { + const agent = copyAgent(); + const contract = resolveHermesPortableStartupContract({ + agent, + sandboxName: SANDBOX, + startupArgv: startupArgv("NEMOCLAW_PROXY_HOST=proxy.internal"), + }); + + expectStartupCandidatesRejected(contract, agent, [ + startupArgv(), + startupArgv("NEMOCLAW_PROXY_HOST=other.internal"), + startupArgv("NEMOCLAW_PROXY_HOST=proxy.internal", "NEMOCLAW_PROXY_PORT=8080"), + ]); + }); + + it("accepts the complete current stored startup contract during lifecycle recovery (#9203)", () => { + const contract = resolveHermesPortableStartupContract({ + agent: loadAgent("hermes"), + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }); + expect(() => assertCurrentHermesPortableStoredStartupContract(contract, SANDBOX)).not.toThrow(); + }); + + it.each([ + { + argv: [ + "env", + `NEMOCLAW_SANDBOX_NAME=${SANDBOX}`, + "NEMOCLAW_HERMES_API_PORT=8642", + "/usr/local/bin/nemoclaw-start", + ], + }, + { argv: startupArgv("NEMOCLAW_PROXY_HOST=proxy.internal") }, + ])("rejects stored startup renderer drift %# during lifecycle recovery (#9203)", ({ argv }) => { + const contract = resolveHermesPortableStartupContract({ + agent: loadAgent("hermes"), + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }); + + expect(() => + assertCurrentHermesPortableStoredStartupContract({ ...contract, argv }, SANDBOX), + ).toThrow("current startup authority disagrees"); + }); + + it.each([ + "API_SERVER_KEY=secret-value", + "NEMOCLAW_SANDBOX_NAME=other", + "NEMOCLAW_HERMES_API_PORT=8643", + "CHAT_UI_URL=http://127.0.0.1:8643/", + "NEMOCLAW_HERMES_DASHBOARD=0", + "NEMOCLAW_HERMES_DASHBOARD=$(touch /tmp/owned)", + "UNREVIEWED_ENV=value", + ])("rejects unsafe or unowned startup assignment %s (#9203)", (assignment) => { + expect(() => + resolveHermesPortableStartupContract({ + agent: copyAgent(), + sandboxName: SANDBOX, + startupArgv: startupArgv(assignment), + }), + ).toThrow("Hermes portable startup contract"); + }); + + it("rejects a credential-bearing proxy without persisting its value (#9203)", () => { + expect(() => + resolveHermesPortableStartupContract({ + agent: copyAgent(), + sandboxName: SANDBOX, + startupArgv: startupArgv("HTTPS_PROXY=https://user:secret@proxy.example:8443"), + }), + ).toThrow("contains credentials"); + }); + + it.each([ + "HTTPS_PROXY=https://proxy.example/?token=do-not-store", + "HTTPS_PROXY=https://proxy.example/path/do-not-store", + "CHAT_UI_URL=https://dashboard.example/#do-not-store", + "HTTP_PROXY=file:///tmp/do-not-store", + ])("rejects durable URL components that could carry credentials: %s (#9203)", (assignment) => { + let error: unknown; + try { + resolveHermesPortableStartupContract({ + agent: copyAgent(), + sandboxName: SANDBOX, + startupArgv: startupArgv(assignment), + }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toContain("do-not-store"); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-contract.ts b/src/lib/onboard/experimental/hermes-portable-contract.ts new file mode 100644 index 00000000000..b0810063850 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-contract.ts @@ -0,0 +1,402 @@ +// 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 path from "node:path"; +import { isDeepStrictEqual, TextDecoder } from "node:util"; + +import type { AgentDefinition, ManifestRecord } from "../../agent/definition-types"; +import { + parseManifestRecord, + readBoolean, + readConfigShieldsFiles, + readHealthProbe, + readObject, + readStateFiles, + readStateLockPlanInImage, + readString, + readUserManagedFiles, +} from "../../agent/manifest-readers"; +import { readAgentRuntime } from "../../agent/runtime-manifest"; +import { buildStateLockPlan, readStateDirectories } from "../../agent/state-directory-contract"; +import { readWebAuth } from "../../agent/web-auth"; +import { + buildCurrentHermesPortableRuntimeEnvArgs, + currentHermesPortableAgentDefinition, +} from "../docker-startup-command-env"; +import type { HermesPortableStartupContract } from "./hermes-portable-receipt"; + +const UTF8 = new TextDecoder("utf-8", { fatal: true }); +const MAX_MANIFEST_BYTES = 128 * 1024; +const STARTUP_EXECUTABLE = "/usr/local/bin/nemoclaw-start"; +const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/u; +const PORT = /^(?:[1-9][0-9]{0,4})$/u; +const PLACEHOLDER_KEYS = /^[A-Z_][A-Z0-9_]*(?:,[A-Z_][A-Z0-9_]*)*$/u; +const SHELL_PAYLOAD = /(?:[`;|]|&&|\$\()/u; +const ALLOWED_ENV = new Set([ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "NEMOCLAW_HERMES_API_PORT", + "NEMOCLAW_PROXY_HOST", + "NEMOCLAW_PROXY_PORT", + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_EXTRA_PLACEHOLDER_KEYS", +]); + +export interface ResolveHermesPortableStartupContractInput { + readonly agent: AgentDefinition; + readonly startupArgv: readonly string[]; + readonly sandboxName: string; +} + +function fail(message: string): never { + throw new Error(`Hermes portable startup contract ${message}`); +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (!value || typeof value !== "object") return value; + const result: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + result[key] = canonical((value as Record)[key]); + } + return result; +} + +function readExactManifestPath(manifestPath: string): Buffer { + const parentPath = path.dirname(manifestPath); + const parentBefore = fs.lstatSync(parentPath, { bigint: true }); + const descriptor = fs.openSync(manifestPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + const named = fs.lstatSync(manifestPath, { bigint: true }); + const uid = process.getuid?.(); + if ( + uid === undefined || + !before.isFile() || + named.isSymbolicLink() || + before.nlink !== 1n || + (before.uid !== 0n && before.uid !== BigInt(uid)) || + (before.mode & 0o22n) !== 0n || + before.dev !== named.dev || + before.ino !== named.ino || + !parentBefore.isDirectory() || + parentBefore.isSymbolicLink() || + (parentBefore.uid !== 0n && parentBefore.uid !== BigInt(uid)) || + (parentBefore.mode & 0o22n) !== 0n || + before.size < 1n || + before.size > BigInt(MAX_MANIFEST_BYTES) + ) { + fail("manifest source is unsafe"); + } + const bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.length) { + const read = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (read === 0) fail("manifest ended during read"); + offset += read; + } + const after = fs.fstatSync(descriptor, { bigint: true }); + const finalNamed = fs.lstatSync(manifestPath, { bigint: true }); + const parentAfter = fs.lstatSync(parentPath, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + finalNamed.dev !== after.dev || + finalNamed.ino !== after.ino || + parentBefore.dev !== parentAfter.dev || + parentBefore.ino !== parentAfter.ino || + parentBefore.mode !== parentAfter.mode || + parentBefore.uid !== parentAfter.uid || + parentBefore.mtimeNs !== parentAfter.mtimeNs || + parentBefore.ctimeNs !== parentAfter.ctimeNs + ) { + fail("manifest changed during read"); + } + try { + UTF8.decode(bytes); + } catch { + fail("manifest is not strict UTF-8"); + } + return bytes; + } finally { + fs.closeSync(descriptor); + } +} + +function readExactManifest(agent: AgentDefinition): Buffer { + return readExactManifestPath(agent.manifestPath); +} + +function manifestConfigPaths(config: ManifestRecord | undefined) { + return { + dir: readString(config ?? {}, "dir") ?? "/sandbox/.openclaw", + configFile: readString(config ?? {}, "config_file") ?? "openclaw.json", + envFile: readString(config ?? {}, "env_file") ?? null, + format: readString(config ?? {}, "format") ?? "json", + shieldsFiles: readConfigShieldsFiles(config), + }; +} + +function manifestProjection(record: ManifestRecord) { + const config = readObject(record, "config"); + const stateDirectories = readStateDirectories(record); + return { + name: readString(record, "name"), + expectedVersion: readString(record, "expected_version"), + gatewayCommand: readString(record, "gateway_command"), + runtime: readAgentRuntime(record), + healthProbe: readHealthProbe(record) ?? null, + devicePairing: readBoolean(record, "device_pairing"), + webAuth: readWebAuth(record), + configPaths: manifestConfigPaths(config), + stateDirectories, + stateFiles: readStateFiles(record) ?? [], + stateLockPlan: buildStateLockPlan(stateDirectories), + stateLockPlanInImage: readStateLockPlanInImage(record), + userManagedFiles: readUserManagedFiles(record) ?? [], + }; +} + +function agentProjection(agent: AgentDefinition): ReturnType { + return { + name: agent.name, + expectedVersion: agent.expected_version, + gatewayCommand: agent.gateway_command, + runtime: agent.runtime ?? { kind: "gateway" }, + healthProbe: agent.healthProbe, + devicePairing: agent.device_pairing, + webAuth: agent.webAuth, + configPaths: agent.configPaths, + stateDirectories: agent.stateDirectories, + stateFiles: agent.stateFiles, + stateLockPlan: agent.stateLockPlan, + stateLockPlanInImage: agent.stateLockPlanInImage, + userManagedFiles: agent.userManagedFiles, + }; +} + +function parseExactManifest(bytes: Buffer): ReturnType { + let record: ManifestRecord; + try { + record = parseManifestRecord(UTF8.decode(bytes), "Hermes portable manifest"); + return manifestProjection(record); + } catch { + fail("manifest is invalid"); + } +} + +function validateUrl(value: string, label: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + fail(`${label} is not a URL`); + } + if (parsed.username || parsed.password) fail(`${label} contains credentials`); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + fail(`${label} uses an unsupported URL scheme`); + } + if (parsed.pathname !== "/" || parsed.search || parsed.hash) { + fail(`${label} contains a path, query, or fragment that cannot enter durable authority`); + } +} + +function validateAssignment(key: string, value: string, sandboxName: string): void { + if (!ENV_NAME.test(key) || !ALLOWED_ENV.has(key)) fail(`argv contains unsupported env '${key}'`); + if (!value || value.length > 4096 || /[\u0000-\u001f\u007f]/u.test(value)) { + fail(`argv env '${key}' has an invalid value`); + } + if (SHELL_PAYLOAD.test(value)) fail(`argv env '${key}' contains a shell payload`); + if (key === "NEMOCLAW_SANDBOX_NAME" && value !== sandboxName) { + fail("argv sandbox identity changed"); + } + if (key === "NEMOCLAW_HERMES_API_PORT" && value !== "8642") { + fail("argv Hermes API port changed"); + } + if (key.endsWith("_PORT") && !PORT.test(value)) fail(`argv env '${key}' is not a port`); + if (key === "NEMOCLAW_EXTRA_PLACEHOLDER_KEYS" && !PLACEHOLDER_KEYS.test(value)) { + fail("argv placeholder names are invalid"); + } + if ( + key === "HTTP_PROXY" || + key === "HTTPS_PROXY" || + key === "http_proxy" || + key === "https_proxy" + ) { + validateUrl(value, `argv env '${key}'`); + } +} + +function validateStartupArgv(argv: readonly string[], sandboxName: string): readonly string[] { + if ( + argv.length < 4 || + argv.length > 64 || + argv[0] !== "env" || + argv.at(-1) !== STARTUP_EXECUTABLE + ) { + fail("argv does not match the managed Hermes startup form"); + } + const seen = new Set(); + for (const assignment of argv.slice(1, -1)) { + const separator = assignment.indexOf("="); + if (separator < 1) fail("argv contains a non-assignment before startup"); + const key = assignment.slice(0, separator); + const value = assignment.slice(separator + 1); + if (seen.has(key)) fail(`argv contains duplicate env '${key}'`); + seen.add(key); + validateAssignment(key, value, sandboxName); + } + if (!seen.has("NEMOCLAW_SANDBOX_NAME") || !seen.has("NEMOCLAW_HERMES_API_PORT")) { + fail("argv is missing the sandbox name or Hermes API port"); + } + return [...argv]; +} + +function startupAssignments(argv: readonly string[]): Map { + const assignments = new Map(); + for (const assignment of argv.slice(1, -1)) { + const separator = assignment.indexOf("="); + assignments.set(assignment.slice(0, separator), assignment.slice(separator + 1)); + } + return assignments; +} + +function requiredAssignment(assignments: ReadonlyMap, name: string): string { + const value = assignments.get(name); + if (!value) fail(`stored argv is missing renderer input '${name}'`); + return value; +} + +function rerenderCurrentStartupArgv( + storedArgv: readonly string[], + sandboxName: string, +): readonly string[] { + const argv = validateStartupArgv(storedArgv, sandboxName); + const assignments = startupAssignments(argv); + const environment = Object.fromEntries(assignments); + const extraPlaceholderKeys = (assignments.get("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS") ?? "") + .split(",") + .filter(Boolean); + const rendered = buildCurrentHermesPortableRuntimeEnvArgs({ + chatUiUrl: "http://127.0.0.1:8642/", + manageDashboard: false, + getDashboardForwardPort: () => "0", + hermesDashboardState: { enabled: false, config: null }, + hermesApiPort: Number(requiredAssignment(assignments, "NEMOCLAW_HERMES_API_PORT")), + extraPlaceholderKeys, + sandboxName, + env: environment, + }); + return ["env", ...rendered.envArgs, STARTUP_EXECUTABLE]; +} + +function stateIdentity(projection: ReturnType): string { + return sha256( + JSON.stringify( + canonical({ + configPaths: projection.configPaths, + stateDirectories: projection.stateDirectories, + stateFiles: projection.stateFiles, + stateLockPlan: projection.stateLockPlan, + stateLockPlanInImage: projection.stateLockPlanInImage, + userManagedFiles: projection.userManagedFiles, + }), + ), + ); +} + +/** Derive the complete lifecycle descriptor from current manifest and launch inputs. */ +export function resolveHermesPortableStartupContract( + input: ResolveHermesPortableStartupContractInput, +): HermesPortableStartupContract { + const { agent, sandboxName } = input; + const manifestBytes = readExactManifest(agent); + const manifest = parseExactManifest(manifestBytes); + if (!isDeepStrictEqual(manifest, agentProjection(agent))) { + fail("loaded Hermes manifest disagrees with its exact source"); + } + if ( + manifest.name !== "hermes" || + manifest.expectedVersion !== "0.19.0" || + manifest.gatewayCommand !== "hermes gateway run" || + manifest.runtime.interactive_command !== "hermes" || + manifest.healthProbe?.url !== "http://localhost:8642/health" || + manifest.healthProbe.port !== 8642 || + manifest.devicePairing !== false || + manifest.webAuth.method !== "bearer_token" || + manifest.webAuth.env !== "API_SERVER_KEY" || + manifest.configPaths.dir !== "/sandbox/.hermes" + ) { + fail("current Hermes manifest does not match the accepted lifecycle contract"); + } + const argv = validateStartupArgv(input.startupArgv, sandboxName); + const stateIdentitySha256 = stateIdentity(manifest); + return { + manifestSha256: sha256(manifestBytes), + startupDescriptorSha256: sha256( + JSON.stringify( + canonical({ + argv, + configDir: manifest.configPaths.dir, + devicePairing: manifest.devicePairing, + gatewayCommand: manifest.gatewayCommand, + health: manifest.healthProbe, + interactiveCommand: manifest.runtime.interactive_command, + stateIdentitySha256, + webAuth: manifest.webAuth, + }), + ), + ), + argv, + gatewayCommand: "hermes gateway run", + interactiveCommand: "hermes", + health: { + url: "http://localhost:8642/health", + port: 8642, + method: "GET", + auth: "bearer_token", + credentialEnv: "API_SERVER_KEY", + successStatus: 200, + }, + devicePairing: false, + configDir: "/sandbox/.hermes", + stateIdentitySha256, + }; +} + +/** Recheck the complete stored startup descriptor and its current manifest source. */ +export function assertCurrentHermesPortableStoredStartupContract( + actual: HermesPortableStartupContract, + sandboxName: string, +): void { + const currentArgv = rerenderCurrentStartupArgv(actual.argv, sandboxName); + const current = resolveHermesPortableStartupContract({ + agent: currentHermesPortableAgentDefinition(), + sandboxName, + startupArgv: currentArgv, + }); + if (!isDeepStrictEqual(current, actual)) fail("current startup authority disagrees"); +} + +/** Re-render from current manifest, profile, and launch inputs before lifecycle mutation. */ +export function assertCurrentHermesPortableStartupContract( + expected: HermesPortableStartupContract, + input: ResolveHermesPortableStartupContractInput, +): HermesPortableStartupContract { + const current = resolveHermesPortableStartupContract(input); + if (!isDeepStrictEqual(current, expected)) fail("current startup authority disagrees"); + return current; +} diff --git a/src/lib/onboard/experimental/hermes-portable-lifecycle.test.ts b/src/lib/onboard/experimental/hermes-portable-lifecycle.test.ts new file mode 100644 index 00000000000..b029850fdc6 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-lifecycle.test.ts @@ -0,0 +1,595 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { loadAgent } from "../../agent/defs"; +import { withMcpLifecycleLockSync } from "../../state/mcp-lifecycle-lock"; +import type { SandboxEntry } from "../../state/registry"; +import { fingerprintOpenShellSandboxLiveIdentity } from "../../adapters/openshell/sandbox-identity"; +import type { HermesPortableOpenShellExecutableAuthority } from "../../adapters/openshell/resolve-shared"; +import type { PodmanExecutableAuthorityDeps, PodmanExecutableStat } from "../../adapters/podman"; +import type { ContainerEngineCommandCapture } from "../../adapters/container-engine"; +import type { HermesPortablePodmanExecutableAuthority } from "./hermes-portable-podman-authority"; +import { hermesPortableContainerInternals } from "./hermes-portable-container"; +import { resolveHermesPortableStartupContract } from "./hermes-portable-contract"; +import { + hermesPortableLifecycleInternals, + recoverHermesPortableSandboxLifecycle, + stopHermesPortableSandboxLifecycle, +} from "./hermes-portable-lifecycle"; +import { hermesPortableCreatePolicySemanticDigest } from "./hermes-portable-policy-authority"; +import { + captureHermesPortablePolicySource, + publishHermesPortableDurablePolicySource, + publishHermesPortableLifecycleReceipt, + type HermesPortableConfiguredReceipt, + type HermesPortablePendingReceipt, +} from "./hermes-portable-receipt"; + +const SANDBOX = "alpha"; +const GATEWAY = "nemoclaw"; +const GENERATION = "generation-1"; +const CONTAINER_ID = "a".repeat(64); +const IMAGE = "b".repeat(64); +const SANDBOX_ID = "sandbox-id-1"; +const POLICY = "version: 1\nnetwork_policies: {}\n"; +const LIVE = `Name: ${SANDBOX}\nID: ${SANDBOX_ID}\nPhase: Ready\n`; +const LABELS = { + "openshell.managed": "true", + "openshell.ai/sandbox-id": SANDBOX_ID, + "openshell.ai/sandbox-name": SANDBOX, + "openshell.ai/sandbox-namespace": "", + "openshell.ai/sandbox-workspace": "default", +}; + +let stateDir: string; +let policyPath: string; + +function startupArgv() { + return [ + "env", + "NEMOCLAW_HERMES_API_PORT=8642", + `NEMOCLAW_SANDBOX_NAME=${SANDBOX}`, + "/usr/local/bin/nemoclaw-start", + ]; +} + +function poisonUnexpectedCommand(scope: string, args: readonly string[]): never { + throw new Error(`unexpected ${scope} command: ${args.join(" ")}`); +} + +function directoryChain(directory: string): string[] { + const parent = path.dirname(directory); + return parent === directory ? [directory] : [directory, ...directoryChain(parent)]; +} + +function openshellExecutableAuthority(): HermesPortableOpenShellExecutableAuthority { + return { + version: "0.0.101", + executable: { + executablePath: "/usr/bin/openshell", + device: "1", + inode: "10", + mode: String(0o100755), + ownerUid: "0", + size: "1024", + modifiedTimeNanoseconds: "11", + changedTimeNanoseconds: "12", + sha256: "f".repeat(64), + directoryChain: ["/usr/bin", "/usr", "/"].map((directory, index) => ({ + device: "1", + inode: String(index + 20), + mode: String(0o40755), + ownerUid: "0", + path: directory, + })), + }, + }; +} + +function podmanExecutableAuthority(): HermesPortablePodmanExecutableAuthority { + const bytes = Buffer.from("podman-5.7.0-test", "utf8"); + return { + version: "5.7.0", + executable: { + executablePath: "/usr/bin/podman", + device: "1", + inode: "30", + mode: String(0o100755), + ownerUid: "0", + size: String(bytes.byteLength), + modifiedTimeNanoseconds: "31", + changedTimeNanoseconds: "32", + sha256: createHash("sha256").update(bytes).digest("hex"), + directoryChain: ["/usr/bin", "/usr", "/"].map((directory, index) => ({ + device: "1", + inode: String(index + 40), + mode: String(0o40755), + ownerUid: "0", + path: directory, + })), + }, + }; +} + +function podmanExecutableAuthorityDeps(): PodmanExecutableAuthorityDeps { + const bytes = Buffer.from("podman-5.7.0-test", "utf8"); + const stat = (filePath: string): PodmanExecutableStat => ({ + dev: 1n, + ino: + filePath === "/usr/bin/podman" + ? 30n + : filePath === "/usr/bin" + ? 40n + : filePath === "/usr" + ? 41n + : 42n, + mode: filePath === "/usr/bin/podman" ? 0o100755n : 0o40755n, + uid: 0n, + size: filePath === "/usr/bin/podman" ? BigInt(bytes.byteLength) : 0n, + mtimeNs: 31n, + ctimeNs: 32n, + isDirectory: () => filePath !== "/usr/bin/podman", + isFile: () => filePath === "/usr/bin/podman", + isSymbolicLink: () => false, + }); + return { + uid: process.getuid!(), + lstat: stat, + readFile: () => bytes, + realpath: (filePath) => filePath, + }; +} + +function activeReceipt(): HermesPortableConfiguredReceipt { + const uid = process.getuid!(); + const socketPath = `/run/user/${String(uid)}/podman/podman.sock`; + const transactionId = randomUUID(); + const policyBytes = fs.readFileSync(policyPath); + const policy = publishHermesPortableDurablePolicySource({ + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: hermesPortableCreatePolicySemanticDigest(policyBytes), + source: captureHermesPortablePolicySource(policyPath), + hooks: { assertLifecycleLock: () => undefined }, + }); + const pending: HermesPortablePendingReceipt = { + schemaVersion: 5, + agent: "hermes", + phase: "pending", + transactionId, + createIntentSha256: "c".repeat(64), + sandboxName: SANDBOX, + gatewayName: GATEWAY, + lifecycleGeneration: GENERATION, + runtimeAuthority: { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid, + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: `/run/user/${String(uid)}`, + socketPath, + }, + openshellExecutableAuthority: openshellExecutableAuthority(), + podmanExecutableAuthority: podmanExecutableAuthority(), + socketAuthority: { + device: "1", + inode: "2", + mode: String(0o140600), + ownerUid: String(uid), + socketPath, + directoryChain: directoryChain(path.dirname(socketPath)).map((directory, index) => ({ + device: "1", + inode: String(index + 3), + mode: String(index === 0 ? 0o40700 : 0o40755), + ownerUid: String(index === 0 ? uid : 0), + path: directory, + })), + }, + startup: resolveHermesPortableStartupContract({ + agent: loadAgent("hermes"), + sandboxName: SANDBOX, + startupArgv: startupArgv(), + }), + policy, + }; + const first = publishHermesPortableLifecycleReceipt(pending, stateDir, { + assertLifecycleLock: () => undefined, + }); + const configuring: HermesPortableConfiguredReceipt = { + ...pending, + phase: "configuring", + previousPhaseSha256: first.sha256, + verifiedLivePolicySemanticSha256: policy.intendedSemanticSha256, + container: { + containerId: CONTAINER_ID, + sandboxId: SANDBOX_ID, + imageId: `sha256:${IMAGE}`, + labelsSha256: hermesPortableContainerInternals.labelsDigest(LABELS), + name: `openshell-default--${SANDBOX}-${SANDBOX_ID}`, + running: true, + restartPolicy: "no", + }, + }; + const second = publishHermesPortableLifecycleReceipt(configuring, stateDir, { + assertLifecycleLock: () => undefined, + }); + const active: HermesPortableConfiguredReceipt = { + ...configuring, + phase: "active", + previousPhaseSha256: second.sha256, + container: { ...configuring.container, restartPolicy: "unless-stopped" }, + }; + publishHermesPortableLifecycleReceipt(active, stateDir, { + assertLifecycleLock: () => undefined, + }); + return active; +} + +function lifecycleDeps(receipt: HermesPortableConfiguredReceipt, initiallyRunning = true) { + let running = initiallyRunning; + const podman = vi.fn((args: readonly string[]) => { + const actions = { + inspect: () => ({ + status: 0, + stdout: JSON.stringify([ + { + Id: CONTAINER_ID, + Image: IMAGE, + Name: receipt.container.name, + Config: { Labels: LABELS }, + State: { Running: running, Paused: false, Status: running ? "running" : "exited" }, + HostConfig: { RestartPolicy: { Name: "unless-stopped" } }, + }, + ]), + stderr: "", + }), + exec: () => ({ status: 0, stdout: "200\n", stderr: "" }), + start: () => { + running = true; + return { status: 0, stdout: "", stderr: "" }; + }, + stop: () => { + running = false; + return { status: 0, stdout: "", stderr: "" }; + }, + }; + const action = actions[args[1] as keyof typeof actions]; + return action?.() ?? poisonUnexpectedCommand("podman", args); + }); + const liveIdentityFingerprint = fingerprintOpenShellSandboxLiveIdentity(LIVE)!; + const captureOpenShell = vi.fn((args: readonly string[]) => { + const responses = { + "policy:get": { status: 0, stdout: POLICY, stderr: "" }, + "sandbox:list": { status: 0, stdout: LIVE, stderr: "" }, + "sandbox:get": { status: 0, stdout: LIVE, stderr: "" }, + "sandbox:exec": { status: 0, stdout: "", stderr: "" }, + }; + return ( + responses[args.slice(0, 2).join(":") as keyof typeof responses] ?? + poisonUnexpectedCommand("OpenShell", args) + ); + }); + return { + deps: { + stateDir, + env: { + HOME: "/home/test", + PATH: "/usr/bin", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: `/run/user/${String(process.getuid!())}`, + }, + readRegistry: () => + ({ + name: SANDBOX, + agent: "hermes", + openshellDriver: "docker", + gatewayName: GATEWAY, + lifecycleGeneration: GENERATION, + lifecycleLiveIdentityFingerprint: liveIdentityFingerprint, + openshellVersion: "0.0.101", + }) as SandboxEntry, + captureOpenShell, + assertOpenShellExecutableAuthority: vi.fn(() => "/usr/bin/openshell"), + launchOpenShell: vi.fn(), + container: { podman, assertSocketAuthority: vi.fn() }, + sleep: vi.fn(), + }, + podman, + captureOpenShell, + }; +} + +function lifecycleContext() { + return { + agent: "hermes", + gatewayName: GATEWAY, + lifecycleGeneration: GENERATION, + openshellDriver: "docker", + provider: "ollama", + }; +} + +beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-lifecycle-")); + policyPath = path.join(stateDir, "policy.yaml"); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("Hermes portable lifecycle", () => { + it("passes only private state, terminal, locale, and TLS variables to child commands (#9203)", () => { + const runtimeAuthority = { + schemaVersion: 1 as const, + kind: "podman" as const, + ownership: "current-user" as const, + uid: process.getuid!(), + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: "/run/user/1000", + socketPath: "/run/user/1000/podman/podman.sock", + }; + const sourceEnv = { + HOME: "/home/test", + PATH: "/usr/bin", + TERM: "xterm-256color", + LANG: "C.UTF-8", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + XDG_CACHE_HOME: "/tmp/ambient-cache", + HTTPS_PROXY: "http://127.0.0.1:8118", + SSL_CERT_FILE: "/etc/ssl/cert.pem", + DOCKER_HOST: "unix:///run/docker.sock", + KUBECONFIG: "/home/test/.kube/config", + SSH_AUTH_SOCK: "/run/user/1000/ssh-agent.sock", + OPENSHELL_GATEWAY: "ambient", + OPENSHELL_GATEWAY_ENDPOINT: "https://ambient.example", + NVIDIA_INFERENCE_API_KEY: "do-not-forward", + GITHUB_TOKEN: "do-not-forward", + AWS_SECRET_ACCESS_KEY: "do-not-forward", + }; + const env = hermesPortableLifecycleInternals.buildHermesPortableOpenShellEnv( + sourceEnv, + runtimeAuthority, + ); + + expect(env).toMatchObject({ + HOME: "/home/test", + PATH: "/usr/bin", + TERM: "xterm-256color", + LANG: "C.UTF-8", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + SSL_CERT_FILE: "/etc/ssl/cert.pem", + }); + expect(env).not.toHaveProperty("HTTPS_PROXY"); + expect(env).not.toHaveProperty("XDG_CACHE_HOME"); + expect(env).not.toHaveProperty("DOCKER_HOST"); + expect(env).not.toHaveProperty("KUBECONFIG"); + expect(env).not.toHaveProperty("SSH_AUTH_SOCK"); + expect(env).not.toHaveProperty("OPENSHELL_GATEWAY"); + expect(env).not.toHaveProperty("OPENSHELL_GATEWAY_ENDPOINT"); + expect(env).not.toHaveProperty("NVIDIA_INFERENCE_API_KEY"); + expect(env).not.toHaveProperty("GITHUB_TOKEN"); + expect(env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY"); + expect(() => + hermesPortableLifecycleInternals.buildHermesPortableOpenShellEnv( + { ...sourceEnv, XDG_CONFIG_HOME: "/tmp/other-config" }, + runtimeAuthority, + ), + ).toThrow("XDG_CONFIG_HOME disagrees with runtime authority"); + }); + + it("constructs production Podman dependencies from the receipt authority (#9203)", () => { + const receipt = activeReceipt(); + const capture = vi.fn( + (_executable, args, _timeoutMs, _input, environment) => { + expect(environment).toEqual({ + HOME: receipt.runtimeAuthority.homeDir, + XDG_CONFIG_HOME: receipt.runtimeAuthority.configHome, + XDG_RUNTIME_DIR: receipt.runtimeAuthority.runtimeDir, + }); + const operation = args.includes("version") + ? "version" + : args.includes("info") + ? "info" + : "business"; + const responses = { + version: { + status: 0, + stdout: JSON.stringify({ + Client: { Version: "5.7.0" }, + Server: { Version: "5.7.0" }, + }), + stderr: "", + }, + info: { + status: 0, + stdout: JSON.stringify({ + host: { + arch: "amd64", + os: "linux", + cgroupVersion: "v2", + networkBackend: "netavark", + security: { rootless: true }, + idMappings: { + uidmap: [ + { container_id: 0, host_id: receipt.runtimeAuthority.uid, size: 1 }, + { container_id: 1, host_id: 100000, size: 65536 }, + ], + gidmap: [ + { container_id: 0, host_id: receipt.runtimeAuthority.uid, size: 1 }, + { container_id: 1, host_id: 100000, size: 65536 }, + ], + }, + }, + }), + stderr: "", + }, + business: { status: 0, stdout: "exact container", stderr: "" }, + } as const; + return responses[operation]; + }, + ); + const container = hermesPortableLifecycleInternals.createContainerDeps( + receipt, + { + HOME: receipt.runtimeAuthority.homeDir, + PATH: "/usr/bin", + XDG_CONFIG_HOME: receipt.runtimeAuthority.configHome, + XDG_RUNTIME_DIR: receipt.runtimeAuthority.runtimeDir, + }, + { + capture, + executableAuthorityDeps: podmanExecutableAuthorityDeps(), + assertSocketAuthority: vi.fn(), + resolveExecutablePath: () => receipt.podmanExecutableAuthority.executable.executablePath, + platform: "linux", + architecture: "x64", + uid: receipt.runtimeAuthority.uid, + }, + ); + + expect(container.podman(["container", "inspect", CONTAINER_ID], 5_000)).toMatchObject({ + status: 0, + stdout: "exact container", + }); + expect(capture).toHaveBeenLastCalledWith( + receipt.podmanExecutableAuthority.executable.executablePath, + [ + "--url", + `unix://${receipt.socketAuthority.socketPath}`, + "container", + "inspect", + CONTAINER_ID, + ], + 5_000, + undefined, + expect.any(Object), + ); + }); + + it("starts and proves exact receipt-owned authenticated health without Docker (#9203)", () => { + const receipt = activeReceipt(); + const { deps, podman } = lifecycleDeps(receipt, false); + + const result = withMcpLifecycleLockSync( + SANDBOX, + () => recoverHermesPortableSandboxLifecycle(SANDBOX, lifecycleContext(), deps), + { stateDir: path.join(stateDir, "state") }, + ); + + expect(result).toEqual({ kind: "recovered" }); + expect(podman.mock.calls.some(([args]) => args[1] === "start")).toBe(true); + expect(podman.mock.calls.every(([args]) => !String(args[0]).includes("docker"))).toBe(true); + }); + + it("rejects an ambient OpenShell endpoint before Podman or OpenShell effects (#9203)", () => { + const receipt = activeReceipt(); + const { deps, podman, captureOpenShell } = lifecycleDeps(receipt, false); + const endpointDeps = { + ...deps, + env: { OPENSHELL_GATEWAY_ENDPOINT: "https://ambient.example" }, + }; + + expect(() => + withMcpLifecycleLockSync( + SANDBOX, + () => recoverHermesPortableSandboxLifecycle(SANDBOX, lifecycleContext(), endpointDeps), + { stateDir: path.join(stateDir, "state") }, + ), + ).toThrow("OPENSHELL_GATEWAY_ENDPOINT is set"); + expect(podman).not.toHaveBeenCalled(); + expect(captureOpenShell).not.toHaveBeenCalled(); + }); + + it("revalidates identity after the stop callback and stops one full ID (#9203)", () => { + const receipt = activeReceipt(); + const { deps, podman } = lifecycleDeps(receipt); + + const result = withMcpLifecycleLockSync( + SANDBOX, + () => stopHermesPortableSandboxLifecycle(SANDBOX, lifecycleContext(), vi.fn(), deps), + { stateDir: path.join(stateDir, "state") }, + ); + + expect(result).toEqual({ kind: "stopped" }); + expect(podman.mock.calls.filter(([args]) => args[1] === "stop")).toEqual([ + [["container", "stop", CONTAINER_ID], 40_000], + ]); + }); + + it("reconciles a receipt-owned stopping state without another stop command (#9203)", () => { + const receipt = activeReceipt(); + const { deps, podman } = lifecycleDeps(receipt, false); + let inspectionCount = 0; + podman.mockImplementation((args: readonly string[]) => { + inspectionCount += args[1] === "inspect" ? 1 : 0; + const status = inspectionCount < 4 ? "stopping" : "exited"; + return args[1] === "inspect" + ? { + status: 0, + stdout: JSON.stringify([ + { + Id: CONTAINER_ID, + Image: IMAGE, + Name: receipt.container.name, + Config: { Labels: LABELS }, + State: { Running: false, Paused: false, Status: status }, + HostConfig: { RestartPolicy: { Name: "unless-stopped" } }, + }, + ]), + stderr: "", + } + : poisonUnexpectedCommand("podman", args); + }); + let now = 0; + + const result = withMcpLifecycleLockSync( + SANDBOX, + () => + stopHermesPortableSandboxLifecycle(SANDBOX, lifecycleContext(), vi.fn(), { + ...deps, + now: () => now, + sleep: (milliseconds) => { + now += milliseconds; + }, + }), + { stateDir: path.join(stateDir, "state") }, + ); + + expect(result).toEqual({ kind: "stopped" }); + expect(podman.mock.calls.filter(([args]) => args[1] === "stop")).toEqual([]); + }); + + it("fails closed when OpenShell same-name identity changes (#9203)", () => { + const receipt = activeReceipt(); + const { deps } = lifecycleDeps(receipt); + deps.captureOpenShell = vi.fn((args: readonly string[]) => + args[0] === "policy" + ? { status: 0, stdout: POLICY, stderr: "" } + : { status: 0, stdout: `Name: ${SANDBOX}\nID: replacement\nPhase: Ready\n`, stderr: "" }, + ); + + expect(() => + withMcpLifecycleLockSync( + SANDBOX, + () => recoverHermesPortableSandboxLifecycle(SANDBOX, lifecycleContext(), deps), + { stateDir: path.join(stateDir, "state") }, + ), + ).toThrow("OpenShell sandbox identity disagrees"); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-lifecycle.ts b/src/lib/onboard/experimental/hermes-portable-lifecycle.ts new file mode 100644 index 00000000000..a4b6e0f42bb --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-lifecycle.ts @@ -0,0 +1,504 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn, spawnSync } from "node:child_process"; +import path from "node:path"; +import { TextDecoder } from "node:util"; + +import { + fingerprintOpenShellSandboxLiveIdentity, + parseOpenShellSandboxId, +} from "../../adapters/openshell/sandbox-identity"; +import { + assertHermesPortableOpenShellExecutableAuthority, + buildOpenShellSubprocessEnv, + HERMES_PORTABLE_OPENSHELL_VERSION, + type HermesPortableOpenShellExecutableAuthority, +} from "../../adapters/openshell/resolve-shared"; +import { isMcpLifecycleLockHeld } from "../../state/mcp-lifecycle-lock-acquisition"; +import type { SandboxEntry } from "../../state/registry/types"; +import { assertNoOpenShellGatewayEndpointOverride } from "../../openshell-gateway-endpoint-guard"; +import { + assertCurrentHermesPortableContainer, + observeHermesPortableAuthenticatedHealth, + startHermesPortableContainer, + stopHermesPortableContainer, + type HermesPortableContainerDeps, + type HermesPortableContainerInspection, + type HermesPortablePodmanResult, +} from "./hermes-portable-container"; +import { + createHermesPortablePodmanCommandAuthority, + type HermesPortablePodmanAuthorityDeps, +} from "./hermes-portable-podman-authority"; +import { assertCurrentHermesPortableStoredStartupContract } from "./hermes-portable-contract"; +import { + proveHermesPortableLivePolicy, + type HermesPortablePolicyCaptureResult, +} from "./hermes-portable-policy-authority"; +import { + assertHermesPortableDurablePolicyAuthority, + readHermesPortableLifecycleReceipt, + type HermesPortableConfiguredReceipt, + type HermesPortableLifecycleReceipt, + type HermesPortableReceiptSnapshot, +} from "./hermes-portable-receipt"; +import type { + PortableDemoLifecycleContext, + PortableDemoLifecycleRecoveryResult, + PortableDemoLifecycleStopResult, +} from "./portable-demo-lifecycle"; +import { defaultPortableDemoStateDir } from "./portable-runtime-receipt-readiness"; + +const UTF8 = new TextDecoder("utf-8", { fatal: true }); +const COMMAND_TIMEOUT_MS = 5_000; +const EXEC_READY_TIMEOUT_MS = 90_000; +const STARTUP_TIMEOUT_MS = 90_000; +const POLL_INTERVAL_MS = 1_000; +const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4)); + +export interface HermesPortableLifecycleCommandResult { + readonly status: number | null; + readonly stdout: string | Buffer; + readonly stderr: string | Buffer; + readonly error?: Error; +} + +export interface HermesPortableLifecycleDeps { + readonly stateDir?: string; + readonly env?: NodeJS.ProcessEnv; + readonly readRegistry?: (sandboxName: string) => SandboxEntry | null; + readonly captureOpenShell?: ( + args: readonly string[], + timeoutMs: number, + ) => HermesPortableLifecycleCommandResult; + readonly launchOpenShell?: (args: readonly string[]) => void; + readonly assertOpenShellExecutableAuthority?: ( + authority: HermesPortableOpenShellExecutableAuthority, + childEnv: NodeJS.ProcessEnv, + resolutionEnv: NodeJS.ProcessEnv, + ) => string; + readonly container?: + | HermesPortableContainerDeps + | ((receipt: HermesPortableConfiguredReceipt) => HermesPortableContainerDeps); + readonly podmanAuthorityDeps?: HermesPortablePodmanAuthorityDeps; + readonly now?: () => number; + readonly sleep?: (milliseconds: number) => void; + readonly log?: (message: string) => void; +} + +interface QualifiedHermesPortableLifecycle { + readonly snapshot: HermesPortableReceiptSnapshot & { + readonly receipt: HermesPortableConfiguredReceipt; + }; + readonly receipt: HermesPortableConfiguredReceipt; + readonly containerDeps: HermesPortableContainerDeps; + readonly container: HermesPortableContainerInspection; +} + +function fail(message: string): never { + throw new Error(`Hermes portable lifecycle ${message}`); +} + +function defaultSleep(milliseconds: number): void { + if (milliseconds > 0) Atomics.wait(SLEEP_BUFFER, 0, 0, milliseconds); +} + +function commandOutput(value: string | Buffer, label: string): string { + if (typeof value === "string") return value; + try { + return UTF8.decode(value); + } catch { + fail(`${label} is not strict UTF-8`); + } +} + +function defaultCaptureOpenShell( + binary: string, + commandEnv: NodeJS.ProcessEnv, + runtimeAuthority: HermesPortableConfiguredReceipt["runtimeAuthority"], +): NonNullable { + const env = buildHermesPortableOpenShellEnv(commandEnv, runtimeAuthority); + return (args, timeoutMs) => { + const result = spawnSync(binary, [...args], { + env, + maxBuffer: 512 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + }); + return { + status: result.status, + stdout: result.stdout ?? Buffer.alloc(0), + stderr: result.stderr ?? Buffer.alloc(0), + ...(result.error ? { error: result.error } : {}), + }; + }; +} + +function defaultLaunchOpenShell( + binary: string, + commandEnv: NodeJS.ProcessEnv, + runtimeAuthority: HermesPortableConfiguredReceipt["runtimeAuthority"], +): (args: readonly string[]) => void { + const env = buildHermesPortableOpenShellEnv(commandEnv, runtimeAuthority); + return (args) => { + const child = spawn(binary, [...args], { + detached: true, + env, + shell: false, + stdio: "ignore", + }); + child.once("error", () => undefined); + child.unref(); + }; +} + +export function buildHermesPortableOpenShellEnv( + commandEnv: NodeJS.ProcessEnv, + runtimeAuthority?: HermesPortableConfiguredReceipt["runtimeAuthority"], +): NodeJS.ProcessEnv { + return buildOpenShellSubprocessEnv(commandEnv, runtimeAuthority); +} + +export interface HermesPortableOpenShellCommandAuthority { + readonly env: NodeJS.ProcessEnv; + readonly executablePath: string; +} + +/** Requalify the exact schema-5 executable and child environment for one command. */ +export function buildHermesPortableOpenShellCommandAuthority( + receipt: HermesPortableLifecycleReceipt, + commandEnv: NodeJS.ProcessEnv, + assertAuthority: NonNullable< + HermesPortableLifecycleDeps["assertOpenShellExecutableAuthority"] + > = assertHermesPortableOpenShellExecutableAuthority, +): HermesPortableOpenShellCommandAuthority { + const env = buildHermesPortableOpenShellEnv(commandEnv, receipt.runtimeAuthority); + return { + env, + executablePath: assertAuthority(receipt.openshellExecutableAuthority, env, commandEnv), + }; +} + +function createContainerDeps( + receipt: HermesPortableConfiguredReceipt, + commandEnv: NodeJS.ProcessEnv, + authorityDeps?: HermesPortablePodmanAuthorityDeps, +): HermesPortableContainerDeps { + const authority = createHermesPortablePodmanCommandAuthority( + receipt.podmanExecutableAuthority, + receipt.socketAuthority, + receipt.runtimeAuthority, + commandEnv, + authorityDeps, + ); + return { + podman: (args, timeoutMs): HermesPortablePodmanResult => { + authority.assertCurrent(); + return authority.engine.capture(args, timeoutMs); + }, + assertSocketAuthority: () => authority.engine.assertAuthority(), + }; +} + +function sameSnapshot( + left: HermesPortableReceiptSnapshot, + right: HermesPortableReceiptSnapshot, +): boolean { + return ( + left.path === right.path && + left.identity.dev === right.identity.dev && + left.identity.ino === right.identity.ino && + left.sha256 === right.sha256 && + left.bytes.equals(right.bytes) + ); +} + +function contextMatches( + receipt: HermesPortableConfiguredReceipt, + context: PortableDemoLifecycleContext, +): boolean { + return ( + context.agent === "hermes" && + context.openshellDriver === "docker" && + context.gatewayName === receipt.gatewayName && + context.lifecycleGeneration === receipt.lifecycleGeneration + ); +} + +function observeOpenShellIdentity( + receipt: HermesPortableConfiguredReceipt, + capture: NonNullable, +): { readonly sandboxId: string; readonly liveIdentityFingerprint: string } { + const gateway = capture(["sandbox", "list", "-g", receipt.gatewayName], COMMAND_TIMEOUT_MS); + if (gateway.status !== 0 || gateway.error) fail("cannot prove the selected gateway reachable"); + const current = capture( + ["sandbox", "get", "-g", receipt.gatewayName, receipt.sandboxName], + COMMAND_TIMEOUT_MS, + ); + if (current.status !== 0 || current.error) fail("cannot prove the current OpenShell sandbox"); + const output = commandOutput(current.stdout, "sandbox identity output"); + const sandboxId = parseOpenShellSandboxId(output); + const liveIdentityFingerprint = fingerprintOpenShellSandboxLiveIdentity(output); + if (!sandboxId || !liveIdentityFingerprint || sandboxId !== receipt.container.sandboxId) { + fail("OpenShell sandbox identity disagrees with the receipt container"); + } + return { sandboxId, liveIdentityFingerprint }; +} + +function policyCapture( + capture: NonNullable, +): (args: readonly string[]) => HermesPortablePolicyCaptureResult { + return (args) => { + const result = capture(args, COMMAND_TIMEOUT_MS); + return { + status: result.status, + stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout, "utf8"), + stderr: Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr, "utf8"), + ...(result.error ? { error: result.error } : {}), + }; + }; +} + +function requireRegistry( + receipt: HermesPortableConfiguredReceipt, + liveIdentityFingerprint: string, + deps: HermesPortableLifecycleDeps, +): void { + const entry = deps.readRegistry?.(receipt.sandboxName); + if ( + !entry || + entry.name !== receipt.sandboxName || + entry.agent !== "hermes" || + entry.openshellDriver !== "docker" || + entry.gatewayName !== receipt.gatewayName || + entry.lifecycleGeneration !== receipt.lifecycleGeneration || + entry.lifecycleLiveIdentityFingerprint !== liveIdentityFingerprint || + entry.openshellVersion !== HERMES_PORTABLE_OPENSHELL_VERSION + ) { + fail("registry authority disagrees with the active receipt"); + } +} + +function qualify( + sandboxName: string, + context: PortableDemoLifecycleContext, + deps: HermesPortableLifecycleDeps, + expected?: HermesPortableReceiptSnapshot, +): QualifiedHermesPortableLifecycle { + const commandEnv = deps.env ?? process.env; + assertNoOpenShellGatewayEndpointOverride(commandEnv); + const stateDir = deps.stateDir ?? defaultPortableDemoStateDir(commandEnv); + const lockStateDir = path.join(stateDir, "state"); + if (!isMcpLifecycleLockHeld(sandboxName, lockStateDir)) { + fail("mutation requires the sandbox lifecycle lock"); + } + const snapshot = readHermesPortableLifecycleReceipt(sandboxName, stateDir); + if (!snapshot) fail("active receipt authority disappeared"); + if (expected && !sameSnapshot(snapshot, expected)) fail("receipt authority changed"); + if (snapshot.receipt.phase !== "active") { + fail(`receipt phase '${snapshot.receipt.phase}' is incomplete and cannot run commands`); + } + const receipt = snapshot.receipt; + if (!contextMatches(receipt, context)) fail("registry context disagrees with the active receipt"); + assertCurrentHermesPortableStoredStartupContract(receipt.startup, sandboxName); + const durablePolicy = assertHermesPortableDurablePolicyAuthority(receipt.policy); + const assertExecutable = + deps.assertOpenShellExecutableAuthority ?? assertHermesPortableOpenShellExecutableAuthority; + const initialCommandAuthority = buildHermesPortableOpenShellCommandAuthority( + receipt, + commandEnv, + assertExecutable, + ); + const rawCapture = + deps.captureOpenShell ?? + defaultCaptureOpenShell( + initialCommandAuthority.executablePath, + commandEnv, + receipt.runtimeAuthority, + ); + const capture: NonNullable = ( + args, + timeoutMs, + ) => { + buildHermesPortableOpenShellCommandAuthority(receipt, commandEnv, assertExecutable); + return rawCapture(args, timeoutMs); + }; + const policy = proveHermesPortableLivePolicy({ + gatewayName: receipt.gatewayName, + sandboxName, + createPolicyBytes: durablePolicy, + capture: policyCapture(capture), + }); + if ( + policy.intendedSemanticSha256 !== receipt.policy.intendedSemanticSha256 || + policy.verifiedLivePolicySemanticSha256 !== receipt.verifiedLivePolicySemanticSha256 + ) { + fail("live policy authority disagrees with the active receipt"); + } + const liveIdentity = observeOpenShellIdentity(receipt, capture); + requireRegistry(receipt, liveIdentity.liveIdentityFingerprint, deps); + const containerDeps = + typeof deps.container === "function" + ? deps.container(receipt) + : (deps.container ?? createContainerDeps(receipt, commandEnv, deps.podmanAuthorityDeps)); + const container = assertCurrentHermesPortableContainer(receipt, containerDeps); + if (container.paused || container.authority.restartPolicy !== "unless-stopped") { + fail("container state or restart policy disagrees with active authority"); + } + return { + snapshot: snapshot as QualifiedHermesPortableLifecycle["snapshot"], + receipt, + containerDeps, + container, + }; +} + +function openshellExecArgs(receipt: HermesPortableConfiguredReceipt, command: readonly string[]) { + return [ + "sandbox", + "exec", + "-g", + receipt.gatewayName, + "--name", + receipt.sandboxName, + "--no-tty", + "--", + ...command, + ]; +} + +function waitFor( + timeoutMs: number, + deps: HermesPortableLifecycleDeps, + probe: (remainingMs: number) => boolean, +): boolean { + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? defaultSleep; + const deadline = now() + timeoutMs; + do { + const remaining = Math.max(1, deadline - now()); + if (probe(remaining)) return true; + sleep(Math.min(POLL_INTERVAL_MS, remaining)); + } while (now() < deadline); + return false; +} + +/** Recover the exact schema-5 container and manifest-owned Hermes startup. */ +export function recoverHermesPortableSandboxLifecycle( + sandboxName: string, + context: PortableDemoLifecycleContext, + deps: HermesPortableLifecycleDeps = {}, +): PortableDemoLifecycleRecoveryResult { + let qualified = qualify(sandboxName, context, deps); + const wasRunning = qualified.container.authority.running; + if (!wasRunning) { + startHermesPortableContainer(qualified.receipt, qualified.containerDeps); + qualified = qualify(sandboxName, context, deps, qualified.snapshot); + } + const commandEnv = deps.env ?? process.env; + const assertExecutable = + deps.assertOpenShellExecutableAuthority ?? assertHermesPortableOpenShellExecutableAuthority; + const commandAuthority = buildHermesPortableOpenShellCommandAuthority( + qualified.receipt, + commandEnv, + assertExecutable, + ); + const rawCapture = + deps.captureOpenShell ?? + defaultCaptureOpenShell( + commandAuthority.executablePath, + commandEnv, + qualified.receipt.runtimeAuthority, + ); + const capture: NonNullable = ( + args, + timeoutMs, + ) => { + buildHermesPortableOpenShellCommandAuthority(qualified.receipt, commandEnv, assertExecutable); + return rawCapture(args, timeoutMs); + }; + const execReady = waitFor(EXEC_READY_TIMEOUT_MS, deps, (remainingMs) => { + const result = capture( + openshellExecArgs(qualified.receipt, ["true"]), + Math.min(COMMAND_TIMEOUT_MS, remainingMs), + ); + return result.status === 0 && !result.error; + }); + if (!execReady) fail("did not reconnect to the selected OpenShell gateway"); + qualified = qualify(sandboxName, context, deps, qualified.snapshot); + if ( + observeHermesPortableAuthenticatedHealth(qualified.receipt, qualified.containerDeps) === "ready" + ) { + qualify(sandboxName, context, deps, qualified.snapshot); + return wasRunning ? { kind: "already-running" } : { kind: "recovered" }; + } + qualified = qualify(sandboxName, context, deps, qualified.snapshot); + const rawLaunch = + deps.launchOpenShell ?? + defaultLaunchOpenShell( + commandAuthority.executablePath, + commandEnv, + qualified.receipt.runtimeAuthority, + ); + const launch = (args: readonly string[]): void => { + buildHermesPortableOpenShellCommandAuthority(qualified.receipt, commandEnv, assertExecutable); + rawLaunch(args); + }; + launch(openshellExecArgs(qualified.receipt, qualified.receipt.startup.argv)); + const recovered = waitFor(STARTUP_TIMEOUT_MS, deps, () => { + const current = qualify(sandboxName, context, deps, qualified.snapshot); + return ( + observeHermesPortableAuthenticatedHealth(current.receipt, current.containerDeps) === "ready" + ); + }); + if (!recovered) fail("managed startup did not pass authenticated health"); + qualify(sandboxName, context, deps, qualified.snapshot); + (deps.log ?? console.log)(` Hermes portable lifecycle recovered sandbox '${sandboxName}'.`); + return { kind: "recovered" }; +} + +/** Requalify active schema-5 authority without starting or changing the sandbox. */ +export function assertHermesPortableSandboxLifecycleAuthority( + sandboxName: string, + context: PortableDemoLifecycleContext, + deps: HermesPortableLifecycleDeps = {}, +): void { + const qualified = qualify(sandboxName, context, deps); + if (!qualified.container.authority.running) fail("exact container is not running"); + if ( + observeHermesPortableAuthenticatedHealth(qualified.receipt, qualified.containerDeps) !== "ready" + ) { + fail("authenticated health is not ready"); + } + qualify(sandboxName, context, deps, qualified.snapshot); +} + +/** Stop only the exact schema-5 full ID after revalidating after the callback. */ +export function stopHermesPortableSandboxLifecycle( + sandboxName: string, + context: PortableDemoLifecycleContext, + beforeStop: () => void, + deps: HermesPortableLifecycleDeps = {}, +): PortableDemoLifecycleStopResult { + let qualified = qualify(sandboxName, context, deps); + if (!qualified.container.authority.running && qualified.container.status === "exited") { + return { kind: "already-stopped" }; + } + if (qualified.container.authority.running) beforeStop(); + qualified = qualify(sandboxName, context, deps, qualified.snapshot); + const result = stopHermesPortableContainer(qualified.receipt, { + ...qualified.containerDeps, + ...(deps.now ? { now: deps.now } : {}), + ...(deps.sleep ? { sleep: deps.sleep } : {}), + }); + const final = qualify(sandboxName, context, deps, qualified.snapshot); + if (final.container.authority.running) fail("exact container remained running after stop"); + return { kind: result }; +} + +export const hermesPortableLifecycleInternals = { + buildHermesPortableOpenShellEnv, + createContainerDeps, + qualify, +}; diff --git a/src/lib/onboard/experimental/hermes-portable-onboarding.test.ts b/src/lib/onboard/experimental/hermes-portable-onboarding.test.ts new file mode 100644 index 00000000000..7d67a9bb0e7 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-onboarding.test.ts @@ -0,0 +1,1184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PodmanSocketAuthority, PodmanSocketAuthorityDeps } from "../../adapters/podman"; +import type { HermesPortableOpenShellExecutableAuthority } from "../../adapters/openshell/resolve-shared"; +import type { HermesPortablePodmanExecutableAuthority } from "./hermes-portable-podman-authority"; +import { loadAgent } from "../../agent/defs"; +import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock-acquisition"; +import type { SandboxEntry } from "../../state/registry"; +import { + isCurrentSandboxInferenceRouteReservation, + normalizeSandboxInferenceRouteSelection, +} from "../../state/registry/route-reservation"; +import { + captureHermesPortablePolicySource, + createHermesPortableTransactionId, + hermesPortableReceiptDirectory, + publishHermesPortableDurablePolicySource, +} from "./hermes-portable-receipt"; +import { hermesPortableCreatePolicySemanticDigest } from "./hermes-portable-policy-authority"; +import { + classifyHermesPortableRegistry, + createHermesPortableChildEnvironment, + createHermesPortableReadyRunner, + createHermesPortableOpenShellCapture, + createHermesPortableReadyCapture, + observeHermesPortableSandbox, + rewriteHermesPortableCreatePolicyArgv, + runHermesPortableOnboardingTransaction, + scopeHermesPortableCreateGatewayArgv, + shouldManageHermesPortableDashboard, + type HermesPortableOnboardingDeps, +} from "./hermes-portable-onboarding"; + +const ID = "a".repeat(64); +const IMAGE = "b".repeat(64); +const SANDBOX_ID = "sandbox-id-1"; +const LIVE_IDENTITY_FINGERPRINT = "live-identity-1"; +const ROUTE_SESSION_ID = "session-alpha"; +const POLICY = "version: 1\nnetwork_policies: {}\n"; +const LABELS = { + "openshell.managed": "true", + "openshell.ai/sandbox-id": SANDBOX_ID, + "openshell.ai/sandbox-name": "alpha", + "openshell.ai/sandbox-namespace": "", + "openshell.ai/sandbox-workspace": "default", +}; + +let stateDir: string; +let policyPath: string; + +function result(stdout: string, status = 0) { + return { status, stdout: Buffer.from(stdout), stderr: Buffer.alloc(0) }; +} + +function inspect(restartPolicy: string) { + return { + status: 0, + stdout: JSON.stringify([ + { + Id: ID, + Image: IMAGE, + Name: `openshell-default--alpha-${SANDBOX_ID}`, + Config: { Labels: LABELS }, + State: { Running: true, Paused: false, Status: "running" }, + HostConfig: { RestartPolicy: { Name: restartPolicy } }, + }, + ]), + stderr: "", + }; +} + +function startupArgv() { + return [ + "env", + "NEMOCLAW_HERMES_API_PORT=8642", + "NEMOCLAW_SANDBOX_NAME=alpha", + "/usr/local/bin/nemoclaw-start", + ]; +} + +function directoryChain(directory: string): string[] { + const parent = path.dirname(directory); + return parent === directory ? [directory] : [directory, ...directoryChain(parent)]; +} + +function unexpectedPodmanArgs(args: readonly string[]): never { + throw new Error(`unexpected podman args: ${args.join(" ")}`); +} + +function removePolicySource(): true { + fs.unlinkSync(policyPath); + return true; +} + +function interruptReceiptWrite( + marker: Buffer, + message: string, + writtenLength: (requestedLength: number) => number, +): void { + const originalWrite = fs.writeSync; + let interrupted = false; + const writeSpy = vi.spyOn(fs, "writeSync") as unknown as { + mockImplementation( + implementation: ( + descriptor: number, + buffer: Uint8Array, + offset: number, + length: number, + position: number | null, + ) => number, + ): void; + }; + writeSpy.mockImplementation((descriptor, buffer, offset, length, position) => { + interrupted && + (() => { + throw new Error(message); + })(); + const requested = Buffer.from(buffer).subarray(offset, offset + length); + return position === 0 && requested.includes(marker) + ? ((interrupted = true), + originalWrite(descriptor, buffer, offset, writtenLength(length), position)) + : originalWrite(descriptor, buffer, offset, length, position); + }); +} + +function interruptCanonicalReceiptLink(phase: "configuring" | "active"): void { + const originalLink = fs.linkSync; + let interrupted = false; + vi.spyOn(fs, "linkSync").mockImplementation((source, target) => { + originalLink(source, target); + !interrupted && + String(target).endsWith(`${phase}.json`) && + (() => { + interrupted = true; + throw new Error(`simulated ${phase} canonical-link exit`); + })(); + }); +} + +function openshellExecutableAuthority(): HermesPortableOpenShellExecutableAuthority { + return { + version: "0.0.101", + executable: { + executablePath: "/usr/bin/openshell", + device: "1", + inode: "10", + mode: String(0o100755), + ownerUid: "0", + size: "1024", + modifiedTimeNanoseconds: "11", + changedTimeNanoseconds: "12", + sha256: "f".repeat(64), + directoryChain: ["/usr/bin", "/usr", "/"].map((directory, index) => ({ + device: "1", + inode: String(index + 20), + mode: String(0o40755), + ownerUid: "0", + path: directory, + })), + }, + }; +} + +function podmanExecutableAuthority(): HermesPortablePodmanExecutableAuthority { + return { + version: "5.7.0", + executable: { + executablePath: "/usr/bin/podman", + device: "1", + inode: "30", + mode: String(0o100755), + ownerUid: "0", + size: "2048", + modifiedTimeNanoseconds: "31", + changedTimeNanoseconds: "32", + sha256: "9".repeat(64), + directoryChain: ["/usr/bin", "/usr", "/"].map((directory, index) => ({ + device: "1", + inode: String(index + 40), + mode: String(0o40755), + ownerUid: "0", + path: directory, + })), + }, + }; +} + +function routeSelection() { + return { + provider: "ollama-local", + model: "qwen3-vl:4b", + endpointUrl: null, + endpointSource: null, + credentialEnv: null, + preferredInferenceApi: null, + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + } as const; +} + +function matchingRegistryEntry( + options: { openshellVersion?: string | null; liveFingerprint?: string } = {}, +): SandboxEntry { + return { + name: "alpha", + agent: "hermes", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + openshellDriver: "docker", + lifecycleLiveIdentityFingerprint: options.liveFingerprint ?? LIVE_IDENTITY_FINGERPRINT, + openshellVersion: "openshellVersion" in options ? options.openshellVersion : "0.0.101", + }; +} + +function input() { + const uid = process.getuid!(); + const sourceDockerfilePath = `ghcr.io/nvidia/nemoclaw/hermes@sha256:${"a".repeat(64)}`; + return { + sandboxName: "alpha", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + stateDir, + createPolicyPath: policyPath, + createArgv: [ + "/usr/bin/openshell", + "sandbox", + "create", + "-g", + "nemoclaw", + "--from", + sourceDockerfilePath, + "--name", + "alpha", + "--policy", + policyPath, + "--", + ...startupArgv(), + ], + runtimeAuthority: { + schemaVersion: 1 as const, + kind: "podman" as const, + ownership: "current-user" as const, + uid, + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: `/run/user/${String(uid)}`, + socketPath: `/run/user/${String(uid)}/podman/podman.sock`, + }, + openshellExecutableAuthority: openshellExecutableAuthority(), + buildContext: { + authority: { + schemaVersion: 1 as const, + sourceRevision: "1".repeat(40), + dockerfileRelativePath: "agents/hermes/Dockerfile" as const, + sourceManifestSha256: "2".repeat(64), + contextManifestSha256: "3".repeat(64), + }, + sourceDockerfilePath, + assertCurrentSource: vi.fn(), + materialize: vi.fn(() => ({ + buildContextPath: "/private/staged-hermes", + dockerfilePath: "/private/staged-hermes/agents/hermes/Dockerfile", + assertCurrent: vi.fn(), + })), + retire: vi.fn(() => true), + }, + startup: { + agent: loadAgent("hermes"), + sandboxName: "alpha", + startupArgv: startupArgv(), + }, + inferenceRouteReservation: { + sessionId: ROUTE_SESSION_ID, + selection: routeSelection(), + }, + }; +} + +function reservationForOnboarding(current: ReturnType = input()): SandboxEntry { + return { + name: current.sandboxName, + pendingRouteReservation: true, + reservationSessionId: current.inferenceRouteReservation.sessionId, + ...normalizeSandboxInferenceRouteSelection(current.inferenceRouteReservation.selection), + gatewayName: current.gatewayName, + hostLocalInferenceReceipt: "receipt-1", + }; +} + +function deps( + options: { + existingSandbox?: boolean; + updateFails?: boolean; + failAfterRegistry?: boolean; + cleanupFails?: boolean; + assertSocketAuthority?: ( + expected: PodmanSocketAuthority, + deps?: PodmanSocketAuthorityDeps, + ) => void; + assertOpenShellExecutableAuthority?: () => void; + afterRegistryCommit?: () => void | Promise; + observeSandbox?: HermesPortableOnboardingDeps<{ ready: true }>["observeSandbox"]; + registryOpenShellVersion?: string | null; + registryLiveFingerprint?: string; + existingRegistry?: boolean; + registryEntry?: SandboxEntry | null; + replaceRegistryBeforeRegistration?: SandboxEntry | null; + podmanAuthority?: HermesPortablePodmanExecutableAuthority; + } = {}, +) { + let present = options.existingSandbox === true; + let restartPolicy = "no"; + let registryEntry = + "registryEntry" in options + ? (options.registryEntry ?? null) + : options.existingRegistry === true + ? matchingRegistryEntry({ + ...("registryOpenShellVersion" in options + ? { openshellVersion: options.registryOpenShellVersion } + : {}), + liveFingerprint: options.registryLiveFingerprint, + }) + : reservationForOnboarding(); + const registryFailures = options.failAfterRegistry + ? [new Error("simulated registry-to-active exit")] + : []; + const events: string[] = []; + const podman = vi.fn((args: readonly string[]) => { + const operation = args[0] === "ps" ? "ps" : args.slice(0, 2).join(" "); + const handlers = new Map< + string, + () => { status: number | null; stdout: string; stderr: string } + >([ + ["ps", () => ({ status: 0, stdout: `${ID}\n`, stderr: "" })], + ["container inspect", () => inspect(restartPolicy)], + ["container exec", () => ({ status: 0, stdout: "200\n", stderr: "" })], + [ + "container update", + () => { + restartPolicy = options.updateFails ? restartPolicy : "unless-stopped"; + return options.updateFails + ? { status: null, stdout: "", stderr: "timed out" } + : { status: 0, stdout: "", stderr: "" }; + }, + ], + ]); + return handlers.get(operation)?.() ?? unexpectedPodmanArgs(args); + }); + const value: HermesPortableOnboardingDeps<{ ready: true }> = { + withLifecycleLock: async (_sandboxName, operation) => { + events.push("lock-enter"); + try { + return await withMcpLifecycleLock("alpha", operation, { + stateDir: path.join(stateDir, "state"), + }); + } finally { + events.push("lock-exit"); + } + }, + captureSocketAuthority: (socketPath) => { + const directories = directoryChain(path.dirname(socketPath)); + return { + device: "1", + inode: "2", + mode: "49536", + ownerUid: String(process.getuid!()), + socketPath, + directoryChain: directories.map((directory, index) => ({ + device: "1", + inode: String(index + 3), + mode: String(index === 0 ? 0o40700 : 0o40755), + ownerUid: String(index === 0 ? process.getuid!() : 0), + path: directory, + })), + }; + }, + capturePodmanExecutableAuthority: () => options.podmanAuthority ?? podmanExecutableAuthority(), + container: { + podman, + assertSocketAuthority: options.assertSocketAuthority ?? vi.fn(), + }, + assertOpenShellExecutableAuthority: options.assertOpenShellExecutableAuthority ?? vi.fn(), + capturePolicy: (args) => { + events.push(args.includes("--base") ? "policy-base" : "policy-full"); + return result(POLICY); + }, + observeSandbox: + options.observeSandbox ?? + (() => + present + ? { + kind: "present", + sandboxId: SANDBOX_ID, + liveIdentityFingerprint: LIVE_IDENTITY_FINGERPRINT, + } + : { kind: "absent" }), + createSandbox: async (argv, buildContextPath) => { + events.push("create"); + const policyIndex = argv.indexOf("--policy"); + expect(argv[policyIndex + 1]).toContain("policy."); + expect(argv[argv.indexOf("--from") + 1]).toBe( + "/private/staged-hermes/agents/hermes/Dockerfile", + ); + expect(buildContextPath).toBe("/private/staged-hermes"); + present = true; + return { ready: true }; + }, + readRegistry: () => registryEntry, + registerSandbox: ( + _result, + _receipt, + _liveIdentityFingerprint, + revalidate, + routeReservation, + ) => { + registryEntry = + "replaceRegistryBeforeRegistration" in options + ? (options.replaceRegistryBeforeRegistration ?? null) + : registryEntry; + isCurrentSandboxInferenceRouteReservation(routeReservation, registryEntry) || + (() => { + throw new Error( + "Cannot register a sandbox after its inference route reservation changed", + ); + })(); + revalidate(); + events.push("registry"); + registryEntry = matchingRegistryEntry(); + return registryEntry; + }, + afterRegistryCommit: async () => { + const failure = registryFailures.shift(); + await (failure ? Promise.reject(failure) : options.afterRegistryCommit?.()); + }, + cleanupTemporaryPolicy: () => { + events.push("temp-cleanup"); + return options.cleanupFails ? false : removePolicySource(); + }, + }; + return { value, events, podman }; +} + +beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-onboard-")); + policyPath = path.join(stateDir, "create.yaml"); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); +}); + +afterEach(() => fs.rmSync(stateDir, { recursive: true, force: true })); + +describe("Hermes portable onboarding transaction", () => { + it("uses only the receipt-owned child environment and exact gateway observations (#9203)", () => { + const runtimeAuthority = input().runtimeAuthority; + const sourceEnv = { + HOME: "/home/test", + PATH: "/usr/bin", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: runtimeAuthority.runtimeDir, + XDG_CACHE_HOME: "/tmp/ambient-cache", + SSL_CERT_FILE: "/etc/ssl/certs.pem", + OPENSHELL_GATEWAY: "ambient", + DOCKER_HOST: "unix:///run/docker.sock", + KUBECONFIG: "/home/test/.kube/config", + SSH_AUTH_SOCK: "/run/user/1000/agent.sock", + HTTPS_PROXY: "https://user:secret@proxy.example", + } satisfies NodeJS.ProcessEnv; + const spawn = vi.fn(() => ({ + status: 0, + signal: null, + output: [], + pid: 1, + stdout: Buffer.from("Name: alpha\nID: sandbox-id-1\nPhase: Ready\n"), + stderr: Buffer.alloc(0), + error: undefined, + })); + const capture = createHermesPortableOpenShellCapture( + (args) => ["/usr/bin/openshell", ...args], + sourceEnv, + runtimeAuthority, + undefined, + spawn as never, + ); + const ready = createHermesPortableReadyCapture("alpha", "nemoclaw", capture); + + expect(ready(["sandbox", "list"])).toContain("Phase: Ready"); + expect(spawn).toHaveBeenCalledWith( + "/usr/bin/openshell", + ["sandbox", "list", "-g", "nemoclaw"], + expect.objectContaining({ + env: { + HOME: "/home/test", + PATH: "/usr/bin", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: runtimeAuthority.runtimeDir, + SSL_CERT_FILE: "/etc/ssl/certs.pem", + }, + }), + ); + expect(createHermesPortableChildEnvironment(sourceEnv, runtimeAuthority)).not.toHaveProperty( + "OPENSHELL_GATEWAY", + ); + expect(createHermesPortableChildEnvironment(sourceEnv, runtimeAuthority)).not.toHaveProperty( + "XDG_CACHE_HOME", + ); + expect(() => + createHermesPortableChildEnvironment({ ...sourceEnv, HOME: "/home/other" }, runtimeAuthority), + ).toThrow("HOME disagrees with runtime authority"); + }); + + it("rejects ambient endpoint selectors before an OpenShell child starts (#9203)", () => { + const spawn = vi.fn(); + const capture = createHermesPortableOpenShellCapture( + (args) => ["/usr/bin/openshell", ...args], + { HOME: "/home/test", OPENSHELL_GATEWAY_ENDPOINT: "https://ambient.invalid" }, + input().runtimeAuthority, + undefined, + spawn as never, + ); + + expect(() => capture(["sandbox", "list", "-g", "nemoclaw"])).toThrow( + "OPENSHELL_GATEWAY_ENDPOINT is set", + ); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("adds exactly one create gateway and rejects existing gateway selection (#9203)", () => { + const current = input(); + const unscoped = current.createArgv.filter((value) => !["-g", "nemoclaw"].includes(value)); + expect(scopeHermesPortableCreateGatewayArgv(unscoped, "nemoclaw")).toEqual(current.createArgv); + expect(() => scopeHermesPortableCreateGatewayArgv(current.createArgv, "nemoclaw")).toThrow( + "already contains gateway selection authority", + ); + }); + + it("does not enroll dashboard/TUI forward authority for schema-5 Hermes (#9203)", () => { + expect( + shouldManageHermesPortableDashboard(true, loadAgent("hermes"), { + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }), + ).toBe(false); + expect( + shouldManageHermesPortableDashboard(true, loadAgent("hermes"), { + NEMOCLAW_EXPERIMENTAL_PROFILE: "default", + }), + ).toBe(true); + expect( + shouldManageHermesPortableDashboard(true, loadAgent("openclaw"), { + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }), + ).toBe(true); + }); + + it("holds one lock through reserve, create, configuring, registry, and active publication (#9203)", async () => { + const fixture = deps(); + + const completed = await runHermesPortableOnboardingTransaction(input(), fixture.value); + + expect(completed.active.receipt.phase).toBe("active"); + expect(completed.created).toBe(true); + expect(fixture.events[0]).toBe("lock-enter"); + expect(fixture.events.at(-1)).toBe("lock-exit"); + expect(fixture.events.indexOf("create")).toBeLessThan(fixture.events.indexOf("registry")); + expect(fixture.events.indexOf("registry")).toBeLessThan( + fixture.events.lastIndexOf("policy-base"), + ); + }); + + it("rejects a pre-existing live sandbox before durable reservation (#9203)", async () => { + const fixture = deps({ existingSandbox: true }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "live sandbox authority already exists before reservation", + ); + expect(fs.existsSync(hermesPortableReceiptDirectory("alpha", stateDir))).toBe(false); + expect(fixture.events).not.toContain("create"); + expect(fixture.podman).not.toHaveBeenCalled(); + }); + + it("rejects a pre-existing registry row before durable reservation (#9203)", async () => { + const fixture = deps({ existingRegistry: true }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "inference route reservation is not owned by the current onboarding session", + ); + expect(fs.existsSync(hermesPortableReceiptDirectory("alpha", stateDir))).toBe(false); + expect(fixture.events).not.toContain("create"); + expect(fixture.podman).not.toHaveBeenCalled(); + }); + + it("admits the current session's exact inference route reservation before registration (#9203)", async () => { + const fixture = deps(); + + const completed = await runHermesPortableOnboardingTransaction(input(), fixture.value); + + expect(completed.active.receipt.phase).toBe("active"); + expect(completed.created).toBe(true); + expect(fixture.events).toContain("registry"); + }); + + it("aborts active publication after registry-side route replacement (#9203)", async () => { + const replacement = { + ...reservationForOnboarding(), + reservationSessionId: "session-beta", + model: "qwen3:8b", + }; + const fixture = deps({ replaceRegistryBeforeRegistration: replacement }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "Cannot register a sandbox after its inference route reservation changed", + ); + expect(fixture.events).not.toContain("registry"); + expect( + fs.existsSync(path.join(hermesPortableReceiptDirectory("alpha", stateDir), "active.json")), + ).toBe(false); + }); + + it("resumes identical pending authority with effects without a duplicate create (#9203)", async () => { + const first = deps({ updateFails: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "restart-policy update failed", + ); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + const second = deps({ existingSandbox: true }); + + const resumed = await runHermesPortableOnboardingTransaction(input(), second.value); + + expect(resumed.active.receipt.phase).toBe("active"); + expect(resumed.created).toBe(false); + expect(second.events).not.toContain("create"); + expect(second.events).toContain("temp-cleanup"); + expect(fs.existsSync(policyPath)).toBe(false); + }); + + it("rejects changed non-policy create intent on pending reentry before effects (#9203)", async () => { + const first = deps({ cleanupFails: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "temporary policy cleanup did not complete", + ); + const pendingBytes = fs.readFileSync( + path.join(hermesPortableReceiptDirectory("alpha", stateDir), "pending.json"), + "utf8", + ); + expect(JSON.parse(pendingBytes).createIntentSha256).toMatch(/^[a-f0-9]{64}$/u); + expect(pendingBytes).not.toContain("ghcr.io/nvidia/nemoclaw/hermes"); + const changed = input(); + changed.createArgv.splice(changed.createArgv.indexOf("--"), 0, "--gpu"); + const second = deps(); + + await expect(runHermesPortableOnboardingTransaction(changed, second.value)).rejects.toThrow( + "saved transaction disagrees", + ); + expect(second.events).not.toContain("create"); + }); + + it("rejects a new onboarding session taking over an existing pending receipt (#9203)", async () => { + const first = deps({ cleanupFails: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "temporary policy cleanup did not complete", + ); + const changed = input(); + changed.inferenceRouteReservation = { + ...changed.inferenceRouteReservation, + sessionId: "session-beta", + }; + const second = deps({ + registryEntry: reservationForOnboarding(changed), + }); + + await expect(runHermesPortableOnboardingTransaction(changed, second.value)).rejects.toThrow( + "saved transaction disagrees", + ); + expect(second.events).not.toContain("create"); + }); + + it("binds the staged build-context manifest into pending create intent (#9203)", async () => { + const localInput = input(); + const first = deps({ cleanupFails: true }); + await expect(runHermesPortableOnboardingTransaction(localInput, first.value)).rejects.toThrow( + "temporary policy cleanup did not complete", + ); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + const changed = input(); + changed.buildContext.authority = { + ...changed.buildContext.authority, + contextManifestSha256: "4".repeat(64), + }; + const second = deps(); + + await expect(runHermesPortableOnboardingTransaction(changed, second.value)).rejects.toThrow( + "saved transaction disagrees", + ); + expect(second.events).not.toContain("create"); + }); + + it("rejects a changed OpenShell executable identity on pending reentry (#9203)", async () => { + const first = deps({ cleanupFails: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "temporary policy cleanup did not complete", + ); + const changed = input(); + changed.createArgv[0] = "/other/openshell"; + const second = deps(); + + await expect(runHermesPortableOnboardingTransaction(changed, second.value)).rejects.toThrow( + "saved transaction disagrees", + ); + expect(second.events).not.toContain("create"); + }); + + it("rejects changed Podman executable authority on pending reentry before effects (#9203)", async () => { + const first = deps({ cleanupFails: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "temporary policy cleanup did not complete", + ); + const changedAuthority = podmanExecutableAuthority(); + const second = deps({ + podmanAuthority: { + ...changedAuthority, + executable: { ...changedAuthority.executable, inode: "31" }, + }, + }); + + await expect(runHermesPortableOnboardingTransaction(input(), second.value)).rejects.toThrow( + "saved transaction disagrees", + ); + expect(second.events).not.toContain("create"); + expect(second.podman).not.toHaveBeenCalled(); + }); + + it("rejects executable generation drift before OpenShell, create, or Podman effects (#9203)", async () => { + const fixture = deps({ + assertOpenShellExecutableAuthority: () => { + throw new Error("simulated OpenShell executable generation drift"); + }, + }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "simulated OpenShell executable generation drift", + ); + expect(fixture.events).not.toContain("create"); + expect(fixture.events).not.toContain("policy-base"); + expect(fixture.podman).not.toHaveBeenCalled(); + }); + + it("keeps configuring authority when registry OpenShell version disagrees (#9203)", async () => { + const first = deps({ failAfterRegistry: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "registry-to-active exit", + ); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + const fixture = deps({ + existingSandbox: true, + existingRegistry: true, + registryOpenShellVersion: "0.0.102", + }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "registry conflicts with configuring authority", + ); + expect( + JSON.parse( + fs.readFileSync( + path.join(hermesPortableReceiptDirectory("alpha", stateDir), "configuring.json"), + "utf8", + ), + ).phase, + ).toBe("configuring"); + expect( + fs.existsSync(path.join(hermesPortableReceiptDirectory("alpha", stateDir), "active.json")), + ).toBe(false); + expect( + fixture.podman.mock.calls.some( + ([args]) => Array.isArray(args) && args[0] === "container" && args[1] === "update", + ), + ).toBe(false); + }); + + it("does not update Podman when a matching registry row has stale live identity (#9203)", async () => { + const first = deps({ failAfterRegistry: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "registry-to-active exit", + ); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + const resumed = deps({ + existingSandbox: true, + existingRegistry: true, + registryLiveFingerprint: "0".repeat(64), + }); + + await expect(runHermesPortableOnboardingTransaction(input(), resumed.value)).rejects.toThrow( + "registry live identity disagrees", + ); + expect( + resumed.podman.mock.calls.some( + ([args]) => Array.isArray(args) && args[0] === "container" && args[1] === "update", + ), + ).toBe(false); + }); + + it("rejects receipt-gateway drift on pending reentry before create (#9203)", async () => { + const first = deps({ cleanupFails: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "temporary policy cleanup did not complete", + ); + const changed = input(); + changed.gatewayName = "other-gateway"; + changed.createArgv[changed.createArgv.indexOf("-g") + 1] = "other-gateway"; + const second = deps({ registryEntry: reservationForOnboarding(changed) }); + + await expect(runHermesPortableOnboardingTransaction(changed, second.value)).rejects.toThrow( + "saved transaction disagrees", + ); + expect(second.events).not.toContain("create"); + }); + + it("rejects credential-bearing create env before durable reservation (#9203)", async () => { + const current = input(); + current.createArgv.splice(current.createArgv.indexOf("--"), 0, "--env", "API_KEY=do-not-log"); + const fixture = deps(); + + await expect(runHermesPortableOnboardingTransaction(current, fixture.value)).rejects.toThrow( + "unsupported effect-bearing option", + ); + expect(fs.existsSync(hermesPortableReceiptDirectory("alpha", stateDir))).toBe(false); + expect(fixture.events).not.toContain("create"); + }); + + it("resumes an exact interrupted pending receipt prefix after process-style reentry (#9203)", async () => { + interruptReceiptWrite( + Buffer.from('{"schemaVersion":5'), + "simulated process exit during pending write", + (length) => Math.floor(length / 2), + ); + const first = deps(); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + "simulated process exit during pending write", + ); + vi.restoreAllMocks(); + + const second = deps(); + const resumed = await runHermesPortableOnboardingTransaction(input(), second.value); + + expect(resumed.active.receipt.phase).toBe("active"); + expect(second.events.filter((event) => event === "create")).toHaveLength(1); + }); + + it.each(["configuring", "active"] as const)( + "resumes an exact interrupted %s receipt prefix after process-style reentry (#9203)", + async (phase) => { + interruptReceiptWrite( + Buffer.from(`\"phase\":\"${phase}\"`), + `simulated process exit during ${phase} write`, + () => 1, + ); + const first = deps(); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + `simulated process exit during ${phase} write`, + ); + vi.restoreAllMocks(); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + + const retry = phase === "active" ? first : deps({ existingSandbox: true }); + const resumed = await runHermesPortableOnboardingTransaction(input(), retry.value); + + expect(resumed.active.receipt.phase).toBe("active"); + expect(retry.events.filter((event) => event === "create")).toHaveLength( + phase === "active" ? 1 : 0, + ); + }, + ); + + it.each(["configuring", "active"] as const)( + "resumes %s after a canonical hard-link process exit (#9203)", + async (phase) => { + interruptCanonicalReceiptLink(phase); + const first = deps(); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow( + `simulated ${phase} canonical-link exit`, + ); + vi.restoreAllMocks(); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + + const retry = phase === "active" ? first : deps({ existingSandbox: true }); + const resumed = await runHermesPortableOnboardingTransaction(input(), retry.value); + + expect(resumed.active.receipt.phase).toBe("active"); + expect(retry.events.filter((event) => event === "create")).toHaveLength( + phase === "active" ? 1 : 0, + ); + }, + ); + + it("preserves configuring after an ambiguous update and completes registry on retry (#9203)", async () => { + const first = deps({ updateFails: true }); + await expect(runHermesPortableOnboardingTransaction(input(), first.value)).rejects.toThrow(); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + const second = deps({ existingSandbox: true }); + + const resumed = await runHermesPortableOnboardingTransaction(input(), second.value); + + expect(resumed.active.receipt.phase).toBe("active"); + expect(second.events).toContain("registry"); + }); + + it("resumes an exact durable-policy publication that crashed before pending (#9203)", async () => { + const transactionId = createHermesPortableTransactionId(); + const currentInput = input(); + await withMcpLifecycleLock( + "alpha", + async () => { + expect(() => + publishHermesPortableDurablePolicySource({ + sandboxName: "alpha", + transactionId, + stateDir, + intendedSemanticSha256: hermesPortableCreatePolicySemanticDigest(Buffer.from(POLICY)), + source: captureHermesPortablePolicySource(policyPath), + hooks: { + afterCanonicalLink: () => { + throw new Error("simulated pre-pending exit"); + }, + }, + }), + ).toThrow("simulated pre-pending exit"); + }, + { stateDir: path.join(stateDir, "state") }, + ); + const fixture = deps(); + + const resumed = await runHermesPortableOnboardingTransaction(currentInput, fixture.value); + + expect(resumed.active.receipt.transactionId).toBe(transactionId); + expect(resumed.active.receipt.phase).toBe("active"); + }); + + it("rejects active authority when the live restart policy drifts (#9203)", async () => { + const fixture = deps(); + await runHermesPortableOnboardingTransaction(input(), fixture.value); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + fixture.podman.mockImplementation((args: readonly string[]) => + args[0] === "container" && args[1] === "inspect" ? inspect("no") : unexpectedPodmanArgs(args), + ); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "committed restart policy", + ); + }); + + it("resumes configuring after registry commit but before active publication (#9203)", async () => { + const fixture = deps({ failAfterRegistry: true }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "registry-to-active exit", + ); + fs.writeFileSync(policyPath, POLICY, { mode: 0o600 }); + const resumed = await runHermesPortableOnboardingTransaction(input(), fixture.value); + + expect(resumed.active.receipt.phase).toBe("active"); + expect(fixture.events.filter((event) => event === "create")).toHaveLength(1); + expect(fixture.events.filter((event) => event === "registry")).toHaveLength(1); + }); + + it("keeps a contender outside the lock through registry and active publication (#9203)", async () => { + let releaseContender!: () => void; + const startContender = new Promise((resolve) => { + releaseContender = resolve; + }); + let contenderEntered = false; + const contender = startContender.then(async () => { + await withMcpLifecycleLock( + "alpha", + async () => { + contenderEntered = true; + }, + { stateDir: path.join(stateDir, "state") }, + ); + }); + const fixture = deps({ + afterRegistryCommit: async () => { + releaseContender(); + await new Promise((resolve) => setImmediate(resolve)); + expect(contenderEntered).toBe(false); + }, + }); + + await runHermesPortableOnboardingTransaction(input(), fixture.value); + await contender; + + expect(contenderEntered).toBe(true); + }); + + it("preserves pending custody when temporary policy cleanup cannot complete (#9203)", async () => { + const fixture = deps({ cleanupFails: true }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "temporary policy cleanup did not complete", + ); + expect(fs.existsSync(policyPath)).toBe(true); + expect(fixture.events).not.toContain("create"); + }); + + it("revalidates the current-user Podman socket immediately before create (#9203)", async () => { + const assertSocketAuthority = vi.fn(() => { + throw new Error("socket generation changed"); + }); + const fixture = deps({ assertSocketAuthority }); + + await expect(runHermesPortableOnboardingTransaction(input(), fixture.value)).rejects.toThrow( + "socket generation changed", + ); + + expect(assertSocketAuthority).toHaveBeenCalledOnce(); + expect(fixture.events).not.toContain("create"); + }); + + it("rejects ambiguous create effects and conflicting registry authority (#9203)", async () => { + const ambiguous = deps({ + observeSandbox: () => ({ kind: "ambiguous", detail: "gateway unavailable" }), + }); + await expect(runHermesPortableOnboardingTransaction(input(), ambiguous.value)).rejects.toThrow( + "gateway unavailable", + ); + expect(ambiguous.events).not.toContain("create"); + }); + + it.each([ + [ + "duplicate pair", + (sourcePath: string) => ["--policy", sourcePath, "--policy", sourcePath], + /exactly one canonical policy option/, + ], + [ + "equals form", + (sourcePath: string) => [`--policy=${sourcePath}`], + /one canonical '--policy ' option/, + ], + ["wrong path", () => ["--policy", "/tmp/other.yaml"], /does not name the captured source/], + ])("rejects %s before policy argv rewriting (#9203)", (_label, buildArgv, error) => { + const argv = buildArgv(policyPath); + expect(() => rewriteHermesPortableCreatePolicyArgv(argv, policyPath, "/durable.yaml")).toThrow( + error, + ); + }); + + it("rejects a noncanonical policy option before durable policy or create effects (#9203)", async () => { + const fixture = deps(); + const invalid = input(); + invalid.createArgv = [ + "openshell", + "sandbox", + "create", + "--policy", + policyPath, + `--policy=${policyPath}`, + "alpha", + ]; + + await expect(runHermesPortableOnboardingTransaction(invalid, fixture.value)).rejects.toThrow( + "one canonical '--policy ' option", + ); + expect(fs.existsSync(hermesPortableReceiptDirectory("alpha", stateDir))).toBe(false); + expect(fixture.events).not.toContain("create"); + }); + + it("classifies absence only after a reachable gateway returns exact sandbox-not-found evidence (#9203)", () => { + const capture = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "[]", stderr: "" }) + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "sandbox 'alpha' not found" }); + + expect(observeHermesPortableSandbox("alpha", "nemoclaw", capture)).toEqual({ + kind: "absent", + }); + expect(capture.mock.calls).toEqual([ + [["sandbox", "list", "-g", "nemoclaw"]], + [["sandbox", "get", "-g", "nemoclaw", "alpha"]], + ]); + }); + + it("accepts Ready identity only from the exact receipt gateway (#9203)", () => { + const capture = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "alpha Ready", stderr: "" }) + .mockReturnValueOnce({ + status: 0, + stdout: "Name: alpha\nID: sandbox-id-1\nPhase: Ready\n", + stderr: "", + }); + + expect(observeHermesPortableSandbox("alpha", "nemoclaw", capture)).toMatchObject({ + kind: "present", + sandboxId: "sandbox-id-1", + }); + expect(capture).toHaveBeenLastCalledWith(["sandbox", "get", "-g", "nemoclaw", "alpha"]); + }); + + it("routes generic Ready identity and exec checks through the exact gateway (#9203)", () => { + const capture = vi.fn(() => ({ + status: 0, + stdout: Buffer.from("ready"), + stderr: Buffer.alloc(0), + })); + const run = createHermesPortableReadyRunner("alpha", "nemoclaw", capture); + + expect(run(["sandbox", "get", "alpha"]).status).toBe(0); + expect(run(["sandbox", "exec", "--name", "alpha", "--", "true"]).status).toBe(0); + expect(capture.mock.calls).toEqual([ + [["sandbox", "get", "-g", "nemoclaw", "alpha"]], + [["sandbox", "exec", "-g", "nemoclaw", "--name", "alpha", "--", "true"]], + ]); + expect(() => run(["sandbox", "get", "beta"])).toThrow("unsupported OpenShell command"); + expect(() => run(["sandbox", "exec", "--name", "beta", "--", "true"])).toThrow( + "unsupported OpenShell command", + ); + }); + + it("rejects exact-gateway identity that has not reached Ready (#9203)", () => { + const capture = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "alpha Creating", stderr: "" }) + .mockReturnValueOnce({ + status: 0, + stdout: "Name: alpha\nID: sandbox-id-1\nPhase: Creating\n", + stderr: "", + }); + + expect(observeHermesPortableSandbox("alpha", "nemoclaw", capture)).toEqual({ + kind: "ambiguous", + detail: "exact OpenShell sandbox is not Ready", + }); + }); + + it.each([ + ["gateway missing", { status: 1, stdout: "", stderr: "gateway not found" }, false], + ["transport failure", { status: null, stdout: "", stderr: "transport unavailable" }, true], + ["unnamed sandbox", { status: 1, stdout: "", stderr: "unknown sandbox" }, true], + ["ambiguous absence", { status: 1, stdout: "", stderr: "no sandbox connection" }, true], + ])( + "keeps %s fail-closed instead of treating it as sandbox absence (#9203)", + (_label, reply, reachable) => { + const capture = vi.fn((args: readonly string[]) => + args[1] === "list" ? (reachable ? { status: 0, stdout: "[]", stderr: "" } : reply) : reply, + ); + + expect(observeHermesPortableSandbox("alpha", "nemoclaw", capture)).toMatchObject({ + kind: "ambiguous", + }); + }, + ); + + it("requires exact Hermes registry agent, gateway, generation, and driver agreement (#9203)", () => { + const receipt = { + sandboxName: "alpha", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + openshellExecutableAuthority: openshellExecutableAuthority(), + } as never; + const matching = { + name: "alpha", + agent: "hermes", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + openshellDriver: "docker", + openshellVersion: "0.0.101", + }; + + expect(classifyHermesPortableRegistry(receipt, null)).toEqual({ kind: "missing" }); + expect(classifyHermesPortableRegistry(receipt, matching)).toMatchObject({ kind: "matching" }); + expect( + classifyHermesPortableRegistry(receipt, { ...matching, lifecycleGeneration: "other" }), + ).toMatchObject({ kind: "conflict" }); + expect( + classifyHermesPortableRegistry(receipt, { ...matching, agent: "openclaw" }), + ).toMatchObject({ kind: "conflict" }); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-onboarding.ts b/src/lib/onboard/experimental/hermes-portable-onboarding.ts new file mode 100644 index 00000000000..a0a8c1756c3 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-onboarding.ts @@ -0,0 +1,1339 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { isDeepStrictEqual, TextDecoder } from "node:util"; + +import type { AgentDefinition } from "../../agent/defs"; +import type { InferenceSelection } from "../../inference/selection"; +import { + fingerprintOpenShellSandboxLiveIdentity, + parseOpenShellSandboxId, +} from "../../adapters/openshell/sandbox-identity"; +import { + assertHermesPortableOpenShellExecutableAuthority, + buildOpenShellSubprocessEnv, + captureHermesPortableOpenShellExecutableAuthority, + type HermesPortableOpenShellExecutableAuthority, +} from "../../adapters/openshell/resolve-shared"; +import { + assertPodmanSocketAuthority, + capturePodmanSocketAuthority, + type PodmanSocketAuthority, +} from "../../adapters/podman"; +import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; +import type { SandboxEntry } from "../../state/registry/types"; +import type { PortableOnboardRuntimeContext } from "../session-bootstrap"; +import { + classifySandboxInferenceRouteReservation, + isCurrentSandboxInferenceRouteReservation, + normalizeSandboxInferenceRouteSelection, + type QualifiedSandboxInferenceRouteReservation, + type SandboxInferenceRouteReservationAuthority, +} from "../../state/registry/route-reservation"; +import { + assertNoExplicitOpenShellGatewayEndpoint, + assertNoOpenShellGatewayEndpointOverride, +} from "../../openshell-gateway-endpoint-guard"; +import { isPortableExperimentalProfile } from "./portable-profile"; +import { defaultPortableDemoStateDir } from "./portable-runtime-receipt-readiness"; +export { defaultPortableDemoStateDir as defaultHermesPortableStateDir }; +import { + assertCurrentHermesPortableContainer, + configureHermesPortableRestartPolicy, + enrollHermesPortableContainer, + probeHermesPortableAuthenticatedHealth, + type HermesPortableContainerDeps, + type HermesPortableContainerInspection, +} from "./hermes-portable-container"; +import { + captureHermesPortablePodmanExecutableAuthority, + createHermesPortablePodmanCommandAuthority, + type HermesPortablePodmanExecutableAuthority, +} from "./hermes-portable-podman-authority"; +import { + assertCurrentHermesPortableStartupContract, + resolveHermesPortableStartupContract, + type ResolveHermesPortableStartupContractInput, +} from "./hermes-portable-contract"; +import { + hermesPortableCreatePolicySemanticDigest, + proveHermesPortableLivePolicy, + type HermesPortablePolicyCapture, +} from "./hermes-portable-policy-authority"; +import { + assertHermesPortableDurablePolicyAuthority, + captureHermesPortablePolicySource, + createHermesPortableTransactionId, + inspectPortableAgentReceiptAuthorityForPublicationRecovery, + publishHermesPortableDurablePolicySource, + publishHermesPortableLifecycleReceipt, + readHermesPortableLifecycleReceipt, + reconcileHermesPortableCurrentPhasePublication, + recoverableHermesPortablePolicyTransactionId, + type HermesPortableConfiguredReceipt, + type HermesPortableLifecycleReceipt, + type HermesPortablePendingReceipt, + type HermesPortableReceiptSnapshot, +} from "./hermes-portable-receipt"; +import { + createHermesPortableBuildContextPlan, + type HermesPortableBuildContextSettings, + type HermesPortableBuildContextPlan, + type HermesPortableStagedBuildContext, +} from "./hermes-portable-build-context"; + +export type HermesPortableSandboxObservation = + | { readonly kind: "absent" } + | { + readonly kind: "present"; + readonly sandboxId: string; + readonly liveIdentityFingerprint: string; + } + | { readonly kind: "ambiguous"; readonly detail: string }; + +export type HermesPortableRegistryDisposition = + | { readonly kind: "missing" } + | { readonly kind: "matching"; readonly entry: SandboxEntry } + | { readonly kind: "conflict"; readonly detail: string }; + +export interface HermesPortableOnboardingInput { + readonly sandboxName: string; + readonly gatewayName: string; + readonly lifecycleGeneration: string; + readonly runtimeAuthority: CheckpointPortableRuntimeAuthority; + readonly openshellExecutableAuthority: HermesPortableOpenShellExecutableAuthority; + readonly stateDir: string; + readonly createArgv: readonly string[]; + readonly createPolicyPath: string; + readonly createPolicySourceBytes?: Buffer; + readonly buildContext: HermesPortableBuildContextPlan; + readonly startup: ResolveHermesPortableStartupContractInput; + readonly inferenceRouteReservation: HermesPortableInferenceRouteReservationAuthority; +} + +export interface HermesPortableInferenceRouteReservationAuthority { + readonly sessionId: string; + readonly selection: InferenceSelection; +} + +export interface HermesPortableOnboardingDeps { + readonly withLifecycleLock: (sandboxName: string, operation: () => Promise) => Promise; + readonly captureSocketAuthority?: typeof capturePodmanSocketAuthority; + readonly capturePodmanExecutableAuthority?: ( + socketAuthority: PodmanSocketAuthority, + runtimeAuthority: CheckpointPortableRuntimeAuthority, + ) => HermesPortablePodmanExecutableAuthority; + readonly container: + | HermesPortableContainerDeps + | (( + socketAuthority: PodmanSocketAuthority, + podmanAuthority: HermesPortablePodmanExecutableAuthority, + ) => HermesPortableContainerDeps); + readonly capturePolicy: HermesPortablePolicyCapture; + readonly assertOpenShellExecutableAuthority: ( + authority: HermesPortableOpenShellExecutableAuthority, + ) => void; + readonly observeSandbox: () => HermesPortableSandboxObservation; + readonly createSandbox: (createArgv: readonly string[], buildContextPath: string) => Promise; + readonly readRegistry: () => SandboxEntry | null; + readonly registerSandbox: ( + result: T | null, + receipt: HermesPortableConfiguredReceipt, + liveIdentityFingerprint: string, + revalidate: () => string, + routeReservation: QualifiedSandboxInferenceRouteReservation, + ) => SandboxEntry | Promise; + readonly afterRegistryCommit?: () => void | Promise; + readonly cleanupTemporaryPolicy?: () => boolean; +} + +export interface HermesPortableOnboardingResult { + readonly active: HermesPortableReceiptSnapshot; + readonly createResult: T | null; + readonly created: boolean; +} + +export interface HermesPortableOpenShellResult { + readonly status: number | null; + readonly stdout: string | Buffer; + readonly stderr: string | Buffer; + readonly error?: Error; +} + +/** Build the capability-minimal environment for one receipt-owned OpenShell child. */ +export function createHermesPortableChildEnvironment( + sourceEnv: NodeJS.ProcessEnv, + runtimeAuthority?: CheckpointPortableRuntimeAuthority, +): NodeJS.ProcessEnv { + assertNoOpenShellGatewayEndpointOverride(sourceEnv); + return buildOpenShellSubprocessEnv(sourceEnv, runtimeAuthority); +} + +/** Adapt the existing synchronous runner to bounded, byte-preserving OpenShell captures. */ +export function createHermesPortableOpenShellCapture( + openshellArgv: (args: string[]) => string[], + sourceEnv: NodeJS.ProcessEnv = process.env, + runtimeAuthority?: CheckpointPortableRuntimeAuthority, + executableAuthority?: HermesPortableOpenShellExecutableAuthority, + spawn: typeof spawnSync = spawnSync, +): (args: readonly string[]) => HermesPortableOpenShellResult & { + readonly stdout: Buffer; + readonly stderr: Buffer; +} { + return (args) => { + assertNoExplicitOpenShellGatewayEndpoint(args); + const [resolvedExecutable, ...argv] = openshellArgv([...args]); + const executable = executableAuthority + ? assertHermesPortableOpenShellExecutableAuthority( + executableAuthority, + createHermesPortableChildEnvironment(sourceEnv, runtimeAuthority), + sourceEnv, + ) + : resolvedExecutable; + if (!executable) fail("OpenShell capture has no executable authority"); + if (resolvedExecutable !== executable) { + fail("OpenShell capture resolution disagrees with executable authority"); + } + const result: SpawnSyncReturns = spawn(executable, argv, { + env: createHermesPortableChildEnvironment(sourceEnv, runtimeAuthority), + timeout: 5_000, + maxBuffer: 512 * 1024, + encoding: null, + }); + return { + status: result.status, + stdout: Buffer.isBuffer(result.stdout) + ? result.stdout + : Buffer.from(result.stdout ?? "", "utf8"), + stderr: Buffer.isBuffer(result.stderr) + ? result.stderr + : Buffer.from(result.stderr ?? "", "utf8"), + ...(result.error ? { error: result.error } : {}), + }; + }; +} + +/** Scope create-flow Ready and identity captures to the receipt-owned gateway. */ +export function createHermesPortableReadyCapture( + sandboxName: string, + gatewayName: string, + capture: ReturnType, +): (args: string[], options?: Record) => string { + const run = createHermesPortableReadyRunner(sandboxName, gatewayName, capture); + return (args) => { + const result = run(args); + if (result.error || result.status !== 0) return ""; + return strictOpenShellText(result.stdout); + }; +} + +/** Route every generic create-readiness command through exact schema-5 authority. */ +export function createHermesPortableReadyRunner( + sandboxName: string, + gatewayName: string, + capture: ReturnType, +): (args: string[], options?: Record) => HermesPortableOpenShellResult { + return (args) => { + const scoped = + args[0] === "sandbox" && args[1] === "list" && args.length === 2 + ? ["sandbox", "list", "-g", gatewayName] + : args[0] === "sandbox" && args[1] === "get" && args.length === 3 && args[2] === sandboxName + ? ["sandbox", "get", "-g", gatewayName, args[2]!] + : args.length === 6 && + args[0] === "sandbox" && + args[1] === "exec" && + args[2] === "--name" && + args[3] === sandboxName && + args[4] === "--" && + args[5] === "true" + ? ["sandbox", "exec", "-g", gatewayName, "--name", args[3]!, "--", "true"] + : null; + if (!scoped) fail("create readiness attempted an unsupported OpenShell command"); + return capture(scoped); + }; +} + +const UTF8 = new TextDecoder("utf-8", { fatal: true }); +const CREATE_INTENT_VALUE_OPTIONS = new Set([ + "-g", + "--cpu", + "--driver-config-json", + "--from", + "--gpu-device", + "--memory", + "--name", + "--policy", + "--provider", +]); +const CREATE_INTENT_DRIVER_KEYS = new Set([ + "docker", + "mode", + "mounts", + "options", + "podman", + "read_only", + "size_bytes", + "source", + "target", + "type", +]); +const CREATE_INTENT_CONTROL = /[\u0000-\u001f\u007f-\u009f]/u; + +function fail(message: string): never { + throw new Error(`Hermes portable onboarding ${message}`); +} + +export function isHermesPortableLifecycleMode( + agent: AgentDefinition | null, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return isPortableExperimentalProfile(env) && agent?.name === "hermes"; +} + +/** Keep unowned dashboard/TUI forwards out of schema-5 enrollment. */ +export function shouldManageHermesPortableDashboard( + ordinaryDecision: boolean, + agent: AgentDefinition | null, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ordinaryDecision && !isHermesPortableLifecycleMode(agent, env); +} + +export function createHermesPortableContainerDeps( + socketAuthority: PodmanSocketAuthority, + runtimeAuthority: CheckpointPortableRuntimeAuthority, + podmanAuthority: HermesPortablePodmanExecutableAuthority, + sourceEnv: NodeJS.ProcessEnv = process.env, +): HermesPortableContainerDeps { + const authority = createHermesPortablePodmanCommandAuthority( + podmanAuthority, + socketAuthority, + runtimeAuthority, + sourceEnv, + ); + return { + podman: (args, timeoutMs) => { + authority.assertCurrent(); + return authority.engine.capture(args, timeoutMs); + }, + assertSocketAuthority: () => authority.engine.assertAuthority(), + }; +} + +export function rewriteHermesPortableCreatePolicyArgv( + createArgv: readonly string[], + sourcePath: string, + durablePath: string, +): string[] { + const rewritten = [...createArgv]; + let matches = 0; + for (let index = 0; index < rewritten.length; index += 1) { + const argument = rewritten[index]!; + if (argument === "--policy") { + matches += 1; + if (rewritten[index + 1] !== sourcePath) { + fail("create argv policy option does not name the captured source"); + } + rewritten[index + 1] = durablePath; + index += 1; + } else if (argument.startsWith("--policy=")) { + fail("create argv must use one canonical '--policy ' option"); + } + } + if (matches !== 1) fail("create argv must contain exactly one canonical policy option"); + return rewritten; +} + +/** Bind one Hermes portable create to its receipt-owned OpenShell gateway. */ +export function scopeHermesPortableCreateGatewayArgv( + createArgv: readonly string[], + gatewayName: string, +): string[] { + if (createArgv[1] !== "sandbox" || createArgv[2] !== "create") { + fail("create argv does not use the expected OpenShell create command"); + } + const separator = createArgv.indexOf("--", 3); + const optionEnd = separator < 0 ? createArgv.length : separator; + if ( + createArgv.slice(3, optionEnd).some((value) => value === "-g" || value.startsWith("--gateway")) + ) { + fail("create argv already contains gateway selection authority"); + } + return [...createArgv.slice(0, 3), "-g", gatewayName, ...createArgv.slice(3)]; +} + +function canonicalCreateIntentValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalCreateIntentValue); + if (value === null || typeof value === "boolean" || typeof value === "number") { + if (typeof value === "number" && !Number.isSafeInteger(value)) { + fail("create driver config number must be a safe integer"); + } + return value; + } + if (typeof value === "string") { + if (!value || value.length > 4096 || CREATE_INTENT_CONTROL.test(value)) { + fail( + "create driver config string must contain 1 to 4096 characters and no control characters", + ); + } + return value; + } + if (!value || typeof value !== "object") { + fail("create driver config contains an unsupported value"); + } + const source = value as Record; + const result: Record = {}; + for (const key of Object.keys(source).sort()) { + if (!CREATE_INTENT_DRIVER_KEYS.has(key)) { + fail("create driver config contains an unsupported field"); + } + result[key] = canonicalCreateIntentValue(source[key]); + } + return result; +} + +function parseCreateIntentDriverConfig(value: string): unknown { + if (!value || value.length > 32 * 1024 || CREATE_INTENT_CONTROL.test(value)) { + fail("create driver config is invalid"); + } + try { + return canonicalCreateIntentValue(JSON.parse(value)); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Hermes portable onboarding ")) { + throw error; + } + fail("create driver config is not valid JSON"); + } +} + +function createHermesPortableCreateIntentSha256( + input: HermesPortableOnboardingInput, + startup: ReturnType, + podmanExecutableAuthority: HermesPortablePodmanExecutableAuthority, +): string { + const argv = [...input.createArgv]; + if (argv.length < 9 || argv.length > 256) fail("create argv has an invalid bounded shape"); + if (!argv[0] || argv[0].length > 4096 || CREATE_INTENT_CONTROL.test(argv[0])) { + fail("create argv has an invalid OpenShell executable identity"); + } + if (argv[1] !== "sandbox" || argv[2] !== "create") { + fail("create argv does not use the expected OpenShell create command"); + } + const separator = argv.indexOf("--", 3); + if (separator < 0 || !isDeepStrictEqual(argv.slice(separator + 1), startup.argv)) { + fail("create argv startup does not match the accepted Hermes startup contract"); + } + const canonicalArgs: unknown[] = []; + let foundFrom = false; + let foundGateway = false; + let foundName = false; + let foundPolicy = false; + let createSourceAuthority: HermesPortableBuildContextPlan["authority"] | null = null; + for (let index = 3; index < separator; index += 1) { + const option = argv[index]!; + if (option === "--gpu") { + canonicalArgs.push(option); + continue; + } + if (!CREATE_INTENT_VALUE_OPTIONS.has(option)) { + fail("create argv contains an unsupported effect-bearing option"); + } + const value = argv[index + 1]; + if (!value || value.startsWith("--") || value.length > 32 * 1024) { + fail("create argv contains an invalid option value"); + } + index += 1; + if (option === "--policy") { + if (foundPolicy || value !== input.createPolicyPath) { + fail("create argv policy option does not name the captured source"); + } + foundPolicy = true; + canonicalArgs.push(option, ""); + continue; + } + if (option === "--name") { + if (foundName || value !== input.sandboxName) { + fail("create argv sandbox identity changed"); + } + foundName = true; + } + if (option === "-g") { + if (foundGateway || value !== input.gatewayName) { + fail("create argv gateway identity changed"); + } + foundGateway = true; + } + if (option === "--from") { + if (foundFrom) fail("create argv contains duplicate image authority"); + foundFrom = true; + if (value !== input.buildContext.sourceDockerfilePath) { + fail("create source does not name the captured build context source"); + } + createSourceAuthority = input.buildContext.authority; + } + canonicalArgs.push( + option, + option === "--driver-config-json" ? parseCreateIntentDriverConfig(value) : value, + ); + } + if (!foundFrom || !foundName || !foundPolicy || !foundGateway) { + fail("create argv is missing required image, sandbox, gateway, or policy authority"); + } + return createHash("sha256") + .update( + JSON.stringify({ + schemaVersion: 1, + command: "openshell sandbox create", + executable: argv[0], + podmanExecutableAuthority, + args: canonicalArgs, + createSourceAuthority, + inferenceRouteReservation: { + sessionId: input.inferenceRouteReservation.sessionId, + selection: normalizeSandboxInferenceRouteSelection( + input.inferenceRouteReservation.selection, + ), + }, + startupDescriptorSha256: startup.startupDescriptorSha256, + }), + ) + .digest("hex"); +} + +function rewriteHermesPortableCreateSourceArgv( + argv: readonly string[], + expectedSource: string, + context: HermesPortableStagedBuildContext, +): readonly string[] { + const rewritten = [...argv]; + const separator = rewritten.indexOf("--", 3); + let found = false; + for (let index = 3; index < separator; index += 1) { + if (rewritten[index] !== "--from") continue; + if (found || rewritten[index + 1] !== expectedSource) { + fail("create source changed before staged-context dispatch"); + } + found = true; + rewritten[index + 1] = context.dockerfilePath; + index += 1; + } + if (!found) fail("create argv is missing the staged-context source"); + return rewritten; +} + +function escapedRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function strictOpenShellText(value: string | Buffer): string { + if (typeof value === "string") return value; + try { + return UTF8.decode(value); + } catch { + fail("OpenShell returned output that is not strict UTF-8"); + } +} + +/** Prove gateway reachability before interpreting one exact not-found response as absence. */ +export function observeHermesPortableSandbox( + sandboxName: string, + gatewayName: string, + capture: (args: readonly string[]) => HermesPortableOpenShellResult, +): HermesPortableSandboxObservation { + const list = capture(["sandbox", "list", "-g", gatewayName]); + if (list.status !== 0 || list.error) { + return { kind: "ambiguous", detail: "the selected OpenShell gateway is not proven reachable" }; + } + const current = capture(["sandbox", "get", "-g", gatewayName, sandboxName]); + if (current.status === 0 && !current.error) { + const output = strictOpenShellText(current.stdout); + const sandboxId = parseOpenShellSandboxId(output); + const liveIdentityFingerprint = fingerprintOpenShellSandboxLiveIdentity(output); + if (!/^Phase:\s*Ready\s*$/mu.test(output)) { + return { kind: "ambiguous", detail: "exact OpenShell sandbox is not Ready" }; + } + return sandboxId && liveIdentityFingerprint + ? { kind: "present", sandboxId, liveIdentityFingerprint } + : { kind: "ambiguous", detail: "sandbox get returned no exact durable sandbox ID" }; + } + if (current.error || current.status === null) { + return { kind: "ambiguous", detail: "sandbox get ended without a status-bearing response" }; + } + const output = + `${strictOpenShellText(current.stderr)}\n${strictOpenShellText(current.stdout)}`.trim(); + const named = new RegExp( + `^(?:Error:\\s*)?sandbox ['\"]?${escapedRegExp(sandboxName)}['\"]? not found\\.?$`, + "u", + ); + const coded = /^Error: code: 'NotFound', message: "sandbox not found"$/u; + return named.test(output) || coded.test(output) + ? { kind: "absent" } + : { kind: "ambiguous", detail: "sandbox get did not prove exact sandbox absence" }; +} + +export function classifyHermesPortableRegistry( + receipt: HermesPortableLifecycleReceipt, + entry: SandboxEntry | null, +): HermesPortableRegistryDisposition { + if (!entry) return { kind: "missing" }; + if (entry.pendingRouteReservation === true) { + return { + kind: "conflict", + detail: "the saved row is an inference route reservation, not registered sandbox authority", + }; + } + if ( + entry.name !== receipt.sandboxName || + entry.agent !== "hermes" || + entry.gatewayName !== receipt.gatewayName || + entry.lifecycleGeneration !== receipt.lifecycleGeneration || + entry.openshellDriver !== "docker" || + entry.openshellVersion !== receipt.openshellExecutableAuthority.version + ) { + return { kind: "conflict", detail: "the saved row has another agent, gateway, or generation" }; + } + return { kind: "matching", entry }; +} + +function commonReceipt( + input: HermesPortableOnboardingInput, + socketAuthority: PodmanSocketAuthority, + podmanExecutableAuthority: HermesPortablePodmanExecutableAuthority, + createIntentSha256: string, + startup: ReturnType, +) { + return { + schemaVersion: 5 as const, + agent: "hermes" as const, + createIntentSha256, + sandboxName: input.sandboxName, + gatewayName: input.gatewayName, + lifecycleGeneration: input.lifecycleGeneration, + runtimeAuthority: input.runtimeAuthority, + openshellExecutableAuthority: input.openshellExecutableAuthority, + podmanExecutableAuthority, + socketAuthority, + startup, + }; +} + +function assertCurrentTransaction( + receipt: HermesPortableLifecycleReceipt, + input: HermesPortableOnboardingInput, + socketAuthority: PodmanSocketAuthority, + podmanExecutableAuthority: HermesPortablePodmanExecutableAuthority, + createIntentSha256: string, + currentIntendedSemanticSha256: string, +): void { + if ( + receipt.sandboxName !== input.sandboxName || + receipt.gatewayName !== input.gatewayName || + receipt.lifecycleGeneration !== input.lifecycleGeneration || + !isDeepStrictEqual(receipt.runtimeAuthority, input.runtimeAuthority) || + !isDeepStrictEqual(receipt.openshellExecutableAuthority, input.openshellExecutableAuthority) || + !isDeepStrictEqual(receipt.podmanExecutableAuthority, podmanExecutableAuthority) || + !isDeepStrictEqual(receipt.socketAuthority, socketAuthority) || + receipt.createIntentSha256 !== createIntentSha256 + ) { + fail("saved transaction disagrees with current sandbox, generation, or runtime authority"); + } + assertCurrentHermesPortableStartupContract(receipt.startup, input.startup); + if (currentIntendedSemanticSha256 !== receipt.policy.intendedSemanticSha256) { + fail("saved transaction disagrees with the current create policy intent"); + } + assertHermesPortableDurablePolicyAuthority(receipt.policy); +} + +function proveLivePolicy( + receipt: HermesPortableLifecycleReceipt, + capture: HermesPortablePolicyCapture, +): string { + const durable = assertHermesPortableDurablePolicyAuthority(receipt.policy); + const proof = proveHermesPortableLivePolicy({ + gatewayName: receipt.gatewayName, + sandboxName: receipt.sandboxName, + createPolicyBytes: durable, + capture, + }); + if (proof.intendedSemanticSha256 !== receipt.policy.intendedSemanticSha256) { + fail("live policy proof disagrees with pending intent"); + } + return proof.verifiedLivePolicySemanticSha256; +} + +function assertRegistryMissingBeforeConfiguration( + receipt: HermesPortableLifecycleReceipt, + disposition: HermesPortableRegistryDisposition, +): void { + if (disposition.kind === "missing") return; + fail( + disposition.kind === "conflict" + ? `registry conflicts with ${receipt.phase} authority: ${disposition.detail}` + : `registry is already committed while receipt phase is '${receipt.phase}'`, + ); +} + +function requireMatchingRegistry( + receipt: HermesPortableLifecycleReceipt, + disposition: HermesPortableRegistryDisposition, + liveIdentityFingerprint: string, +): void { + if ( + disposition.kind === "matching" && + disposition.entry.lifecycleLiveIdentityFingerprint === liveIdentityFingerprint + ) { + return; + } + fail( + disposition.kind === "conflict" + ? `registry conflicts with ${receipt.phase} authority: ${disposition.detail}` + : disposition.kind === "missing" + ? `registry is missing for receipt phase '${receipt.phase}'` + : `registry live identity disagrees with receipt phase '${receipt.phase}'`, + ); +} + +function requireRegistryBeforeConfigurationMutation( + disposition: HermesPortableRegistryDisposition, + liveIdentityFingerprint: string, +): void { + if (disposition.kind === "missing") return; + if ( + disposition.kind === "matching" && + disposition.entry.lifecycleLiveIdentityFingerprint === liveIdentityFingerprint + ) { + return; + } + fail( + disposition.kind === "conflict" + ? `registry conflicts with configuring authority: ${disposition.detail}` + : "registry live identity disagrees with configuring authority", + ); +} + +function requireCurrentOpenShellIdentity( + receipt: HermesPortableConfiguredReceipt, + observation: HermesPortableSandboxObservation, +): Extract { + if (observation.kind !== "present" || observation.sandboxId !== receipt.container.sandboxId) { + fail("current OpenShell sandbox identity disagrees with the receipt container"); + } + return observation; +} + +function requireCurrentReceiptSnapshot( + expected: HermesPortableReceiptSnapshot & { readonly receipt: T }, + stateDir: string, + allowPublicationRecovery = false, +): HermesPortableReceiptSnapshot & { readonly receipt: T } { + const recoveryAuthority = allowPublicationRecovery + ? inspectPortableAgentReceiptAuthorityForPublicationRecovery( + expected.receipt.sandboxName, + stateDir, + ) + : null; + const current = allowPublicationRecovery + ? recoveryAuthority?.kind === "hermes" + ? recoveryAuthority.snapshot + : null + : readHermesPortableLifecycleReceipt(expected.receipt.sandboxName, stateDir); + if ( + !current || + current.path !== expected.path || + current.identity.dev !== expected.identity.dev || + current.identity.ino !== expected.identity.ino || + current.sha256 !== expected.sha256 || + !current.bytes.equals(expected.bytes) + ) { + fail("receipt authority changed during lifecycle verification"); + } + return current as HermesPortableReceiptSnapshot & { readonly receipt: T }; +} + +function requireConfiguredReceiptSnapshot( + snapshot: HermesPortableReceiptSnapshot, +): HermesPortableReceiptSnapshot & { readonly receipt: HermesPortableConfiguredReceipt } { + if (snapshot.receipt.phase === "pending") fail("configured receipt authority is required"); + return snapshot as HermesPortableReceiptSnapshot & { + readonly receipt: HermesPortableConfiguredReceipt; + }; +} + +function requireConfiguredContainerReady(container: HermesPortableContainerInspection): void { + if ( + !container.authority.running || + container.paused || + container.authority.restartPolicy !== "unless-stopped" + ) { + fail("exact container is not running with the committed restart policy"); + } +} + +function configuringReceipt( + pending: HermesPortableReceiptSnapshot, + livePolicyDigest: string, + container: HermesPortableContainerInspection, +): HermesPortableConfiguredReceipt { + if (pending.receipt.phase !== "pending") fail("configuring requires pending authority"); + return { + ...pending.receipt, + phase: "configuring", + previousPhaseSha256: pending.sha256, + verifiedLivePolicySemanticSha256: livePolicyDigest, + container: container.authority, + }; +} + +function activeReceipt( + configuring: HermesPortableReceiptSnapshot, + container: HermesPortableContainerInspection, +): HermesPortableConfiguredReceipt { + if (configuring.receipt.phase !== "configuring") fail("active requires configuring authority"); + return { + ...configuring.receipt, + phase: "active", + previousPhaseSha256: configuring.sha256, + container: container.authority, + }; +} + +/** + * Hold one sandbox lifecycle fence across reservation, create, registry commit, + * and active publication. Every retry resumes the highest immutable phase. + */ +export async function runHermesPortableOnboardingTransaction( + input: HermesPortableOnboardingInput, + deps: HermesPortableOnboardingDeps, +): Promise> { + return await deps.withLifecycleLock(input.sandboxName, async () => { + const assertOpenShellExecutableAuthority = (): void => + deps.assertOpenShellExecutableAuthority(input.openshellExecutableAuthority); + const observeSandbox = (): HermesPortableSandboxObservation => { + assertOpenShellExecutableAuthority(); + return deps.observeSandbox(); + }; + const capturePolicy: HermesPortablePolicyCapture = (args) => { + assertOpenShellExecutableAuthority(); + return deps.capturePolicy(args); + }; + const validatedCreateArgv = rewriteHermesPortableCreatePolicyArgv( + input.createArgv, + input.createPolicyPath, + input.createPolicyPath, + ); + input.buildContext.assertCurrentSource(); + const startup = resolveHermesPortableStartupContract(input.startup); + const temporaryPolicy = input.createPolicySourceBytes + ? { + bytes: Buffer.from(input.createPolicySourceBytes), + sha256: createHash("sha256").update(input.createPolicySourceBytes).digest("hex"), + } + : captureHermesPortablePolicySource(input.createPolicyPath); + const currentIntendedSemanticSha256 = hermesPortableCreatePolicySemanticDigest( + temporaryPolicy.bytes, + ); + const socketAuthority = (deps.captureSocketAuthority ?? capturePodmanSocketAuthority)( + input.runtimeAuthority.socketPath, + ); + const podmanExecutableAuthority = ( + deps.capturePodmanExecutableAuthority ?? captureHermesPortablePodmanExecutableAuthority + )(socketAuthority, input.runtimeAuthority); + const createIntentSha256 = createHermesPortableCreateIntentSha256( + input, + startup, + podmanExecutableAuthority, + ); + const containerDeps = + typeof deps.container === "function" + ? deps.container(socketAuthority, podmanExecutableAuthority) + : deps.container; + const recoverableTransactionId = recoverableHermesPortablePolicyTransactionId( + input.sandboxName, + input.stateDir, + ); + const authority = recoverableTransactionId + ? { kind: "none" as const } + : inspectPortableAgentReceiptAuthorityForPublicationRecovery( + input.sandboxName, + input.stateDir, + ); + if (authority.kind === "openclaw") fail("will not reinterpret OpenClaw lifecycle authority"); + let snapshot = authority.kind === "hermes" ? authority.snapshot : null; + const initialRegistryEntry = deps.readRegistry(); + const routeReservationAuthority: SandboxInferenceRouteReservationAuthority = { + sandboxName: input.sandboxName, + gatewayName: input.gatewayName, + sessionId: input.inferenceRouteReservation.sessionId, + selection: input.inferenceRouteReservation.selection, + }; + const initialRouteReservation = classifySandboxInferenceRouteReservation( + routeReservationAuthority, + initialRegistryEntry, + ); + const admittedRouteReservation = + initialRouteReservation.kind === "owned" ? initialRouteReservation.reservation : null; + let committedRegistryEntry = + snapshot && + snapshot.receipt.phase !== "pending" && + initialRegistryEntry && + classifyHermesPortableRegistry(snapshot.receipt, initialRegistryEntry).kind === "matching" + ? structuredClone(initialRegistryEntry) + : null; + const canClassifyCommittedRegistry = Boolean( + snapshot && + snapshot.receipt.phase !== "pending" && + initialRouteReservation.kind === "not-reservation", + ); + if ( + !committedRegistryEntry && + initialRouteReservation.kind !== "owned" && + !canClassifyCommittedRegistry + ) { + fail("inference route reservation is not owned by the current onboarding session"); + } + const registryDisposition = ( + receipt: HermesPortableLifecycleReceipt, + ): HermesPortableRegistryDisposition => { + const entry = deps.readRegistry(); + const reservation = classifySandboxInferenceRouteReservation( + routeReservationAuthority, + entry, + ); + if (reservation.kind === "conflict") return reservation; + if (reservation.kind === "owned") { + return admittedRouteReservation && + isCurrentSandboxInferenceRouteReservation(admittedRouteReservation, entry) + ? { kind: "missing" } + : { + kind: "conflict", + detail: "the inference route reservation changed after admission", + }; + } + if (reservation.kind === "missing") { + return admittedRouteReservation + ? { + kind: "conflict", + detail: "the inference route reservation disappeared after admission", + } + : { kind: "missing" }; + } + const committed = classifyHermesPortableRegistry(receipt, entry); + if ( + committed.kind === "matching" && + (!committedRegistryEntry || !isDeepStrictEqual(entry, committedRegistryEntry)) + ) { + return { + kind: "conflict", + detail: "sandbox registry authority replaced the route reservation before registration", + }; + } + return committed; + }; + if (!snapshot) { + const preexisting = observeSandbox(); + if (preexisting.kind === "present") { + fail("live sandbox authority already exists before reservation"); + } + if (preexisting.kind === "ambiguous") { + fail(`cannot prove sandbox absence before reservation: ${preexisting.detail}`); + } + } + let createArgv: readonly string[]; + if (snapshot) { + snapshot = reconcileHermesPortableCurrentPhasePublication(snapshot, input.stateDir); + assertCurrentTransaction( + snapshot.receipt, + input, + socketAuthority, + podmanExecutableAuthority, + createIntentSha256, + currentIntendedSemanticSha256, + ); + createArgv = rewriteHermesPortableCreatePolicyArgv( + validatedCreateArgv, + input.createPolicyPath, + snapshot.receipt.policy.sourcePath, + ); + } else { + const transactionId = recoverableTransactionId ?? createHermesPortableTransactionId(); + const policy = publishHermesPortableDurablePolicySource({ + sandboxName: input.sandboxName, + transactionId, + stateDir: input.stateDir, + intendedSemanticSha256: currentIntendedSemanticSha256, + source: temporaryPolicy, + }); + createArgv = rewriteHermesPortableCreatePolicyArgv( + validatedCreateArgv, + input.createPolicyPath, + policy.sourcePath, + ); + const pending: HermesPortablePendingReceipt = { + ...commonReceipt( + input, + socketAuthority, + podmanExecutableAuthority, + createIntentSha256, + startup, + ), + transactionId, + phase: "pending", + policy, + }; + snapshot = publishHermesPortableLifecycleReceipt(pending, input.stateDir); + } + if (deps.cleanupTemporaryPolicy && !deps.cleanupTemporaryPolicy()) { + fail("temporary policy cleanup did not complete after durable reservation"); + } + + let createResult: T | null = null; + let created = false; + if (snapshot.receipt.phase === "active") { + let activeSnapshot = requireConfiguredReceiptSnapshot(snapshot); + const liveIdentity = requireCurrentOpenShellIdentity( + activeSnapshot.receipt, + observeSandbox(), + ); + requireConfiguredContainerReady( + assertCurrentHermesPortableContainer(activeSnapshot.receipt, containerDeps), + ); + proveLivePolicy(activeSnapshot.receipt, capturePolicy); + requireMatchingRegistry( + activeSnapshot.receipt, + registryDisposition(activeSnapshot.receipt), + liveIdentity.liveIdentityFingerprint, + ); + probeHermesPortableAuthenticatedHealth(activeSnapshot.receipt, containerDeps); + activeSnapshot = requireCurrentReceiptSnapshot(activeSnapshot, input.stateDir); + const finalIdentity = requireCurrentOpenShellIdentity( + activeSnapshot.receipt, + observeSandbox(), + ); + requireConfiguredContainerReady( + assertCurrentHermesPortableContainer(activeSnapshot.receipt, containerDeps), + ); + proveLivePolicy(activeSnapshot.receipt, capturePolicy); + requireMatchingRegistry( + activeSnapshot.receipt, + registryDisposition(activeSnapshot.receipt), + finalIdentity.liveIdentityFingerprint, + ); + return { active: activeSnapshot, createResult, created }; + } + + if (snapshot.receipt.phase === "pending") { + assertRegistryMissingBeforeConfiguration( + snapshot.receipt, + registryDisposition(snapshot.receipt), + ); + const buildContext = input.buildContext.materialize({ + sandboxName: snapshot.receipt.sandboxName, + transactionId: snapshot.receipt.transactionId, + createIntentSha256: snapshot.receipt.createIntentSha256, + stateDir: input.stateDir, + }); + let observation = observeSandbox(); + if (observation.kind === "ambiguous") + fail(`cannot classify create effects: ${observation.detail}`); + if (observation.kind === "absent") { + snapshot = requireCurrentReceiptSnapshot(snapshot, input.stateDir, true); + const currentCreateIntentSha256 = createHermesPortableCreateIntentSha256( + input, + startup, + podmanExecutableAuthority, + ); + assertCurrentTransaction( + snapshot.receipt, + input, + socketAuthority, + podmanExecutableAuthority, + currentCreateIntentSha256, + currentIntendedSemanticSha256, + ); + (containerDeps.assertSocketAuthority ?? assertPodmanSocketAuthority)( + snapshot.receipt.socketAuthority, + containerDeps.socketAuthority, + ); + assertOpenShellExecutableAuthority(); + buildContext.assertCurrent(); + input.buildContext.assertCurrentSource(); + createResult = await deps.createSandbox( + rewriteHermesPortableCreateSourceArgv( + createArgv, + input.buildContext.sourceDockerfilePath, + buildContext, + ), + buildContext.buildContextPath, + ); + buildContext.assertCurrent(); + input.buildContext.assertCurrentSource(); + created = true; + observation = observeSandbox(); + } + if (observation.kind !== "present") { + fail( + observation.kind === "ambiguous" + ? `cannot classify create result: ${observation.detail}` + : "create returned without exact live sandbox authority", + ); + } + assertCurrentTransaction( + snapshot.receipt, + input, + socketAuthority, + podmanExecutableAuthority, + createIntentSha256, + currentIntendedSemanticSha256, + ); + const livePolicyDigest = proveLivePolicy(snapshot.receipt, capturePolicy); + const container = enrollHermesPortableContainer( + snapshot.receipt, + observation.sandboxId, + containerDeps, + ); + snapshot = publishHermesPortableLifecycleReceipt( + configuringReceipt(snapshot, livePolicyDigest, container), + input.stateDir, + ); + } + + if (snapshot.receipt.phase !== "configuring") fail("transaction has an unsupported phase"); + if ( + !input.buildContext.retire({ + sandboxName: snapshot.receipt.sandboxName, + transactionId: snapshot.receipt.transactionId, + createIntentSha256: snapshot.receipt.createIntentSha256, + stateDir: input.stateDir, + }) + ) { + fail("staged build context cleanup did not complete after configuration"); + } + let configuringSnapshot = requireConfiguredReceiptSnapshot(snapshot); + assertCurrentTransaction( + configuringSnapshot.receipt, + input, + socketAuthority, + podmanExecutableAuthority, + createIntentSha256, + currentIntendedSemanticSha256, + ); + let liveIdentity = requireCurrentOpenShellIdentity( + configuringSnapshot.receipt, + observeSandbox(), + ); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); + requireRegistryBeforeConfigurationMutation( + registryDisposition(configuringSnapshot.receipt), + liveIdentity.liveIdentityFingerprint, + ); + configureHermesPortableRestartPolicy(configuringSnapshot.receipt, containerDeps); + const beforeRegistry = registryDisposition(configuringSnapshot.receipt); + if (beforeRegistry.kind === "conflict") { + fail(`registry conflicts with configuring authority: ${beforeRegistry.detail}`); + } + if (beforeRegistry.kind === "missing") { + const revalidateRegistryBoundary = (): string => { + assertCurrentTransaction( + configuringSnapshot.receipt, + input, + socketAuthority, + podmanExecutableAuthority, + createIntentSha256, + currentIntendedSemanticSha256, + ); + const currentIdentity = requireCurrentOpenShellIdentity( + configuringSnapshot.receipt, + observeSandbox(), + ); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); + requireConfiguredContainerReady( + assertCurrentHermesPortableContainer(configuringSnapshot.receipt, containerDeps), + ); + assertRegistryMissingBeforeConfiguration( + configuringSnapshot.receipt, + registryDisposition(configuringSnapshot.receipt), + ); + return currentIdentity.liveIdentityFingerprint; + }; + if (!admittedRouteReservation) { + fail("inference route reservation is missing before sandbox registration"); + } + committedRegistryEntry = await deps.registerSandbox( + createResult, + configuringSnapshot.receipt, + liveIdentity.liveIdentityFingerprint, + revalidateRegistryBoundary, + admittedRouteReservation, + ); + await deps.afterRegistryCommit?.(); + } + assertCurrentTransaction( + configuringSnapshot.receipt, + input, + socketAuthority, + podmanExecutableAuthority, + createIntentSha256, + currentIntendedSemanticSha256, + ); + liveIdentity = requireCurrentOpenShellIdentity(configuringSnapshot.receipt, observeSandbox()); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); + const currentContainer = assertCurrentHermesPortableContainer( + configuringSnapshot.receipt, + containerDeps, + ); + requireConfiguredContainerReady(currentContainer); + requireMatchingRegistry( + configuringSnapshot.receipt, + registryDisposition(configuringSnapshot.receipt), + liveIdentity.liveIdentityFingerprint, + ); + probeHermesPortableAuthenticatedHealth(configuringSnapshot.receipt, containerDeps); + configuringSnapshot = requireCurrentReceiptSnapshot(configuringSnapshot, input.stateDir, true); + liveIdentity = requireCurrentOpenShellIdentity(configuringSnapshot.receipt, observeSandbox()); + proveLivePolicy(configuringSnapshot.receipt, capturePolicy); + requireConfiguredContainerReady( + assertCurrentHermesPortableContainer(configuringSnapshot.receipt, containerDeps), + ); + requireMatchingRegistry( + configuringSnapshot.receipt, + registryDisposition(configuringSnapshot.receipt), + liveIdentity.liveIdentityFingerprint, + ); + const active = publishHermesPortableLifecycleReceipt( + activeReceipt(configuringSnapshot, currentContainer), + input.stateDir, + ); + return { active, createResult, created }; + }); +} + +export interface HermesPortableOnboardingFromOnboardInput { + readonly sandboxName: string; + readonly gatewayName: string; + readonly lifecycleGeneration: string; + readonly portableRuntime: PortableOnboardRuntimeContext; + readonly createArgv: readonly string[]; + readonly createPolicyPath: string; + readonly startup: ResolveHermesPortableStartupContractInput; + readonly inferenceRouteReservation: HermesPortableInferenceRouteReservationAuthority; + readonly withLifecycleLock: HermesPortableOnboardingDeps["withLifecycleLock"]; + readonly childEnv: NodeJS.ProcessEnv; + readonly openshellArgv: (args: string[]) => string[]; + readonly createSandbox: ( + createArgv: readonly string[], + readyCapture: ReturnType, + readyRunner: ReturnType, + buildContextPath: string, + ) => Promise; + readonly readRegistry: () => SandboxEntry | null; + readonly registerSandbox: HermesPortableOnboardingDeps["registerSandbox"]; + readonly sourceRoot: string; + readonly buildContextSettings: HermesPortableBuildContextSettings; + readonly cleanupTemporaryPolicy?: () => boolean; + readonly createPolicySourceBytes?: Buffer; +} + +/** Assemble the existing onboarding transaction without changing its lifecycle fence. */ +export async function runHermesPortableOnboardingFromOnboard( + input: HermesPortableOnboardingFromOnboardInput, +): Promise> { + const { + sandboxName, + gatewayName, + lifecycleGeneration, + portableRuntime, + createArgv, + createPolicyPath, + startup, + inferenceRouteReservation, + withLifecycleLock, + childEnv, + openshellArgv, + createSandbox, + readRegistry, + registerSandbox, + sourceRoot, + buildContextSettings, + cleanupTemporaryPolicy, + createPolicySourceBytes, + } = input; + const runtimeAuthority = portableRuntime.authority; + const portableEnvironmentScope = portableRuntime.environmentScope; + if (!portableEnvironmentScope) { + throw new Error("Hermes portable onboarding is missing runtime environment authority."); + } + const podmanSourceEnv = + portableEnvironmentScope.createHermesPortablePodmanSourceEnvironment(runtimeAuthority); + const scopedCreateArgv = scopeHermesPortableCreateGatewayArgv(createArgv, gatewayName); + const buildContext = createHermesPortableBuildContextPlan(sourceRoot, buildContextSettings); + const executablePath = scopedCreateArgv[0]; + if (!executablePath) fail("create command has no OpenShell executable authority"); + const commandEnv = createHermesPortableChildEnvironment(childEnv, runtimeAuthority); + const openshellExecutableAuthority = captureHermesPortableOpenShellExecutableAuthority( + executablePath, + commandEnv, + childEnv, + ); + const assertOpenShellExecutableAuthority = (): void => { + assertHermesPortableOpenShellExecutableAuthority( + openshellExecutableAuthority, + commandEnv, + childEnv, + ); + }; + const captureOpenShell = createHermesPortableOpenShellCapture( + openshellArgv, + childEnv, + runtimeAuthority, + openshellExecutableAuthority, + ); + const readyRunner = createHermesPortableReadyRunner(sandboxName, gatewayName, captureOpenShell); + return runHermesPortableOnboardingTransaction( + { + sandboxName, + gatewayName, + lifecycleGeneration, + runtimeAuthority, + openshellExecutableAuthority, + stateDir: defaultPortableDemoStateDir(process.env), + createArgv: scopedCreateArgv, + createPolicyPath, + ...(createPolicySourceBytes ? { createPolicySourceBytes } : {}), + buildContext, + startup, + inferenceRouteReservation, + }, + { + withLifecycleLock, + capturePodmanExecutableAuthority: (socketAuthority) => + captureHermesPortablePodmanExecutableAuthority( + socketAuthority, + runtimeAuthority, + podmanSourceEnv, + ), + container: (socketAuthority, podmanAuthority) => + createHermesPortableContainerDeps( + socketAuthority, + runtimeAuthority, + podmanAuthority, + podmanSourceEnv, + ), + assertOpenShellExecutableAuthority: () => assertOpenShellExecutableAuthority(), + capturePolicy: captureOpenShell, + observeSandbox: () => + observeHermesPortableSandbox(sandboxName, gatewayName, captureOpenShell), + createSandbox: (argv, buildContextPath) => + createSandbox( + argv, + createHermesPortableReadyCapture(sandboxName, gatewayName, captureOpenShell), + readyRunner, + buildContextPath, + ), + readRegistry, + registerSandbox, + ...(cleanupTemporaryPolicy ? { cleanupTemporaryPolicy } : {}), + }, + ); +} diff --git a/src/lib/onboard/experimental/hermes-portable-podman-authority.test.ts b/src/lib/onboard/experimental/hermes-portable-podman-authority.test.ts new file mode 100644 index 00000000000..cae30bbba6f --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-podman-authority.test.ts @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { + PodmanExecutableAuthorityDeps, + PodmanExecutableStat, + PodmanSocketAuthority, +} from "../../adapters/podman"; +import type { ContainerEngineCommandCapture } from "../../adapters/container-engine"; +import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; +import { createPortableOnboardEnvironmentScope } from "../session-bootstrap"; +import { + captureHermesPortablePodmanExecutableAuthority, + createHermesPortablePodmanCommandAuthority, + type HermesPortablePodmanAuthorityDeps, +} from "./hermes-portable-podman-authority"; + +const PODMAN_PATH = "/usr/bin/podman"; +const PODMAN_BYTES = Buffer.from("hermes-portable-podman-5.7.0", "utf8"); + +function runtimeAuthority(): CheckpointPortableRuntimeAuthority { + const uid = process.getuid!(); + return { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid, + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: `/run/user/${String(uid)}`, + socketPath: `/run/user/${String(uid)}/podman/podman.sock`, + }; +} + +function socketAuthority(): PodmanSocketAuthority { + const runtime = runtimeAuthority(); + return { + device: "1", + inode: "2", + mode: String(0o140600), + ownerUid: String(runtime.uid), + socketPath: runtime.socketPath, + directoryChain: [], + }; +} + +function executableDeps(generation: { + executableInode: bigint; + parentInode: bigint; +}): PodmanExecutableAuthorityDeps { + const executable = (): PodmanExecutableStat => ({ + dev: 1n, + ino: generation.executableInode, + mode: 0o100755n, + uid: 0n, + size: BigInt(PODMAN_BYTES.byteLength), + mtimeNs: 10n, + ctimeNs: 11n, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }); + const directory = (filePath: string): PodmanExecutableStat => ({ + ...executable(), + ino: filePath === "/usr/bin" ? generation.parentInode : 100n, + mode: 0o40755n, + size: 0n, + isDirectory: () => true, + isFile: () => false, + }); + return { + uid: process.getuid!(), + lstat: (filePath) => (filePath === PODMAN_PATH ? executable() : directory(filePath)), + readFile: () => PODMAN_BYTES, + realpath: (filePath) => filePath, + }; +} + +function podmanInfo(overrides: Record = {}): string { + return JSON.stringify({ + host: { + arch: "amd64", + os: "linux", + cgroupVersion: "v2", + networkBackend: "netavark", + security: { rootless: true }, + idMappings: { + uidmap: [ + { container_id: 0, host_id: 1000, size: 1 }, + { container_id: 1, host_id: 100000, size: 65536 }, + ], + gidmap: [ + { container_id: 0, host_id: 1000, size: 1 }, + { container_id: 1, host_id: 100000, size: 65536 }, + ], + }, + ...overrides, + }, + }); +} + +function authorityDeps( + capture: ContainerEngineCommandCapture, + executableAuthorityDeps: PodmanExecutableAuthorityDeps, + resolveExecutablePath: (env: NodeJS.ProcessEnv) => string = () => PODMAN_PATH, +): HermesPortablePodmanAuthorityDeps { + return { + capture, + executableAuthorityDeps, + assertSocketAuthority: vi.fn(), + resolveExecutablePath, + platform: "linux", + architecture: "x64", + uid: process.getuid!(), + }; +} + +function successfulCapture( + options: { client?: string; server?: string; info?: string; failInfo?: boolean } = {}, +): ReturnType> { + return vi.fn((_executable, args, _timeout, _input, env) => { + expect(env).toEqual({ + HOME: "/home/test", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: `/run/user/${String(process.getuid!())}`, + }); + const operation = args.includes("version") + ? "version" + : args.includes("info") + ? "info" + : "business"; + const responses = { + version: () => ({ + status: 0, + stdout: JSON.stringify({ + Client: { Version: options.client ?? "5.7.0" }, + Server: { Version: options.server ?? "5.7.0" }, + }), + stderr: "", + }), + info: () => + options.failInfo + ? { status: 125, stdout: "", stderr: "API unavailable" } + : { status: 0, stdout: options.info ?? podmanInfo(), stderr: "" }, + business: () => ({ status: 0, stdout: "business command", stderr: "" }), + } as const; + return responses[operation](); + }); +} + +describe("Hermes portable Podman executable and endpoint authority", () => { + it("omits exact scope-owned selectors before the Podman child", () => { + const runtime = runtimeAuthority(); + const containersConf = "/home/test/.config/nemoclaw/portable/containers.conf"; + const env: NodeJS.ProcessEnv = { HOME: runtime.homeDir, PATH: "/usr/bin" }; + const scope = createPortableOnboardEnvironmentScope(env, null); + scope.installRuntime({ containersConf, socketPath: runtime.socketPath }); + const capture = successfulCapture(); + + expect(() => + captureHermesPortablePodmanExecutableAuthority( + socketAuthority(), + runtime, + scope.createHermesPortablePodmanSourceEnvironment(runtime), + authorityDeps(capture, executableDeps({ executableInode: 10n, parentInode: 20n })), + ), + ).not.toThrow(); + expect(env).toMatchObject({ + DOCKER_HOST: `unix://${runtime.socketPath}`, + CONTAINERS_CONF: containersConf, + }); + expect(capture).toHaveBeenCalled(); + }); + + it("captures and reuses only the exact 5.7.0 rootless amd64 netavark authority", () => { + const generation = { executableInode: 10n, parentInode: 20n }; + const capture = successfulCapture(); + const deps = authorityDeps(capture, executableDeps(generation)); + const authority = captureHermesPortablePodmanExecutableAuthority( + socketAuthority(), + runtimeAuthority(), + { PATH: "/usr/bin", HOME: "/home/test" }, + deps, + ); + const command = createHermesPortablePodmanCommandAuthority( + authority, + socketAuthority(), + runtimeAuthority(), + { PATH: "/usr/bin", HOME: "/home/test" }, + deps, + ); + + command.assertCurrent(); + expect(command.engine.capture(["container", "inspect", "a".repeat(64)]).status).toBe(0); + expect(authority).toMatchObject({ + version: "5.7.0", + executable: { executablePath: PODMAN_PATH, inode: "10" }, + }); + expect(capture).toHaveBeenCalledWith( + PODMAN_PATH, + ["--url", `unix://${runtimeAuthority().socketPath}`, "container", "inspect", "a".repeat(64)], + 15_000, + undefined, + expect.any(Object), + ); + }); + + it.each([ + ["client", { client: "5.6.2" }], + ["server", { server: "5.8.0" }], + ["API", { failInfo: true }], + ])("rejects an exact %s qualification failure", (_label, options) => { + const generation = { executableInode: 10n, parentInode: 20n }; + expect(() => + captureHermesPortablePodmanExecutableAuthority( + socketAuthority(), + runtimeAuthority(), + { PATH: "/usr/bin", HOME: "/home/test" }, + authorityDeps(successfulCapture(options), executableDeps(generation)), + ), + ).toThrow(); + }); + + it("rejects binary, parent, and PATH replacement before another child", () => { + const generation = { executableInode: 10n, parentInode: 20n }; + let resolved = PODMAN_PATH; + const capture = successfulCapture(); + const deps = authorityDeps(capture, executableDeps(generation), () => resolved); + const authority = captureHermesPortablePodmanExecutableAuthority( + socketAuthority(), + runtimeAuthority(), + { PATH: "/usr/bin", HOME: "/home/test" }, + deps, + ); + const command = createHermesPortablePodmanCommandAuthority( + authority, + socketAuthority(), + runtimeAuthority(), + { PATH: "/usr/bin", HOME: "/home/test" }, + deps, + ); + + generation.executableInode = 11n; + expect(() => command.assertCurrent()).toThrow("changed after it was qualified"); + generation.executableInode = 10n; + generation.parentInode = 21n; + expect(() => command.assertCurrent()).toThrow("changed after it was qualified"); + generation.parentInode = 20n; + resolved = "/opt/replacement/podman"; + expect(() => command.assertCurrent()).toThrow("PATH resolves another Podman executable"); + }); + + it("rejects selectors and socket/runtime disagreement before a Podman child", () => { + const capture = successfulCapture(); + const deps = authorityDeps(capture, executableDeps({ executableInode: 10n, parentInode: 20n })); + expect(() => + captureHermesPortablePodmanExecutableAuthority( + socketAuthority(), + runtimeAuthority(), + { PATH: "/usr/bin", HOME: "/home/test", DOCKER_HOST: "tcp://attacker.test" }, + deps, + ), + ).toThrow("connection selector is not allowed"); + + const wrongSocket = { ...socketAuthority(), socketPath: "/run/user/1/podman.sock" }; + expect(() => + captureHermesPortablePodmanExecutableAuthority( + wrongSocket, + runtimeAuthority(), + { PATH: "/usr/bin", HOME: "/home/test" }, + deps, + ), + ).toThrow("runtime or socket identity disagrees"); + expect(capture).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-podman-authority.ts b/src/lib/onboard/experimental/hermes-portable-podman-authority.ts new file mode 100644 index 00000000000..9e650f00900 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-podman-authority.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isDeepStrictEqual } from "node:util"; + +import { + assertPodmanExecutableAuthority, + capturePodmanExecutableAuthority, + createPodmanContainerEngine, + resolvePodmanExecutablePath, + type PodmanBoundContainerEngine, + type PodmanExecutableAuthority, + type PodmanExecutableAuthorityDeps, + type PodmanSocketAuthority, + type PodmanSocketAuthorityDeps, +} from "../../adapters/podman"; +import type { ContainerEngineCommandCapture } from "../../adapters/container-engine"; +import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; +import { parsePortableRuntimeAuthority } from "../../state/onboard/portable-runtime-authority"; +import { qualifyPodmanEndpointHost } from "../runtime-provider/podman-preflight"; +import { buildHermesPortablePodmanEnvironment } from "./hermes-portable-container"; + +export const HERMES_PORTABLE_PODMAN_VERSION = "5.7.0" as const; +const HERMES_PORTABLE_PODMAN_NETWORK_BACKEND = "netavark"; + +export interface HermesPortablePodmanExecutableAuthority { + readonly version: typeof HERMES_PORTABLE_PODMAN_VERSION; + readonly executable: PodmanExecutableAuthority; +} + +export interface HermesPortablePodmanAuthorityDeps { + readonly capture?: ContainerEngineCommandCapture; + readonly socketAuthorityDeps?: PodmanSocketAuthorityDeps; + readonly executableAuthorityDeps?: PodmanExecutableAuthorityDeps; + readonly assertSocketAuthority?: ( + expected: PodmanSocketAuthority, + deps?: PodmanSocketAuthorityDeps, + ) => void; + readonly resolveExecutablePath?: (env: NodeJS.ProcessEnv) => string; + readonly platform?: NodeJS.Platform; + readonly architecture?: NodeJS.Architecture; + readonly uid?: number; +} + +export interface HermesPortablePodmanCommandAuthority { + readonly engine: PodmanBoundContainerEngine; + readonly assertCurrent: () => void; +} + +function fail(message: string): never { + throw new Error(`Hermes portable Podman authority ${message}`); +} + +function currentUid(configured: number | undefined): number { + const uid = configured ?? (typeof process.getuid === "function" ? process.getuid() : Number.NaN); + if (!Number.isSafeInteger(uid) || uid < 0) fail("requires the current Unix user ID"); + return uid; +} + +function requireRuntimeAuthority( + runtimeAuthority: CheckpointPortableRuntimeAuthority, + socketAuthority: PodmanSocketAuthority, + deps: HermesPortablePodmanAuthorityDeps, +): void { + const parsed = parsePortableRuntimeAuthority(runtimeAuthority); + if ( + !parsed || + !isDeepStrictEqual(parsed, runtimeAuthority) || + parsed.uid !== currentUid(deps.uid) || + parsed.socketPath !== socketAuthority.socketPath + ) { + fail("runtime or socket identity disagrees with current-user receipt authority"); + } +} + +function requireExpectedAuthority(authority: HermesPortablePodmanExecutableAuthority): void { + if ( + authority.version !== HERMES_PORTABLE_PODMAN_VERSION || + !authority.executable || + typeof authority.executable !== "object" + ) { + fail(`requires exact Podman ${HERMES_PORTABLE_PODMAN_VERSION} authority`); + } +} + +function requireResolvedExecutable( + authority: HermesPortablePodmanExecutableAuthority, + sourceEnv: NodeJS.ProcessEnv, + deps: HermesPortablePodmanAuthorityDeps, +): void { + const resolved = (deps.resolveExecutablePath ?? resolvePodmanExecutablePath)(sourceEnv); + if (resolved !== authority.executable.executablePath) { + fail("PATH resolves another Podman executable"); + } +} + +function qualifyExactMatrix( + engine: PodmanBoundContainerEngine, + deps: HermesPortablePodmanAuthorityDeps, +): void { + const receipt = qualifyPodmanEndpointHost(engine, { + expectedVersion: HERMES_PORTABLE_PODMAN_VERSION, + expectedNetworkBackend: HERMES_PORTABLE_PODMAN_NETWORK_BACKEND, + platform: deps.platform ?? process.platform, + architecture: deps.architecture ?? process.arch, + }); + if ( + receipt.clientVersion !== HERMES_PORTABLE_PODMAN_VERSION || + receipt.serverVersion !== HERMES_PORTABLE_PODMAN_VERSION || + receipt.rootless !== true || + receipt.cgroupVersion !== "v2" || + receipt.os !== "linux" || + receipt.architecture !== "amd64" || + receipt.networkBackend !== HERMES_PORTABLE_PODMAN_NETWORK_BACKEND + ) { + fail("exact client, server, rootless, cgroup, platform, or network matrix disagrees"); + } +} + +export function createHermesPortablePodmanCommandAuthority( + authority: HermesPortablePodmanExecutableAuthority, + socketAuthority: PodmanSocketAuthority, + runtimeAuthority: CheckpointPortableRuntimeAuthority, + sourceEnv: NodeJS.ProcessEnv = process.env, + deps: HermesPortablePodmanAuthorityDeps = {}, +): HermesPortablePodmanCommandAuthority { + requireExpectedAuthority(authority); + requireRuntimeAuthority(runtimeAuthority, socketAuthority, deps); + const commandEnvironment = buildHermesPortablePodmanEnvironment(runtimeAuthority, sourceEnv); + requireResolvedExecutable(authority, sourceEnv, deps); + assertPodmanExecutableAuthority(authority.executable, deps.executableAuthorityDeps); + const engine = createPodmanContainerEngine({ + operation: "state-mutation", + socketAuthority, + executable: authority.executable.executablePath, + executableAuthority: authority.executable, + commandEnvironment, + ...(deps.capture ? { capture: deps.capture } : {}), + ...(deps.socketAuthorityDeps ? { authorityDeps: deps.socketAuthorityDeps } : {}), + ...(deps.executableAuthorityDeps + ? { executableAuthorityDeps: deps.executableAuthorityDeps } + : {}), + ...(deps.assertSocketAuthority ? { assertAuthority: deps.assertSocketAuthority } : {}), + }); + const assertCurrent = (): void => { + requireRuntimeAuthority(runtimeAuthority, socketAuthority, deps); + buildHermesPortablePodmanEnvironment(runtimeAuthority, sourceEnv); + requireResolvedExecutable(authority, sourceEnv, deps); + assertPodmanExecutableAuthority(authority.executable, deps.executableAuthorityDeps); + engine.assertAuthority(); + qualifyExactMatrix(engine, deps); + engine.assertAuthority(); + }; + return Object.freeze({ engine, assertCurrent }); +} + +export function captureHermesPortablePodmanExecutableAuthority( + socketAuthority: PodmanSocketAuthority, + runtimeAuthority: CheckpointPortableRuntimeAuthority, + sourceEnv: NodeJS.ProcessEnv = process.env, + deps: HermesPortablePodmanAuthorityDeps = {}, +): HermesPortablePodmanExecutableAuthority { + requireRuntimeAuthority(runtimeAuthority, socketAuthority, deps); + buildHermesPortablePodmanEnvironment(runtimeAuthority, sourceEnv); + const executablePath = (deps.resolveExecutablePath ?? resolvePodmanExecutablePath)(sourceEnv); + const authority = Object.freeze({ + version: HERMES_PORTABLE_PODMAN_VERSION, + executable: capturePodmanExecutableAuthority(executablePath, deps.executableAuthorityDeps), + }); + createHermesPortablePodmanCommandAuthority( + authority, + socketAuthority, + runtimeAuthority, + sourceEnv, + deps, + ).assertCurrent(); + return authority; +} diff --git a/src/lib/onboard/experimental/hermes-portable-policy-authority.test.ts b/src/lib/onboard/experimental/hermes-portable-policy-authority.test.ts new file mode 100644 index 00000000000..1c0ff1ab103 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-policy-authority.test.ts @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + hermesPortableCreatePolicySemanticDigest, + hermesPortablePolicyAuthorityInternals, + proveHermesPortableLivePolicy, + type HermesPortablePolicyCaptureResult, +} from "./hermes-portable-policy-authority"; + +const CREATE = Buffer.from(`version: 1 +network_policies: + inference: + name: inference + endpoints: + - host: inference.local + port: 443 +`); + +const FORMATTED = Buffer.from(`Policy: alpha +--- +network_policies: + inference: { endpoints: [{ port: 443, host: inference.local }], name: inference } +version: 1 +`); + +function result( + stdout: Buffer, + status = 0, + stderr = Buffer.alloc(0), +): HermesPortablePolicyCaptureResult { + return { status, stdout, stderr }; +} + +function deeplyNestedPolicyAuthority(): Record { + const root: Record = {}; + Array.from({ length: 70 }).reduce>((current) => { + const next: Record = {}; + current.next = next; + return next; + }, root); + return root; +} + +describe("Hermes portable policy authority", () => { + it("accepts formatting and mapping-order normalization from exact scoped base/full reads (#9203)", () => { + const capture = vi.fn(() => result(FORMATTED)); + + const proof = proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture, + }); + + expect(proof.verifiedLivePolicySemanticSha256).toBe(proof.intendedSemanticSha256); + expect(capture.mock.calls).toEqual([ + [["policy", "get", "-g", "nemoclaw", "--base", "alpha"]], + [["policy", "get", "-g", "nemoclaw", "--full", "alpha"]], + ]); + }); + + it("rejects a reserved provider entry in the exact create input (#9203)", () => { + const create = Buffer.from(`version: 1 +network_policies: + _provider_injected: { endpoints: [] } +`); + + expect(() => hermesPortableCreatePolicySemanticDigest(create)).toThrow( + "create input contains a reserved provider-composed entry", + ); + }); + + it("rejects arbitrary content under a registered-looking provider key in full policy (#9203)", () => { + const full = Buffer.from(`${CREATE.toString("utf8")} _provider_nvidia_inference: + name: _provider_nvidia_inference + endpoints: [] +`); + const capture = vi.fn((args: readonly string[]) => + result(args.includes("--base") ? CREATE : full), + ); + + expect(() => + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture, + }), + ).toThrow("unproven provider-composed or out-of-band delta"); + }); + + it("does not erase a __proto__ policy entry during semantic comparison (#9203)", () => { + const full = Buffer.from(`${CREATE.toString("utf8")} __proto__: + name: injected + endpoints: [] +`); + + expect(() => + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture: (args) => result(args.includes("--base") ? CREATE : full), + }), + ).toThrow("out-of-band delta"); + }); + + it("rejects cyclic and oversized semantic structures deterministically (#9203)", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + + expect(() => hermesPortablePolicyAuthorityInternals.semanticDigest(cyclic)).toThrow( + "cyclic semantic structure", + ); + expect(() => + hermesPortablePolicyAuthorityInternals.semanticDigest(deeplyNestedPolicyAuthority()), + ).toThrow("oversized semantic structure"); + }); + + it("rejects capture ambiguity even when the child status is zero (#9203)", () => { + const capture = vi.fn(() => ({ + ...result(FORMATTED), + error: new Error("transport ended ambiguously"), + })); + + expect(() => + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture, + }), + ).toThrow("scoped base policy failed with status 0"); + expect(capture).toHaveBeenCalledTimes(1); + }); + + it("rejects non-reserved semantic drift in base or full policy (#9203)", () => { + const drift = Buffer.from(CREATE.toString("utf8").replace("port: 443", "port: 8443")); + expect(() => + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture: (args) => result(args.includes("--base") ? drift : drift), + }), + ).toThrow("base policy disagrees"); + expect(() => + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture: (args) => result(args.includes("--base") ? CREATE : drift), + }), + ).toThrow("out-of-band delta"); + }); + + it.each(["create", "base", "full"] as const)( + "rejects malformed UTF-8 in the %s policy bytes (#9203)", + (target) => { + const malformed = Buffer.from([0xff]); + const createPolicyBytes = target === "create" ? malformed : CREATE; + const capture = (args: readonly string[]) => + result( + target === "base" && args.includes("--base") + ? malformed + : target === "full" && args.includes("--full") + ? malformed + : CREATE, + ); + + expect(() => + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes, + capture, + }), + ).toThrow("strict UTF-8"); + }, + ); + + it("rejects duplicate policy documents and failed scoped reads (#9203)", () => { + const duplicate = Buffer.from(`${CREATE.toString("utf8")}---\n${CREATE.toString("utf8")}`); + expect(() => hermesPortableCreatePolicySemanticDigest(duplicate)).toThrow( + "duplicate or ambiguous", + ); + expect(() => + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture: () => result(Buffer.alloc(0), 1, Buffer.from("gateway unavailable")), + }), + ).toThrow("scoped base policy failed with status 1"); + }); + + it("does not expose malformed policy content or capture stderr in errors (#9203)", () => { + const secret = "DO_NOT_LOG_POLICY_SECRET"; + let malformedError: Error | null = null; + try { + hermesPortableCreatePolicySemanticDigest( + Buffer.from(`version: 1\nnetwork_policies:\n secret: [${secret}\n`), + ); + } catch (error) { + malformedError = error as Error; + } + expect(malformedError?.message).toContain("create input is invalid"); + expect(malformedError?.message).not.toContain(secret); + + let captureError: Error | null = null; + try { + proveHermesPortableLivePolicy({ + gatewayName: "nemoclaw", + sandboxName: "alpha", + createPolicyBytes: CREATE, + capture: () => result(Buffer.alloc(0), 1, Buffer.from(secret)), + }); + } catch (error) { + captureError = error as Error; + } + expect(captureError?.message).toContain("scoped base policy failed with status 1"); + expect(captureError?.message).not.toContain(secret); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-policy-authority.ts b/src/lib/onboard/experimental/hermes-portable-policy-authority.ts new file mode 100644 index 00000000000..b6f9bb49689 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-policy-authority.ts @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { TextDecoder } from "node:util"; + +import YAML from "yaml"; + +import { parseOpenShellPolicy } from "../../policy/merge"; + +const UTF8 = new TextDecoder("utf-8", { fatal: true }); +const MAX_POLICY_BYTES = 256 * 1024; +const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; + +export interface HermesPortablePolicyCaptureResult { + readonly status: number | null; + readonly stdout: Buffer; + readonly stderr: Buffer; + readonly error?: Error; +} + +export interface HermesPortablePolicyCapture { + (args: readonly string[]): HermesPortablePolicyCaptureResult; +} + +export interface HermesPortableLivePolicyProof { + readonly intendedSemanticSha256: string; + readonly verifiedLivePolicySemanticSha256: string; +} + +function fail(message: string): never { + throw new Error(`Hermes portable policy authority ${message}`); +} + +function decode(bytes: Buffer, label: string): string { + if (bytes.length > MAX_POLICY_BYTES) fail(`${label} exceeds the byte limit`); + try { + return UTF8.decode(bytes); + } catch { + fail(`${label} is not strict UTF-8`); + } +} + +interface CanonicalState { + readonly active: WeakSet; + nodes: number; +} + +function canonical( + value: unknown, + state: CanonicalState = { active: new WeakSet(), nodes: 0 }, + depth = 0, +): unknown { + state.nodes += 1; + if (state.nodes > 16_384 || depth > 64) fail("contains an oversized semantic structure"); + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (!value || typeof value !== "object") fail("contains a non-JSON semantic value"); + if (state.active.has(value)) fail("contains a cyclic semantic structure"); + state.active.add(value); + try { + if (Array.isArray(value)) { + return value.map((entry) => canonical(entry, state, depth + 1)); + } + const result: Record = Object.create(null); + for (const key of Object.keys(value as Record).sort()) { + if (key.length === 0 || key.length > 1024) fail("contains an invalid mapping key"); + result[key] = canonical((value as Record)[key], state, depth + 1); + } + return result; + } finally { + state.active.delete(value); + } +} + +function parseOnePolicyDocument(raw: string, label: string): Record { + let parsed: ReturnType; + try { + parsed = parseOpenShellPolicy(raw); + } catch { + fail(`${label} is invalid`); + } + const separators = [...raw.matchAll(/(?:^|\r?\n)---[ \t]*(?:\r?\n|$)/gu)]; + if (separators.length > 1) fail(`${label} is duplicate or ambiguous`); + if (separators.length === 1 && separators[0]!.index! > 0) { + const prefix = raw.slice(0, separators[0]!.index).trim(); + if (prefix) { + const prefixDocuments = YAML.parseAllDocuments(prefix); + const prefixPolicy = prefixDocuments[0]?.toJSON(); + if ( + prefixDocuments.length !== 1 || + prefixDocuments[0]!.errors.length > 0 || + (prefixPolicy && + typeof prefixPolicy === "object" && + !Array.isArray(prefixPolicy) && + ("version" in prefixPolicy || "network_policies" in prefixPolicy)) + ) { + fail(`${label} is duplicate or ambiguous`); + } + } + } + const documents = YAML.parseAllDocuments(parsed.yamlBody); + if (documents.length !== 1 || documents[0]!.errors.length > 0) { + fail(`${label} is duplicate or ambiguous`); + } + return parsed.policy; +} + +function semanticDigest(policy: Record): string { + return createHash("sha256") + .update(JSON.stringify(canonical(policy))) + .digest("hex"); +} + +function rejectReservedCreateEntries(policy: Record): void { + const policies = policy.network_policies; + if (!policies || typeof policies !== "object" || Array.isArray(policies)) return; + if (Object.keys(policies).some((name) => name.startsWith("_provider_"))) { + fail("create input contains a reserved provider-composed entry"); + } +} + +/** Capture and bind the exact create-policy bytes before sandbox creation. */ +export function hermesPortableCreatePolicySemanticDigest(bytes: Buffer): string { + const policy = parseOnePolicyDocument(decode(bytes, "create input"), "create input"); + rejectReservedCreateEntries(policy); + return semanticDigest(policy); +} + +function capturePolicy( + capture: HermesPortablePolicyCapture, + args: readonly string[], + label: string, +): Record { + const result = capture(args); + decode(result.stderr, `${label} stderr`); + if (result.status !== 0 || result.error) { + fail(`${label} failed with status ${String(result.status)}`); + } + return parseOnePolicyDocument(decode(result.stdout, label), label); +} + +/** + * Prove the current 0.0.101 Hermes matrix's empty provider projection. + * Both reads are explicitly gateway and sandbox scoped. A non-empty full/base + * delta is unsupported until OpenShell exposes an authoritative projection. + */ +export function proveHermesPortableLivePolicy(input: { + readonly gatewayName: string; + readonly sandboxName: string; + readonly createPolicyBytes: Buffer; + readonly capture: HermesPortablePolicyCapture; +}): HermesPortableLivePolicyProof { + if (!NAME.test(input.gatewayName) || !NAME.test(input.sandboxName)) { + fail("gateway or sandbox identity is invalid"); + } + const intendedSemanticSha256 = hermesPortableCreatePolicySemanticDigest(input.createPolicyBytes); + const prefix = ["policy", "get", "-g", input.gatewayName] as const; + const base = capturePolicy( + input.capture, + [...prefix, "--base", input.sandboxName], + "scoped base policy", + ); + const full = capturePolicy( + input.capture, + [...prefix, "--full", input.sandboxName], + "scoped full policy", + ); + const baseDigest = semanticDigest(base); + const fullDigest = semanticDigest(full); + if (baseDigest !== intendedSemanticSha256) fail("scoped base policy disagrees with create input"); + if (fullDigest !== baseDigest) { + fail("scoped full policy contains an unproven provider-composed or out-of-band delta"); + } + return { + intendedSemanticSha256, + verifiedLivePolicySemanticSha256: fullDigest, + }; +} + +export const hermesPortablePolicyAuthorityInternals = { semanticDigest }; diff --git a/src/lib/onboard/experimental/hermes-portable-receipt.test.ts b/src/lib/onboard/experimental/hermes-portable-receipt.test.ts new file mode 100644 index 00000000000..0e290a6ae82 --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-receipt.test.ts @@ -0,0 +1,1155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; +import { withMcpLifecycleLockSync } from "../../state/mcp-lifecycle-lock-acquisition"; +import type { PodmanSocketAuthority } from "../../adapters/podman"; +import type { HermesPortableOpenShellExecutableAuthority } from "../../adapters/openshell/resolve-shared"; +import type { HermesPortablePodmanExecutableAuthority } from "./hermes-portable-podman-authority"; +import { + HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION, + captureHermesPortablePolicySource, + hermesPortablePolicySourcePath, + hermesPortableReceiptDirectory, + hermesPortableReceiptInternals, + hermesPortableReceiptRoot, + inspectPortableAgentReceiptAuthority, + inspectPortableAgentReceiptAuthorityForPublicationRecovery, + publishHermesPortableDurablePolicySource, + publishHermesPortableLifecycleReceipt, + readHermesPortableLifecycleReceipt, + type HermesPortableConfiguredReceipt, + type HermesPortablePendingReceipt, + type HermesPortablePolicyAuthority, + type HermesPortableStartupContract, +} from "./hermes-portable-receipt"; +import { portableDemoReceiptPath } from "./portable-runtime-receipt-readiness"; + +const SANDBOX = "alpha"; +const GATEWAY = "nemoclaw"; +const GENERATION = "generation-1"; +const CONTAINER_ID = "a".repeat(64); +const SANDBOX_ID = "sandbox-id-1"; +const IMAGE_ID = `sha256:${"b".repeat(64)}`; +const SHA = "c".repeat(64); + +let stateDir: string; +let homeDir: string; +let policyPath: string; + +function uid(): number { + return typeof process.getuid === "function" + ? process.getuid() + : (() => { + throw new Error("test requires current-user identity"); + })(); +} + +function directoryChain(directory: string): string[] { + const parent = path.dirname(directory); + return parent === directory ? [directory] : [directory, ...directoryChain(parent)]; +} + +function createExistingHermesReceiptDirectories(count: number): void { + Array.from({ length: count }, (_value, index) => { + fs.mkdirSync(hermesPortableReceiptDirectory(`existing-${index}`, stateDir), { mode: 0o700 }); + }); +} + +function requireConfiguringReceipt( + receipt: ReturnType["receipt"], +): HermesPortableConfiguredReceipt { + return receipt.phase === "configuring" + ? receipt + : (() => { + throw new Error("fixture requires configuring"); + })(); +} + +function failShortWrite(): never { + throw new Error("simulated short-write exit"); +} + +function createUnaccountedReceiptLinks(target: string, count: number): void { + Array.from({ length: count }, (_value, index) => { + fs.linkSync(target, path.join(stateDir, `unaccounted-${index}.json`)); + }); +} + +function installShortWrite(prefixLength: number): void { + const originalWrite = fs.writeSync; + const writeSpy = vi.spyOn(fs, "writeSync") as unknown as { + mockImplementationOnce( + implementation: ( + descriptor: number, + buffer: Uint8Array, + offset: number, + length: number, + position: number | null, + ) => number, + ): void; + }; + writeSpy.mockImplementationOnce((descriptor, buffer, offset, length, position) => + originalWrite(descriptor, buffer, offset, Math.min(prefixLength, length), position), + ); +} + +function failReceiptShortWrite(written: number, total: number): void { + written < total ? failShortWrite() : undefined; +} + +function failPolicyShortWrite(written: number, total: number): void { + written < total + ? (() => { + throw new Error("simulated policy short-write exit"); + })() + : undefined; +} + +function runtimeAuthority(): CheckpointPortableRuntimeAuthority { + const currentUid = uid(); + return { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid: currentUid, + homeDir, + configHome: path.join(homeDir, ".config"), + runtimeDir: `/run/user/${String(currentUid)}`, + socketPath: `/run/user/${String(currentUid)}/podman/podman.sock`, + }; +} + +function socketAuthority(): PodmanSocketAuthority { + const runtime = runtimeAuthority(); + const directories = directoryChain(path.dirname(runtime.socketPath)); + return { + device: "1", + inode: "2", + mode: String(0o140600), + ownerUid: String(uid()), + socketPath: runtime.socketPath, + directoryChain: directories.map((directory, index) => ({ + device: "1", + inode: String(index + 3), + mode: String(index === 0 ? 0o40700 : 0o40755), + ownerUid: String(index === 0 ? uid() : 0), + path: directory, + })), + }; +} + +function openshellExecutableAuthority(): HermesPortableOpenShellExecutableAuthority { + return { + version: "0.0.101", + executable: { + executablePath: "/usr/bin/openshell", + device: "1", + inode: "10", + mode: String(0o100755), + ownerUid: "0", + size: "1024", + modifiedTimeNanoseconds: "11", + changedTimeNanoseconds: "12", + sha256: "8".repeat(64), + directoryChain: ["/usr/bin", "/usr", "/"].map((directory, index) => ({ + device: "1", + inode: String(index + 20), + mode: String(0o40755), + ownerUid: "0", + path: directory, + })), + }, + }; +} + +function podmanExecutableAuthority(): HermesPortablePodmanExecutableAuthority { + return { + version: "5.7.0", + executable: { + executablePath: "/usr/bin/podman", + device: "1", + inode: "30", + mode: String(0o100755), + ownerUid: "0", + size: "2048", + modifiedTimeNanoseconds: "31", + changedTimeNanoseconds: "32", + sha256: "9".repeat(64), + directoryChain: ["/usr/bin", "/usr", "/"].map((directory, index) => ({ + device: "1", + inode: String(index + 40), + mode: String(0o40755), + ownerUid: "0", + path: directory, + })), + }, + }; +} + +function startup(): HermesPortableStartupContract { + return { + manifestSha256: SHA, + startupDescriptorSha256: "d".repeat(64), + argv: [ + "env", + "NEMOCLAW_SANDBOX_NAME=alpha", + "NEMOCLAW_HERMES_API_PORT=8642", + "/usr/local/bin/nemoclaw-start", + ], + gatewayCommand: "hermes gateway run", + interactiveCommand: "hermes", + health: { + url: "http://localhost:8642/health", + port: 8642, + method: "GET", + auth: "bearer_token", + credentialEnv: "API_SERVER_KEY", + successStatus: 200, + }, + devicePairing: false, + configDir: "/sandbox/.hermes", + stateIdentitySha256: "e".repeat(64), + }; +} + +function policy(transactionId: string): HermesPortablePolicyAuthority { + return publishHermesPortableDurablePolicySource({ + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source: captureHermesPortablePolicySource(policyPath), + hooks: { assertLifecycleLock: () => {} }, + }); +} + +function pending( + overrides: Partial = {}, +): HermesPortablePendingReceipt { + const transactionId = overrides.transactionId ?? randomUUID(); + return { + schemaVersion: HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION, + agent: "hermes", + phase: "pending", + transactionId, + createIntentSha256: "c".repeat(64), + sandboxName: SANDBOX, + gatewayName: GATEWAY, + lifecycleGeneration: GENERATION, + runtimeAuthority: runtimeAuthority(), + openshellExecutableAuthority: openshellExecutableAuthority(), + podmanExecutableAuthority: podmanExecutableAuthority(), + socketAuthority: socketAuthority(), + startup: startup(), + policy: overrides.policy ?? policy(transactionId), + ...overrides, + }; +} + +function configuring( + parent: ReturnType, + overrides: Partial = {}, +): HermesPortableConfiguredReceipt { + const base = parent.receipt; + return { + ...base, + phase: "configuring", + previousPhaseSha256: parent.sha256, + verifiedLivePolicySemanticSha256: base.policy.intendedSemanticSha256, + container: { + containerId: CONTAINER_ID, + sandboxId: SANDBOX_ID, + imageId: IMAGE_ID, + labelsSha256: "9".repeat(64), + name: `openshell-default--${SANDBOX}-${SANDBOX_ID}`, + running: true, + restartPolicy: "no", + }, + ...overrides, + }; +} + +function active( + parent: ReturnType, + overrides: Partial = {}, +): HermesPortableConfiguredReceipt { + const receipt = requireConfiguringReceipt(parent.receipt); + return { + ...receipt, + phase: "active", + previousPhaseSha256: parent.sha256, + container: { ...receipt.container, restartPolicy: "unless-stopped" }, + ...overrides, + }; +} + +function writeLegacyReceipt(bytes: Buffer): string { + const target = portableDemoReceiptPath(SANDBOX, stateDir); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + fs.writeFileSync(target, bytes, { mode: 0o600 }); + return target; +} + +function publish( + receipt: Parameters[0], + hooks: Parameters[2] = {}, +) { + return publishHermesPortableLifecycleReceipt(receipt, stateDir, { + assertLifecycleLock: () => {}, + ...hooks, + }); +} + +function leaveInterruptedReceiptPrefix(receipt: HermesPortablePendingReceipt): string { + const originalWrite = fs.writeSync; + const writeSpy = vi.spyOn(fs, "writeSync") as unknown as { + mockImplementationOnce( + implementation: ( + descriptor: number, + buffer: Uint8Array, + offset: number, + length: number, + position: number | null, + ) => number, + ): void; + }; + writeSpy.mockImplementationOnce((descriptor, buffer, offset, length, position) => + originalWrite(descriptor, buffer, offset, Math.max(1, Math.floor(length / 2)), position), + ); + expect(() => + publish(receipt, { + afterStageWrite: (written, total) => { + return written < total ? failShortWrite() : undefined; + }, + }), + ).toThrow("simulated short-write exit"); + vi.restoreAllMocks(); + return hermesPortableReceiptInternals.stagePath( + hermesPortableReceiptDirectory(SANDBOX, stateDir), + receipt, + ); +} + +beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-receipt-")); + homeDir = path.join(stateDir, "home"); + policyPath = path.join(stateDir, "policy.yaml"); + fs.mkdirSync(path.join(homeDir, ".config"), { recursive: true, mode: 0o700 }); + fs.writeFileSync(policyPath, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("Hermes portable receipt authority", () => { + it("requires the shared sandbox lifecycle lock before any receipt publication (#9203)", () => { + expect(() => publishHermesPortableLifecycleReceipt(pending(), stateDir)).toThrow( + "requires the sandbox lifecycle lock", + ); + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + }); + + it("publishes one strict pending receipt without changing legacy receipt behavior (#9203)", () => { + const receipt = pending(); + const published = publish(receipt); + + expect(published.receipt).toEqual(receipt); + expect(published.bytes.toString("utf8")).toBe(`${JSON.stringify(published.receipt)}\n`); + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toEqual(published); + expect(inspectPortableAgentReceiptAuthority(SANDBOX, stateDir)).toEqual({ + kind: "hermes", + snapshot: published, + }); + }); + + it("rejects a schema-5 receipt without create intent before writing a stage (#9203)", () => { + const receipt = pending(); + const missingIntent = { ...receipt } as Record; + delete missingIntent.createIntentSha256; + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + + expect(() => publish(missingIntent as never)).toThrow("invalid identity fields"); + expect(fs.existsSync(path.join(directory, "pending.json"))).toBe(false); + expect(fs.readdirSync(directory).sort()).toEqual([`policy.${receipt.transactionId}.yaml`]); + }); + + it("rejects a schema-5 receipt outside the exact Podman 5.7.0 authority (#9203)", () => { + const receipt = pending(); + const wrongVersion = { + ...receipt, + podmanExecutableAuthority: { + ...receipt.podmanExecutableAuthority, + version: "5.8.0", + }, + }; + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + + expect(() => publish(wrongVersion as never)).toThrow("invalid Podman executable authority"); + expect(fs.existsSync(path.join(directory, "pending.json"))).toBe(false); + }); + + it("keeps the receipt root usable beyond eight independent Hermes sandboxes (#9203)", () => { + const root = hermesPortableReceiptRoot(stateDir); + fs.mkdirSync(root, { mode: 0o700 }); + createExistingHermesReceiptDirectories(9); + + const published = publish(pending()); + + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toEqual(published); + expect(fs.statSync(root).nlink).toBeGreaterThan(10); + }); + + it("keeps a schema-4 OpenClaw receipt byte-for-byte and does not reinterpret it (#9203)", () => { + const legacyBytes = Buffer.from( + `${JSON.stringify({ + schemaVersion: 4, + sandboxName: SANDBOX, + sandboxId: SANDBOX_ID, + containerId: CONTAINER_ID, + dashboardPort: 18789, + registryGeneration: GENERATION, + runtimeAuthority: runtimeAuthority(), + })}\n`, + ); + const target = writeLegacyReceipt(legacyBytes); + + expect(inspectPortableAgentReceiptAuthority(SANDBOX, stateDir)).toEqual({ + kind: "openclaw", + path: target, + }); + expect(fs.readFileSync(target)).toEqual(legacyBytes); + expect(() => pending()).toThrow("will not reserve policy over OpenClaw authority"); + expect(inspectPortableAgentReceiptAuthority(SANDBOX, stateDir)).toEqual({ + kind: "openclaw", + path: target, + }); + expect(fs.readFileSync(target)).toEqual(legacyBytes); + }); + + it("rejects a conflicting pending transaction without replacing its bytes (#9203)", () => { + const first = publish(pending()); + const conflicting = { ...first.receipt, lifecycleGeneration: "generation-2" }; + + expect(() => publish(conflicting)).toThrow("publication artifacts disagree"); + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)?.bytes).toEqual(first.bytes); + }); + + it("rejects malformed UTF-8 and preserves the exact malformed bytes (#9203)", () => { + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + fs.mkdirSync(path.dirname(directory), { recursive: true, mode: 0o700 }); + fs.mkdirSync(directory, { mode: 0o700 }); + const target = hermesPortableReceiptInternals.phasePath(directory, "pending"); + const malformed = Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0xff, 0x7d]); + fs.writeFileSync(target, malformed, { mode: 0o600 }); + + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow("strict UTF-8"); + expect(fs.readFileSync(target)).toEqual(malformed); + }); + + it("advances only through a digest-bound pending, configuring, and active chain (#9203)", () => { + const first = publish(pending()); + const second = publish(configuring(first)); + const third = publish(active(second)); + + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toEqual(third); + expect(third.receipt).toMatchObject({ + phase: "active", + container: { containerId: CONTAINER_ID, restartPolicy: "unless-stopped", running: true }, + }); + }); + + it("rejects a phase whose previous digest does not match the durable prior bytes (#9203)", () => { + const first = publish(pending()); + const next = configuring(first, { previousPhaseSha256: "0".repeat(64) }); + + expect(() => publish(next)).toThrow("does not match its prior phase"); + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)?.receipt.phase).toBe("pending"); + }); + + it("resumes the same phase after interruption at the hard-link publication boundary (#9203)", () => { + const receipt = pending(); + expect(() => + publish(receipt, { + afterCanonicalLink: () => { + throw new Error("simulated process exit"); + }, + }), + ).toThrow("simulated process exit"); + + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const target = hermesPortableReceiptInternals.phasePath(directory, "pending"); + const staged = hermesPortableReceiptInternals.stagePath(directory, receipt); + expect(fs.statSync(target).ino).toBe(fs.statSync(staged).ino); + expect(fs.statSync(target).nlink).toBe(2); + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + + const resumed = publish(receipt); + expect(resumed.receipt).toEqual(receipt); + expect(fs.statSync(target).nlink).toBe(1); + expect(fs.existsSync(staged)).toBe(false); + }); + + it.each([1, 2])( + "rejects %i unaccounted hard link(s) during publication recovery (#9203)", + (linkCount) => { + const receipt = pending(); + const published = publish(receipt); + createUnaccountedReceiptLinks(published.path, linkCount); + + expect(() => + withMcpLifecycleLockSync( + SANDBOX, + () => inspectPortableAgentReceiptAuthorityForPublicationRecovery(SANDBOX, stateDir), + { stateDir: path.join(stateDir, "state") }, + ), + ).toThrow("unaccounted or different generations"); + expect(fs.readFileSync(published.path)).toEqual(published.bytes); + }, + ); + + it("retires an exact empty phase stage left before the first write (#9203)", () => { + const receipt = pending(); + expect(() => + publish(receipt, { + afterStageCreate: () => { + throw new Error("simulated exit before phase write"); + }, + }), + ).toThrow("simulated exit before phase write"); + + const staged = hermesPortableReceiptInternals.stagePath( + hermesPortableReceiptDirectory(SANDBOX, stateDir), + receipt, + ); + expect(fs.statSync(staged).size).toBe(0); + + expect(publish(receipt).receipt).toEqual(receipt); + expect(fs.existsSync(staged)).toBe(false); + }); + + it.each([ + ["cleanup link", "afterCleanupLink"], + ["stage detach", "afterStageDetach"], + ] as const)( + "resumes the same phase after interruption at the %s boundary (#9203)", + (_label, hook) => { + const receipt = pending(); + expect(() => + publish(receipt, { + [hook]: () => { + throw new Error("simulated cleanup interruption"); + }, + }), + ).toThrow("simulated cleanup interruption"); + + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + expect(publish(receipt).receipt).toEqual(receipt); + expect(fs.readdirSync(directory).sort()).toEqual([ + "pending.json", + `policy.${receipt.transactionId}.yaml`, + ]); + }, + ); + + it.each([1, 8])( + "retires an exact %i-byte authorized receipt prefix and resumes publication (#9203)", + (prefixLength) => { + const receipt = pending(); + installShortWrite(prefixLength); + expect(() => + publish(receipt, { + afterStageWrite: failReceiptShortWrite, + }), + ).toThrow("simulated short-write exit"); + vi.restoreAllMocks(); + + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const staged = hermesPortableReceiptInternals.stagePath(directory, receipt); + const prior = fs.readFileSync(staged); + const priorIdentity = fs.statSync(staged).ino; + expect(prior.length).toBeGreaterThan(0); + expect(priorIdentity).toBeGreaterThan(0); + expect(publish(receipt).receipt).toEqual(receipt); + expect(fs.existsSync(staged)).toBe(false); + expect(fs.existsSync(path.join(directory, "pending.json"))).toBe(true); + }, + ); + + it("preserves a non-prefix interrupted stage without publishing (#9203)", () => { + const receipt = pending(); + const originalWrite = fs.writeSync; + const writeSpy = vi.spyOn(fs, "writeSync") as unknown as { + mockImplementationOnce( + implementation: ( + descriptor: number, + buffer: Uint8Array, + offset: number, + length: number, + position: number | null, + ) => number, + ): void; + }; + writeSpy.mockImplementationOnce((descriptor, buffer, offset, length, position) => + originalWrite(descriptor, buffer, offset, Math.max(1, Math.floor(length / 2)), position), + ); + expect(() => + publish(receipt, { + afterStageWrite: (written, total) => { + written < total + ? (() => { + throw new Error("simulated short-write exit"); + })() + : undefined; + }, + }), + ).toThrow("simulated short-write exit"); + vi.restoreAllMocks(); + + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const staged = hermesPortableReceiptInternals.stagePath(directory, receipt); + const changed = fs.readFileSync(staged); + changed[changed.length - 1] ^= 1; + fs.writeFileSync(staged, changed, { mode: 0o600 }); + const identity = fs.statSync(staged).ino; + + expect(() => publish(receipt)).toThrow("not the exact authorized receipt prefix"); + expect(fs.readFileSync(staged)).toEqual(changed); + expect(fs.statSync(staged).ino).toBe(identity); + expect(fs.existsSync(path.join(directory, "pending.json"))).toBe(false); + }); + + it("preserves an identity-rotated prefix stage at the retirement boundary (#9203)", () => { + const receipt = pending(); + const staged = leaveInterruptedReceiptPrefix(receipt); + const prior = fs.readFileSync(staged); + const displaced = `${staged}.displaced`; + + expect(() => + publish(receipt, { + beforeInterruptedStageRetirement: () => { + fs.renameSync(staged, displaced); + fs.writeFileSync(staged, prior, { mode: 0o600 }); + }, + }), + ).toThrow("changed before exact retirement"); + expect(fs.readFileSync(staged)).toEqual(prior); + expect(fs.readFileSync(displaced)).toEqual(prior); + expect(fs.existsSync(path.join(path.dirname(staged), "pending.json"))).toBe(false); + }); + + it("preserves a contender that publishes canonical evidence before prefix retirement (#9203)", () => { + const receipt = pending(); + const staged = leaveInterruptedReceiptPrefix(receipt); + const target = path.join(path.dirname(staged), "pending.json"); + const contender = Buffer.from("contender evidence\n"); + + expect(() => + publish(receipt, { + beforeInterruptedStageRetirement: () => { + fs.writeFileSync(target, contender, { flag: "wx", mode: 0o600 }); + }, + }), + ).toThrow("conflicts with other publication evidence"); + expect(fs.readFileSync(target)).toEqual(contender); + expect(fs.existsSync(staged)).toBe(true); + }); + + it("fails on prefix-retirement directory fsync and completes an identical retry (#9203)", () => { + const receipt = pending(); + const staged = leaveInterruptedReceiptPrefix(receipt); + vi.spyOn(fs, "fsyncSync").mockImplementationOnce(() => { + throw new Error("simulated prefix retirement fsync failure"); + }); + + expect(() => publish(receipt)).toThrow("simulated prefix retirement fsync failure"); + vi.restoreAllMocks(); + expect(fs.existsSync(staged)).toBe(false); + expect(publish(receipt).receipt).toEqual(receipt); + }); + + it("does not unlink a replacement injected at the final cleanup boundary (#9203)", () => { + const receipt = pending(); + const replacement = Buffer.from("replacement evidence\n"); + let replacedPath = ""; + + expect(() => + publish(receipt, { + beforeCleanupUnlink: () => { + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const staged = hermesPortableReceiptInternals.stagePath(directory, receipt); + replacedPath = `${staged}.cleanup`; + fs.unlinkSync(replacedPath); + fs.writeFileSync(replacedPath, replacement, { mode: 0o600 }); + }, + }), + ).toThrow("artifact changed before exact detach"); + + expect(fs.readFileSync(replacedPath)).toEqual(replacement); + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + }); + + it("reconciles exact same-generation cleanup evidence before canonical success (#9203)", () => { + const receipt = pending(); + const published = publish(receipt); + const staged = hermesPortableReceiptInternals.stagePath(path.dirname(published.path), receipt); + const cleanup = `${staged}.cleanup`; + fs.linkSync(published.path, cleanup); + + expect(publish(receipt)).toEqual(published); + expect(fs.existsSync(cleanup)).toBe(false); + expect(fs.statSync(published.path).nlink).toBe(1); + }); + + it("preserves mismatched cleanup evidence instead of accepting canonical authority (#9203)", () => { + const receipt = pending(); + const published = publish(receipt); + const staged = hermesPortableReceiptInternals.stagePath(path.dirname(published.path), receipt); + const cleanup = `${staged}.cleanup`; + const mismatch = Buffer.from("mismatched cleanup generation\n"); + fs.writeFileSync(cleanup, mismatch, { mode: 0o600 }); + + expect(() => publish(receipt)).toThrow("publication artifacts disagree"); + expect(fs.readFileSync(published.path)).toEqual(published.bytes); + expect(fs.readFileSync(cleanup)).toEqual(mismatch); + }); + + it("rejects an oversized receipt before creating a private stage (#9203)", () => { + const receipt = pending({ + startup: { + ...startup(), + argv: [ + "env", + ...Array.from( + { length: 20 }, + (_value, index) => `NEMOCLAW_TEST_${String(index)}=${"x".repeat(2000)}`, + ), + "/usr/local/bin/nemoclaw-start", + ], + }, + }); + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + + expect(() => publish(receipt)).toThrow("exceeds the bounded receipt size"); + expect(fs.existsSync(path.join(directory, "pending.json"))).toBe(false); + expect(fs.existsSync(hermesPortableReceiptInternals.stagePath(directory, receipt))).toBe(false); + }); + + it("preserves a fully written pre-link stage and resumes only the same transaction (#9203)", () => { + const receipt = pending(); + expect(() => + publish(receipt, { + afterStageFsync: () => { + throw new Error("simulated pre-link exit"); + }, + }), + ).toThrow("simulated pre-link exit"); + + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + expect(() => + publish({ ...receipt, transactionId: randomUUID(), lifecycleGeneration: "generation-2" }), + ).toThrow("directory contains other publication evidence"); + expect(publish(receipt).receipt).toEqual(receipt); + }); + + it("preserves a staged receipt when the retry presents different authority (#9203)", () => { + const receipt = pending(); + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const staged = hermesPortableReceiptInternals.stagePath(directory, receipt); + expect(() => + publish(receipt, { + afterStageFsync: () => { + throw new Error("simulated pre-link exit"); + }, + }), + ).toThrow("simulated pre-link exit"); + const prior = fs.readFileSync(staged); + const priorIdentity = fs.statSync(staged).ino; + + expect(() => publish({ ...receipt, lifecycleGeneration: "generation-2" })).toThrow( + "directory contains other publication evidence", + ); + expect(fs.readFileSync(staged)).toEqual(prior); + expect(fs.statSync(staged).ino).toBe(priorIdentity); + expect(fs.existsSync(path.join(directory, "pending.json"))).toBe(false); + }); + + it("requires a successful phase-stage fsync and reopen before publication (#9203)", () => { + const receipt = pending(); + vi.spyOn(fs, "fsyncSync").mockImplementationOnce(() => { + throw new Error("simulated phase stage fsync failure"); + }); + expect(() => publish(receipt)).toThrow("simulated phase stage fsync failure"); + vi.restoreAllMocks(); + + expect(() => + publish(receipt, { + beforeStageDurabilityReopen: () => { + throw new Error("simulated phase stage reopen failure"); + }, + }), + ).toThrow("simulated phase stage reopen failure"); + expect(() => readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + expect(publish(receipt).receipt).toEqual(receipt); + }); + + it("rejects an unsafe receipt directory without mutating its mode (#9203)", () => { + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + fs.mkdirSync(path.dirname(directory), { recursive: true, mode: 0o700 }); + fs.mkdirSync(directory, { mode: 0o755 }); + + expect(() => publish(pending())).toThrow("directory is unsafe"); + expect(fs.statSync(directory).mode & 0o777).toBe(0o755); + }); + + it("keeps the exact private policy source after temporary materialization disappears (#9203)", () => { + const transactionId = randomUUID(); + const authority = policy(transactionId); + const expected = fs.readFileSync(policyPath); + fs.unlinkSync(policyPath); + + const receipt = pending({ transactionId, policy: authority }); + const published = publish(receipt); + + expect(fs.readFileSync(authority.sourcePath)).toEqual(expected); + expect(fs.statSync(authority.sourcePath).mode & 0o777).toBe(0o600); + expect(readHermesPortableLifecycleReceipt(SANDBOX, stateDir)).toEqual(published); + }); + + it("rejects source replacement immediately before durable publication without creating authority (#9203)", () => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const replacement = Buffer.from("version: 1\nnetwork_policies:\n replacement: {}\n"); + + expect(() => + publishHermesPortableDurablePolicySource({ + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + hooks: { + assertLifecycleLock: () => {}, + afterStageFsync: () => fs.writeFileSync(policyPath, replacement, { mode: 0o600 }), + }, + }), + ).toThrow("policy source changed while in custody"); + + expect(fs.readFileSync(policyPath)).toEqual(replacement); + expect(fs.existsSync(hermesPortablePolicySourcePath(SANDBOX, transactionId, stateDir))).toBe( + false, + ); + expect(() => inspectPortableAgentReceiptAuthority(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + }); + + it("resumes durable policy publication after the canonical hard-link crash boundary (#9203)", () => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const input = { + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + } as const; + + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { + assertLifecycleLock: () => {}, + afterCanonicalLink: () => { + throw new Error("simulated policy publication exit"); + }, + }, + }), + ).toThrow("simulated policy publication exit"); + expect(() => inspectPortableAgentReceiptAuthority(SANDBOX, stateDir)).toThrow( + "incomplete or unknown publication evidence", + ); + + const authority = publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }); + expect(fs.readFileSync(authority.sourcePath)).toEqual(source.bytes); + expect(fs.statSync(authority.sourcePath).nlink).toBe(1); + }); + + it("preserves durable policy publication when an unaccounted hard link appears (#9203)", () => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const input = { + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + } as const; + + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { + assertLifecycleLock: () => {}, + afterCanonicalLink: () => { + throw new Error("simulated policy publication exit"); + }, + }, + }), + ).toThrow("simulated policy publication exit"); + const target = hermesPortablePolicySourcePath(SANDBOX, transactionId, stateDir); + const staged = hermesPortableReceiptInternals.policyStagePath( + path.dirname(target), + transactionId, + source.sha256, + input.intendedSemanticSha256, + ); + const external = path.join(stateDir, "unaccounted-policy-link.yaml"); + fs.linkSync(target, external); + const before = fs.readdirSync(path.dirname(target)).sort(); + + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }), + ).toThrow("publication artifacts have unaccounted links"); + expect(fs.readdirSync(path.dirname(target)).sort()).toEqual(before); + expect(fs.readFileSync(target)).toEqual(source.bytes); + expect(fs.statSync(target).ino).toBe(fs.statSync(staged).ino); + expect(fs.statSync(target).nlink).toBe(3); + }); + + it.each([1, 4, 16])( + "retires an exact %i-byte durable-policy prefix and resumes publication (#9203)", + (prefixLength) => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const input = { + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + } as const; + installShortWrite(prefixLength); + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { + assertLifecycleLock: () => {}, + afterStageWrite: failPolicyShortWrite, + }, + }), + ).toThrow("simulated policy short-write exit"); + vi.restoreAllMocks(); + + const authority = publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }); + expect(fs.readFileSync(authority.sourcePath)).toEqual(source.bytes); + }, + ); + + it("reconciles or preserves durable-policy cleanup evidence before canonical success (#9203)", () => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const input = { + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + } as const; + const authority = publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }); + const staged = hermesPortableReceiptInternals.policyStagePath( + path.dirname(authority.sourcePath), + transactionId, + source.sha256, + input.intendedSemanticSha256, + ); + const cleanup = `${staged}.cleanup`; + fs.linkSync(authority.sourcePath, cleanup); + expect( + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }).sourcePath, + ).toBe(authority.sourcePath); + expect(fs.existsSync(cleanup)).toBe(false); + + const mismatch = Buffer.from("mismatched policy cleanup\n"); + fs.writeFileSync(cleanup, mismatch, { mode: 0o600 }); + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }), + ).toThrow("publication artifacts disagree"); + expect(fs.readFileSync(authority.sourcePath)).toEqual(source.bytes); + expect(fs.readFileSync(cleanup)).toEqual(mismatch); + }); + + it("preserves a staged durable policy when retry bytes disagree (#9203)", () => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const directory = hermesPortableReceiptDirectory(SANDBOX, stateDir); + const staged = hermesPortableReceiptInternals.policyStagePath( + directory, + transactionId, + source.sha256, + "f".repeat(64), + ); + expect(() => + publishHermesPortableDurablePolicySource({ + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + hooks: { + assertLifecycleLock: () => {}, + afterStageFsync: () => { + throw new Error("simulated policy pre-link exit"); + }, + }, + }), + ).toThrow("simulated policy pre-link exit"); + const prior = fs.readFileSync(staged); + const priorIdentity = fs.statSync(staged).ino; + fs.writeFileSync(policyPath, "version: 1\nnetwork_policies:\n changed: {}\n", { + mode: 0o600, + }); + + expect(() => + publishHermesPortableDurablePolicySource({ + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source: captureHermesPortablePolicySource(policyPath), + hooks: { assertLifecycleLock: () => {} }, + }), + ).toThrow("directory contains other policy authority"); + expect(fs.readFileSync(staged)).toEqual(prior); + expect(fs.statSync(staged).ino).toBe(priorIdentity); + expect(fs.existsSync(hermesPortablePolicySourcePath(SANDBOX, transactionId, stateDir))).toBe( + false, + ); + }); + + it("retires an exact empty durable-policy stage left before the first write (#9203)", () => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const input = { + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + } as const; + + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { + assertLifecycleLock: () => {}, + afterStageCreate: () => { + throw new Error("simulated exit before policy write"); + }, + }, + }), + ).toThrow("simulated exit before policy write"); + + const authority = publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }); + expect(fs.readFileSync(authority.sourcePath)).toEqual(source.bytes); + expect(fs.statSync(authority.sourcePath).nlink).toBe(1); + }); + + it("requires a successful durable-policy stage fsync and reopen before publication (#9203)", () => { + const transactionId = randomUUID(); + const source = captureHermesPortablePolicySource(policyPath); + const input = { + sandboxName: SANDBOX, + transactionId, + stateDir, + intendedSemanticSha256: "f".repeat(64), + source, + } as const; + vi.spyOn(fs, "fsyncSync").mockImplementationOnce(() => { + throw new Error("simulated policy stage fsync failure"); + }); + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }), + ).toThrow("simulated policy stage fsync failure"); + vi.restoreAllMocks(); + + expect(() => + publishHermesPortableDurablePolicySource({ + ...input, + hooks: { + assertLifecycleLock: () => {}, + beforeStageDurabilityReopen: () => { + throw new Error("simulated policy stage reopen failure"); + }, + }, + }), + ).toThrow("simulated policy stage reopen failure"); + expect(fs.existsSync(hermesPortablePolicySourcePath(SANDBOX, transactionId, stateDir))).toBe( + false, + ); + const authority = publishHermesPortableDurablePolicySource({ + ...input, + hooks: { assertLifecycleLock: () => {} }, + }); + expect(fs.readFileSync(authority.sourcePath)).toEqual(source.bytes); + }); + + it("rejects malformed UTF-8 policy bytes without modifying the source (#9203)", () => { + const malformed = Buffer.from([0x76, 0x65, 0x72, 0xff]); + fs.writeFileSync(policyPath, malformed, { mode: 0o600 }); + + expect(() => captureHermesPortablePolicySource(policyPath)).toThrow( + "policy source is not strict UTF-8", + ); + expect(fs.readFileSync(policyPath)).toEqual(malformed); + }); +}); diff --git a/src/lib/onboard/experimental/hermes-portable-receipt.ts b/src/lib/onboard/experimental/hermes-portable-receipt.ts new file mode 100644 index 00000000000..a43abfb0ffb --- /dev/null +++ b/src/lib/onboard/experimental/hermes-portable-receipt.ts @@ -0,0 +1,1697 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual, TextDecoder } from "node:util"; + +import { isErrnoException } from "../../core/errno"; +import { + HERMES_PORTABLE_OPENSHELL_VERSION, + type HermesPortableOpenShellExecutableAuthority, +} from "../../adapters/openshell/resolve-shared"; +import type { PodmanSocketAuthority } from "../../adapters/podman"; +import { isMcpLifecycleLockHeld } from "../../state/mcp-lifecycle-lock-acquisition"; +import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; +import { parsePortableRuntimeAuthority } from "../../state/onboard/portable-runtime-authority"; +import { portableDemoReceiptPath } from "./portable-runtime-receipt-readiness"; +import { + HERMES_PORTABLE_PODMAN_VERSION, + type HermesPortablePodmanExecutableAuthority, +} from "./hermes-portable-podman-authority"; + +export const HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION = 5 as const; +export const HERMES_PORTABLE_RECEIPT_DIRECTORY = "hermes-portable-lifecycle"; + +const RECEIPT_MODE = 0o600; +const DIRECTORY_MODE = 0o700; +const MAX_RECEIPT_BYTES = 32 * 1024; +const MAX_POLICY_BYTES = 256 * 1024; +const MAX_DIRECTORY_ENTRIES = 8; +const UTF8 = new TextDecoder("utf-8", { fatal: true }); +const SHA256 = /^[a-f0-9]{64}$/u; +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u; +const GENERATION = /^[A-Za-z0-9._:-]{1,256}$/u; +const SANDBOX = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; +const CONTAINER_ID = /^[a-f0-9]{64}$/u; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/u; +const DECIMAL = /^(?:0|[1-9][0-9]{0,39})$/u; +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u; + +export type HermesPortableReceiptPhase = "pending" | "configuring" | "active"; + +export interface HermesPortableStartupContract { + readonly manifestSha256: string; + readonly startupDescriptorSha256: string; + readonly argv: readonly string[]; + readonly gatewayCommand: "hermes gateway run"; + readonly interactiveCommand: "hermes"; + readonly health: { + readonly url: "http://localhost:8642/health"; + readonly port: 8642; + readonly method: "GET"; + readonly auth: "bearer_token"; + readonly credentialEnv: "API_SERVER_KEY"; + readonly successStatus: 200; + }; + readonly devicePairing: false; + readonly configDir: "/sandbox/.hermes"; + readonly stateIdentitySha256: string; +} + +export interface HermesPortablePolicyAuthority { + readonly sourcePath: string; + readonly sourceSha256: string; + readonly intendedSemanticSha256: string; + readonly sourceIdentity: { + readonly dev: string; + readonly ino: string; + readonly size: string; + readonly mode: 384; + readonly uid: number; + readonly mtimeNs: string; + readonly ctimeNs: string; + }; +} + +export interface HermesPortableContainerAuthority { + readonly containerId: string; + readonly sandboxId: string; + readonly imageId: string; + readonly labelsSha256: string; + readonly name: string; + readonly running: boolean; + readonly restartPolicy: string; +} + +interface HermesPortableReceiptCommon { + readonly schemaVersion: typeof HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION; + readonly agent: "hermes"; + readonly transactionId: string; + readonly createIntentSha256: string; + readonly sandboxName: string; + readonly gatewayName: string; + readonly lifecycleGeneration: string; + readonly runtimeAuthority: CheckpointPortableRuntimeAuthority; + readonly openshellExecutableAuthority: HermesPortableOpenShellExecutableAuthority; + readonly podmanExecutableAuthority: HermesPortablePodmanExecutableAuthority; + readonly socketAuthority: PodmanSocketAuthority; + readonly startup: HermesPortableStartupContract; + readonly policy: HermesPortablePolicyAuthority; +} + +export interface HermesPortablePendingReceipt extends HermesPortableReceiptCommon { + readonly phase: "pending"; +} + +export interface HermesPortableConfiguredReceipt extends HermesPortableReceiptCommon { + readonly phase: "configuring" | "active"; + readonly previousPhaseSha256: string; + readonly verifiedLivePolicySemanticSha256: string; + readonly container: HermesPortableContainerAuthority; +} + +export type HermesPortableLifecycleReceipt = + | HermesPortablePendingReceipt + | HermesPortableConfiguredReceipt; + +export interface HermesPortableReceiptSnapshot { + readonly receipt: HermesPortableLifecycleReceipt; + readonly bytes: Buffer; + readonly sha256: string; + readonly path: string; + readonly identity: { + readonly dev: bigint; + readonly ino: bigint; + }; +} + +export type PortableAgentReceiptAuthority = + | { readonly kind: "none" } + | { readonly kind: "openclaw"; readonly path: string } + | { readonly kind: "hermes"; readonly snapshot: HermesPortableReceiptSnapshot }; + +export interface HermesPortableReceiptPublicationHooks { + readonly assertLifecycleLock?: () => void; + readonly afterStageCreate?: () => void; + readonly afterStageWrite?: (written: number, total: number) => void; + readonly afterStageFsync?: () => void; + readonly beforeStageDurabilityReopen?: () => void; + readonly afterCanonicalLink?: () => void; + readonly afterDirectoryFsync?: () => void; + readonly afterCleanupLink?: () => void; + readonly afterStageDetach?: () => void; + readonly beforeCleanupUnlink?: () => void; + readonly beforeInterruptedStageRetirement?: () => void; +} + +export interface HermesPortablePolicySourceSnapshot { + readonly path: string; + readonly bytes: Buffer; + readonly sha256: string; + readonly identity: fs.BigIntStats; +} + +export interface HermesPortablePolicySourceBytes { + readonly bytes: Buffer; + readonly sha256: string; +} + +export type HermesPortablePolicyPublicationSource = + | HermesPortablePolicySourceSnapshot + | HermesPortablePolicySourceBytes; + +function fail(message: string): never { + throw new Error(`Hermes portable lifecycle receipt ${message}`); +} + +function currentUid(): number { + if (typeof process.getuid !== "function") fail("requires current-user identity"); + return process.getuid(); +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function safeString(value: unknown, maximum = 4096): value is string { + return ( + typeof value === "string" && value.length > 0 && value.length <= maximum && !CONTROL.test(value) + ); +} + +function exactAbsolutePath(value: unknown): value is string { + return safeString(value) && path.isAbsolute(value) && path.normalize(value) === value; +} + +function parseStartup(value: unknown): HermesPortableStartupContract { + const startup = record(value); + const health = record(startup?.health); + if ( + !startup || + !exactKeys(startup, [ + "argv", + "configDir", + "devicePairing", + "gatewayCommand", + "health", + "interactiveCommand", + "manifestSha256", + "startupDescriptorSha256", + "stateIdentitySha256", + ]) || + !health || + !exactKeys(health, ["auth", "credentialEnv", "method", "port", "successStatus", "url"]) || + !SHA256.test(String(startup.manifestSha256)) || + !SHA256.test(String(startup.startupDescriptorSha256)) || + !SHA256.test(String(startup.stateIdentitySha256)) || + !Array.isArray(startup.argv) || + startup.argv.length < 2 || + startup.argv.length > 128 || + startup.argv.some((argument) => !safeString(argument, 2048)) || + startup.gatewayCommand !== "hermes gateway run" || + startup.interactiveCommand !== "hermes" || + startup.devicePairing !== false || + startup.configDir !== "/sandbox/.hermes" || + health.url !== "http://localhost:8642/health" || + health.port !== 8642 || + health.method !== "GET" || + health.auth !== "bearer_token" || + health.credentialEnv !== "API_SERVER_KEY" || + health.successStatus !== 200 + ) { + fail("has an invalid startup contract"); + } + return startup as unknown as HermesPortableStartupContract; +} + +function parsePolicy(value: unknown): HermesPortablePolicyAuthority { + const policy = record(value); + const identity = record(policy?.sourceIdentity); + if ( + !policy || + !exactKeys(policy, [ + "intendedSemanticSha256", + "sourceIdentity", + "sourcePath", + "sourceSha256", + ]) || + !identity || + !exactKeys(identity, ["ctimeNs", "dev", "ino", "mode", "mtimeNs", "size", "uid"]) || + !exactAbsolutePath(policy.sourcePath) || + !SHA256.test(String(policy.sourceSha256)) || + !SHA256.test(String(policy.intendedSemanticSha256)) || + !DECIMAL.test(String(identity.dev)) || + !DECIMAL.test(String(identity.ino)) || + !DECIMAL.test(String(identity.size)) || + !DECIMAL.test(String(identity.mtimeNs)) || + !DECIMAL.test(String(identity.ctimeNs)) || + identity.mode !== RECEIPT_MODE || + identity.uid !== currentUid() + ) { + fail("has invalid policy authority"); + } + return policy as unknown as HermesPortablePolicyAuthority; +} + +function parseContainer( + value: unknown, + phase: "configuring" | "active", +): HermesPortableContainerAuthority { + const container = record(value); + if ( + !container || + !exactKeys(container, [ + "containerId", + "imageId", + "labelsSha256", + "name", + "restartPolicy", + "running", + "sandboxId", + ]) || + !CONTAINER_ID.test(String(container.containerId)) || + !GENERATION.test(String(container.sandboxId)) || + !IMAGE_ID.test(String(container.imageId)) || + !SHA256.test(String(container.labelsSha256)) || + !safeString(container.name, 512) || + typeof container.running !== "boolean" || + !safeString(container.restartPolicy, 128) || + (phase === "configuring" && container.running !== true) || + (phase === "active" && + (container.running !== true || container.restartPolicy !== "unless-stopped")) + ) { + fail("has invalid container authority"); + } + return container as unknown as HermesPortableContainerAuthority; +} + +function parseSocketAuthority( + value: unknown, + runtimeAuthority: CheckpointPortableRuntimeAuthority, +): PodmanSocketAuthority { + const authority = record(value); + if ( + !authority || + !exactKeys(authority, [ + "device", + "directoryChain", + "inode", + "mode", + "ownerUid", + "socketPath", + ]) || + !DECIMAL.test(String(authority.device)) || + !DECIMAL.test(String(authority.inode)) || + !DECIMAL.test(String(authority.mode)) || + authority.ownerUid !== String(currentUid()) || + authority.socketPath !== runtimeAuthority.socketPath || + !Array.isArray(authority.directoryChain) || + authority.directoryChain.length < 1 || + authority.directoryChain.length > 64 + ) { + fail("has invalid Podman socket authority"); + } + let expectedPath = path.dirname(runtimeAuthority.socketPath); + for (const value of authority.directoryChain) { + const directory = record(value); + if ( + !directory || + !exactKeys(directory, ["device", "inode", "mode", "ownerUid", "path"]) || + !DECIMAL.test(String(directory.device)) || + !DECIMAL.test(String(directory.inode)) || + !DECIMAL.test(String(directory.mode)) || + !DECIMAL.test(String(directory.ownerUid)) || + directory.path !== expectedPath + ) { + fail("has invalid Podman socket directory authority"); + } + expectedPath = path.dirname(expectedPath); + } + if (expectedPath !== path.dirname(expectedPath)) { + fail("has incomplete Podman socket directory authority"); + } + return authority as unknown as PodmanSocketAuthority; +} + +function parseOpenShellExecutableAuthority( + value: unknown, +): HermesPortableOpenShellExecutableAuthority { + const authority = record(value); + const executable = record(authority?.executable); + if ( + !authority || + !exactKeys(authority, ["executable", "version"]) || + authority.version !== HERMES_PORTABLE_OPENSHELL_VERSION || + !executable || + !exactKeys(executable, [ + "changedTimeNanoseconds", + "device", + "directoryChain", + "executablePath", + "inode", + "mode", + "modifiedTimeNanoseconds", + "ownerUid", + "sha256", + "size", + ]) || + !exactAbsolutePath(executable.executablePath) || + !DECIMAL.test(String(executable.changedTimeNanoseconds)) || + !DECIMAL.test(String(executable.device)) || + !DECIMAL.test(String(executable.inode)) || + !DECIMAL.test(String(executable.mode)) || + !DECIMAL.test(String(executable.modifiedTimeNanoseconds)) || + !DECIMAL.test(String(executable.ownerUid)) || + !SHA256.test(String(executable.sha256)) || + !DECIMAL.test(String(executable.size)) || + !Array.isArray(executable.directoryChain) || + executable.directoryChain.length < 1 || + executable.directoryChain.length > 64 + ) { + fail("has invalid OpenShell executable authority"); + } + const uid = String(currentUid()); + if (executable.ownerUid !== "0" && executable.ownerUid !== uid) { + fail("has invalid OpenShell executable ownership"); + } + let expectedPath = path.dirname(executable.executablePath as string); + for (const value of executable.directoryChain) { + const directory = record(value); + if ( + !directory || + !exactKeys(directory, ["device", "inode", "mode", "ownerUid", "path"]) || + !DECIMAL.test(String(directory.device)) || + !DECIMAL.test(String(directory.inode)) || + !DECIMAL.test(String(directory.mode)) || + !DECIMAL.test(String(directory.ownerUid)) || + (directory.ownerUid !== "0" && directory.ownerUid !== uid) || + directory.path !== expectedPath + ) { + fail("has invalid OpenShell executable directory authority"); + } + expectedPath = path.dirname(expectedPath); + } + if (expectedPath !== path.dirname(expectedPath)) { + fail("has incomplete OpenShell executable directory authority"); + } + return authority as unknown as HermesPortableOpenShellExecutableAuthority; +} + +function parsePodmanExecutableAuthority(value: unknown): HermesPortablePodmanExecutableAuthority { + const authority = record(value); + const executable = record(authority?.executable); + if ( + !authority || + !exactKeys(authority, ["executable", "version"]) || + authority.version !== HERMES_PORTABLE_PODMAN_VERSION || + !executable || + !exactKeys(executable, [ + "changedTimeNanoseconds", + "device", + "directoryChain", + "executablePath", + "inode", + "mode", + "modifiedTimeNanoseconds", + "ownerUid", + "sha256", + "size", + ]) || + !exactAbsolutePath(executable.executablePath) || + !DECIMAL.test(String(executable.changedTimeNanoseconds)) || + !DECIMAL.test(String(executable.device)) || + !DECIMAL.test(String(executable.inode)) || + !DECIMAL.test(String(executable.mode)) || + !DECIMAL.test(String(executable.modifiedTimeNanoseconds)) || + !DECIMAL.test(String(executable.ownerUid)) || + !SHA256.test(String(executable.sha256)) || + !DECIMAL.test(String(executable.size)) || + !Array.isArray(executable.directoryChain) || + executable.directoryChain.length < 1 || + executable.directoryChain.length > 64 + ) { + fail("has invalid Podman executable authority"); + } + const uid = String(currentUid()); + if (executable.ownerUid !== "0" && executable.ownerUid !== uid) { + fail("has invalid Podman executable ownership"); + } + let expectedPath = path.dirname(executable.executablePath as string); + for (const value of executable.directoryChain) { + const directory = record(value); + if ( + !directory || + !exactKeys(directory, ["device", "inode", "mode", "ownerUid", "path"]) || + !DECIMAL.test(String(directory.device)) || + !DECIMAL.test(String(directory.inode)) || + !DECIMAL.test(String(directory.mode)) || + !DECIMAL.test(String(directory.ownerUid)) || + (directory.ownerUid !== "0" && directory.ownerUid !== uid) || + directory.path !== expectedPath + ) { + fail("has invalid Podman executable directory authority"); + } + expectedPath = path.dirname(expectedPath); + } + if (expectedPath !== path.dirname(expectedPath)) { + fail("has incomplete Podman executable directory authority"); + } + return authority as unknown as HermesPortablePodmanExecutableAuthority; +} + +function parseReceiptBytes(bytes: Buffer): HermesPortableLifecycleReceipt { + let value: unknown; + try { + value = JSON.parse(UTF8.decode(bytes)); + } catch { + fail("is malformed or is not strict UTF-8"); + } + const receipt = record(value); + const phase = receipt?.phase; + const configured = phase === "configuring" || phase === "active"; + const expected = [ + "agent", + "createIntentSha256", + "gatewayName", + "lifecycleGeneration", + "openshellExecutableAuthority", + "podmanExecutableAuthority", + "phase", + "policy", + "runtimeAuthority", + "sandboxName", + "schemaVersion", + "socketAuthority", + "startup", + "transactionId", + ...(configured ? ["container", "previousPhaseSha256", "verifiedLivePolicySemanticSha256"] : []), + ]; + const authority = parsePortableRuntimeAuthority(receipt?.runtimeAuthority); + if ( + !receipt || + !exactKeys(receipt, expected) || + receipt.schemaVersion !== HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION || + receipt.agent !== "hermes" || + (phase !== "pending" && !configured) || + !UUID.test(String(receipt.transactionId)) || + !SHA256.test(String(receipt.createIntentSha256)) || + !SANDBOX.test(String(receipt.sandboxName)) || + !safeString(receipt.gatewayName, 256) || + !GENERATION.test(String(receipt.lifecycleGeneration)) || + !authority || + authority.uid !== currentUid() + ) { + fail("has invalid identity fields"); + } + const common = { + schemaVersion: HERMES_PORTABLE_RECEIPT_SCHEMA_VERSION, + agent: "hermes" as const, + transactionId: receipt.transactionId as string, + createIntentSha256: receipt.createIntentSha256 as string, + sandboxName: receipt.sandboxName as string, + gatewayName: receipt.gatewayName as string, + lifecycleGeneration: receipt.lifecycleGeneration as string, + runtimeAuthority: authority, + openshellExecutableAuthority: parseOpenShellExecutableAuthority( + receipt.openshellExecutableAuthority, + ), + podmanExecutableAuthority: parsePodmanExecutableAuthority(receipt.podmanExecutableAuthority), + socketAuthority: parseSocketAuthority(receipt.socketAuthority, authority), + startup: parseStartup(receipt.startup), + policy: parsePolicy(receipt.policy), + }; + if (phase === "pending") return { ...common, phase }; + if ( + !SHA256.test(String(receipt.previousPhaseSha256)) || + !SHA256.test(String(receipt.verifiedLivePolicySemanticSha256)) + ) { + fail("has invalid phase authority"); + } + return { + ...common, + phase, + previousPhaseSha256: receipt.previousPhaseSha256 as string, + verifiedLivePolicySemanticSha256: receipt.verifiedLivePolicySemanticSha256 as string, + container: parseContainer(receipt.container, phase), + }; +} + +function serializeReceipt(receipt: HermesPortableLifecycleReceipt): Buffer { + const normalized = parseReceiptBytes(Buffer.from(`${JSON.stringify(receipt)}\n`, "utf8")); + const bytes = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8"); + if (bytes.byteLength > Number(MAX_RECEIPT_BYTES)) { + fail("serialized receipt exceeds the bounded receipt size"); + } + return bytes; +} + +function receiptHash(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function sandboxReceiptStem(sandboxName: string): string { + return createHash("sha256").update(sandboxName).digest("hex"); +} + +export function hermesPortableReceiptRoot(stateDir: string): string { + return path.join(stateDir, HERMES_PORTABLE_RECEIPT_DIRECTORY); +} + +export function hermesPortableReceiptDirectory(sandboxName: string, stateDir: string): string { + return path.join(hermesPortableReceiptRoot(stateDir), sandboxReceiptStem(sandboxName)); +} + +function phasePath(directory: string, phase: HermesPortableReceiptPhase): string { + return path.join(directory, `${phase}.json`); +} + +function policySourceBasename(transactionId: string): string { + if (!UUID.test(transactionId)) fail("has an invalid policy transaction identity"); + return `policy.${transactionId}.yaml`; +} + +export function hermesPortablePolicySourcePath( + sandboxName: string, + transactionId: string, + stateDir: string, +): string { + return path.join( + hermesPortableReceiptDirectory(sandboxName, stateDir), + policySourceBasename(transactionId), + ); +} + +function policyPublicationTransactionId(entry: string): string | null { + const match = + /^(?:policy\.([a-f0-9-]{36})\.yaml|\.policy\.([a-f0-9-]{36})\.[a-f0-9]{64}\.[a-f0-9]{64}\.next(?:\.cleanup)?)$/u.exec( + entry, + ); + const transactionId = match?.[1] ?? match?.[2]; + return transactionId && UUID.test(transactionId) ? transactionId : null; +} + +function pendingPublicationTransactionId(entry: string): string | null { + const identity = phasePublicationIdentity(entry); + return identity?.phase === "pending" ? identity.transactionId : null; +} + +function phasePublicationIdentity(entry: string): { + readonly phase: HermesPortableReceiptPhase; + readonly transactionId: string; + readonly createIntentSha256: string; + readonly generationSha256: string; + readonly cleanup: boolean; +} | null { + const match = + /^\.(pending|configuring|active)\.([a-f0-9-]{36})\.([a-f0-9]{64})\.([a-f0-9]{64})\.next(\.cleanup)?$/u.exec( + entry, + ); + if (!match || !UUID.test(match[2]!)) return null; + return { + phase: match[1] as HermesPortableReceiptPhase, + transactionId: match[2]!, + createIntentSha256: match[3]!, + generationSha256: match[4]!, + cleanup: Boolean(match[5]), + }; +} + +function stagePath(directory: string, receipt: HermesPortableLifecycleReceipt): string { + const generationSha256 = receiptHash(Buffer.from(receipt.lifecycleGeneration, "utf8")); + return path.join( + directory, + `.${receipt.phase}.${receipt.transactionId}.${receipt.createIntentSha256}.${generationSha256}.next`, + ); +} + +function policyStagePath( + directory: string, + transactionId: string, + sourceSha256: string, + intendedSemanticSha256: string, +): string { + return path.join( + directory, + `.policy.${transactionId}.${sourceSha256}.${intendedSemanticSha256}.next`, + ); +} + +function cleanupPath(target: string): string { + return `${target}.cleanup`; +} + +function sameDirectoryIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid + ); +} + +function validDirectoryLinkCount(identity: fs.BigIntStats, bounded: boolean): boolean { + return identity.nlink >= 1n && (!bounded || identity.nlink <= BigInt(MAX_DIRECTORY_ENTRIES + 2)); +} + +interface OpenReceiptDirectory { + readonly path: string; + readonly descriptor: number; + readonly identity: fs.BigIntStats; + readonly boundedLinks: boolean; +} + +function validateDirectory(directory: string, boundedLinks = true): OpenReceiptDirectory { + const noFollow = fs.constants.O_NOFOLLOW; + const directoryFlag = fs.constants.O_DIRECTORY; + if (typeof noFollow !== "number" || typeof directoryFlag !== "number") { + fail("requires O_NOFOLLOW and O_DIRECTORY"); + } + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY | noFollow | directoryFlag); + try { + const identity = fs.fstatSync(descriptor, { bigint: true }); + const named = fs.lstatSync(directory, { bigint: true }); + if ( + !identity.isDirectory() || + named.isSymbolicLink() || + !sameDirectoryIdentity(identity, named) || + identity.uid !== BigInt(currentUid()) || + (identity.mode & 0o777n) !== BigInt(DIRECTORY_MODE) || + !validDirectoryLinkCount(identity, boundedLinks) || + !validDirectoryLinkCount(named, boundedLinks) + ) { + fail(`directory is unsafe: ${directory}`); + } + return { path: directory, descriptor, identity, boundedLinks }; + } catch (error) { + fs.closeSync(descriptor); + throw error; + } +} + +function revalidateDirectory(directory: OpenReceiptDirectory): void { + const descriptor = fs.fstatSync(directory.descriptor, { bigint: true }); + const named = fs.lstatSync(directory.path, { bigint: true }); + if ( + !sameDirectoryIdentity(directory.identity, descriptor) || + !sameDirectoryIdentity(directory.identity, named) || + !validDirectoryLinkCount(descriptor, directory.boundedLinks) || + !validDirectoryLinkCount(named, directory.boundedLinks) + ) { + fail(`directory changed while in use: ${directory.path}`); + } +} + +function ensurePrivateDirectory(directory: string, boundedLinks = true): OpenReceiptDirectory { + try { + fs.mkdirSync(directory, { mode: DIRECTORY_MODE }); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + } + return validateDirectory(directory, boundedLinks); +} + +function ensureReceiptDirectory(sandboxName: string, stateDir: string): OpenReceiptDirectory { + const root = hermesPortableReceiptRoot(stateDir); + const rootDirectory = ensurePrivateDirectory(root, false); + try { + revalidateDirectory(rootDirectory); + return ensurePrivateDirectory(hermesPortableReceiptDirectory(sandboxName, stateDir)); + } finally { + fs.closeSync(rootDirectory.descriptor); + } +} + +interface ExactFile { + readonly bytes: Buffer; + readonly identity: fs.BigIntStats; +} + +function sameFileIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function readExactFile( + target: string, + allowedLinks = 1n, + maximumBytes = MAX_RECEIPT_BYTES, + minimumBytes = 1n, +): ExactFile | null { + const noFollow = fs.constants.O_NOFOLLOW; + if (typeof noFollow !== "number") fail("requires O_NOFOLLOW"); + const nonblock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollow | nonblock); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; + } + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + const named = fs.lstatSync(target, { bigint: true }); + if ( + !before.isFile() || + named.isSymbolicLink() || + !sameFileIdentity(before, named) || + before.uid !== BigInt(currentUid()) || + (before.mode & 0o777n) !== BigInt(RECEIPT_MODE) || + before.nlink !== allowedLinks || + before.size < minimumBytes || + before.size > BigInt(maximumBytes) + ) { + fail(`file is unsafe: ${target}`); + } + const bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.length) { + const count = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count === 0) fail(`file ended during read: ${target}`); + offset += count; + } + if (!sameFileIdentity(before, fs.fstatSync(descriptor, { bigint: true }))) { + fail(`file changed during read: ${target}`); + } + return { bytes, identity: before }; + } finally { + fs.closeSync(descriptor); + } +} + +function policyAuthorityFromFile( + target: string, + file: ExactFile, + intendedSemanticSha256: string, +): HermesPortablePolicyAuthority { + if (!SHA256.test(intendedSemanticSha256)) fail("has an invalid intended policy digest"); + try { + UTF8.decode(file.bytes); + } catch { + fail("policy source is not strict UTF-8"); + } + return { + sourcePath: target, + sourceSha256: receiptHash(file.bytes), + intendedSemanticSha256, + sourceIdentity: { + dev: String(file.identity.dev), + ino: String(file.identity.ino), + size: String(file.identity.size), + mode: RECEIPT_MODE, + uid: currentUid(), + mtimeNs: String(file.identity.mtimeNs), + ctimeNs: String(file.identity.ctimeNs), + }, + }; +} + +function samePolicyIdentity(authority: HermesPortablePolicyAuthority, file: ExactFile): boolean { + return ( + authority.sourceSha256 === receiptHash(file.bytes) && + authority.sourceIdentity.dev === String(file.identity.dev) && + authority.sourceIdentity.ino === String(file.identity.ino) && + authority.sourceIdentity.size === String(file.identity.size) && + authority.sourceIdentity.mode === RECEIPT_MODE && + authority.sourceIdentity.uid === currentUid() && + authority.sourceIdentity.mtimeNs === String(file.identity.mtimeNs) && + authority.sourceIdentity.ctimeNs === String(file.identity.ctimeNs) + ); +} + +export function captureHermesPortablePolicySource( + sourcePath: string, +): HermesPortablePolicySourceSnapshot { + if (!exactAbsolutePath(sourcePath)) fail("policy source path is invalid"); + const file = readExactFile(sourcePath, 1n, MAX_POLICY_BYTES); + if (!file) fail(`policy source is missing: ${sourcePath}`); + try { + UTF8.decode(file.bytes); + } catch { + fail("policy source is not strict UTF-8"); + } + return { + path: sourcePath, + bytes: file.bytes, + sha256: receiptHash(file.bytes), + identity: file.identity, + }; +} + +export function assertHermesPortablePolicySourceSnapshot( + snapshot: HermesPortablePolicySourceSnapshot, +): void { + const current = captureHermesPortablePolicySource(snapshot.path); + if ( + current.sha256 !== snapshot.sha256 || + !current.bytes.equals(snapshot.bytes) || + !sameFileIdentity(current.identity, snapshot.identity) + ) { + fail("policy source changed while in custody"); + } +} + +function assertHermesPortablePolicyPublicationSource( + source: HermesPortablePolicyPublicationSource, +): void { + if ("path" in source) { + assertHermesPortablePolicySourceSnapshot(source); + return; + } + if ( + source.bytes.length < 1 || + source.bytes.length > MAX_POLICY_BYTES || + receiptHash(source.bytes) !== source.sha256 + ) { + fail("in-memory policy source is invalid"); + } + try { + UTF8.decode(source.bytes); + } catch { + fail("in-memory policy source is not strict UTF-8"); + } +} + +export function assertHermesPortableDurablePolicyAuthority( + authority: HermesPortablePolicyAuthority, +): Buffer { + const file = readExactFile(authority.sourcePath, 1n, MAX_POLICY_BYTES); + if (!file || !samePolicyIdentity(authority, file)) { + fail("durable policy source disagrees with its receipt authority"); + } + try { + UTF8.decode(file.bytes); + } catch { + fail("durable policy source is not strict UTF-8"); + } + return file.bytes; +} + +function writeStage( + target: string, + bytes: Buffer, + hooks: HermesPortableReceiptPublicationHooks, +): void { + const descriptor = fs.openSync( + target, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + RECEIPT_MODE, + ); + try { + fs.fchmodSync(descriptor, RECEIPT_MODE); + hooks.afterStageCreate?.(); + let offset = 0; + while (offset < bytes.length) { + const count = fs.writeSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count <= 0) fail("stage write did not make progress"); + offset += count; + hooks.afterStageWrite?.(offset, bytes.length); + } + fs.fsyncSync(descriptor); + hooks.afterStageFsync?.(); + } finally { + fs.closeSync(descriptor); + // Preserve an incomplete private generation. An identical publisher can + // retire it under the lifecycle lock; an ordinary reader fails closed. + } +} + +function fsyncExactStage( + target: string, + expectedBytes: Buffer, + hooks: HermesPortableReceiptPublicationHooks, + maximumBytes = MAX_RECEIPT_BYTES, +): void { + const expected = readExactFile(target, 1n, maximumBytes); + if (!expected || !expected.bytes.equals(expectedBytes)) { + fail("staged authority changed before durability recheck"); + } + hooks.beforeStageDurabilityReopen?.(); + const noFollow = fs.constants.O_NOFOLLOW; + if (typeof noFollow !== "number") fail("requires O_NOFOLLOW"); + const nonblock = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; + const descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollow | nonblock); + try { + const opened = fs.fstatSync(descriptor, { bigint: true }); + const named = fs.lstatSync(target, { bigint: true }); + if (!sameFileIdentity(expected.identity, opened) || !sameFileIdentity(opened, named)) { + fail("staged authority changed before durability recheck"); + } + fs.fsyncSync(descriptor); + if (!sameFileIdentity(opened, fs.fstatSync(descriptor, { bigint: true }))) { + fail("staged authority changed during durability recheck"); + } + } finally { + fs.closeSync(descriptor); + } + const durable = readExactFile(target, 1n, maximumBytes); + if (!durable || !sameArtifact(expected, durable) || !durable.bytes.equals(expectedBytes)) { + fail("staged authority changed after durability recheck"); + } +} + +function retireInterruptedEmptyStage( + canonical: string, + staged: string, + cleanup: string, + directory: OpenReceiptDirectory, + assertLifecycleLock: () => void, + maximumBytes = MAX_RECEIPT_BYTES, +): void { + if (stageLinkCount(staged) !== 1n) return; + const empty = readExactFile(staged, 1n, maximumBytes, 0n); + if (!empty || empty.bytes.length !== 0) return; + if (stageLinkCount(canonical) !== null || stageLinkCount(cleanup) !== null) { + fail("empty stage conflicts with other publication evidence"); + } + assertLifecycleLock(); + revalidateDirectory(directory); + const current = readExactFile(staged, 1n, maximumBytes, 0n); + if (!current || !sameArtifact(empty, current) || current.bytes.length !== 0) { + fail("empty stage changed before exact retirement"); + } + fs.unlinkSync(staged); + fs.fsyncSync(directory.descriptor); +} + +function retireInterruptedExactPrefixStage( + canonical: string, + staged: string, + cleanup: string, + directory: OpenReceiptDirectory, + expectedBytes: Buffer, + assertLifecycleLock: () => void, + hooks: HermesPortableReceiptPublicationHooks, + maximumBytes = MAX_RECEIPT_BYTES, +): void { + if (stageLinkCount(staged) !== 1n) return; + const interrupted = readExactFile(staged, 1n, maximumBytes); + if (!interrupted || interrupted.bytes.length >= expectedBytes.length) return; + if (!expectedBytes.subarray(0, interrupted.bytes.length).equals(interrupted.bytes)) { + fail("interrupted stage is not the exact authorized receipt prefix"); + } + if (stageLinkCount(canonical) !== null || stageLinkCount(cleanup) !== null) { + fail("interrupted stage conflicts with other publication evidence"); + } + hooks.beforeInterruptedStageRetirement?.(); + assertLifecycleLock(); + revalidateDirectory(directory); + if (stageLinkCount(canonical) !== null || stageLinkCount(cleanup) !== null) { + fail("interrupted stage conflicts with other publication evidence"); + } + const current = readExactFile(staged, 1n, maximumBytes); + if ( + !current || + !sameArtifact(interrupted, current) || + current.bytes.length >= expectedBytes.length || + !expectedBytes.subarray(0, current.bytes.length).equals(current.bytes) + ) { + fail("interrupted stage changed before exact retirement"); + } + unlinkExactArtifact(staged, current, undefined, maximumBytes); + fs.fsyncSync(directory.descriptor); + if (stageLinkCount(staged) !== null) fail("interrupted stage remained after exact retirement"); +} + +function readPublicationArtifact( + target: string, + maximumBytes = MAX_RECEIPT_BYTES, +): ExactFile | null { + const links = stageLinkCount(target); + if (links === null) return null; + if (links < 1n || links > 3n) fail(`publication artifact has invalid links: ${target}`); + return readExactFile(target, links, maximumBytes); +} + +function sameArtifact(left: ExactFile, right: ExactFile): boolean { + return ( + left.identity.dev === right.identity.dev && + left.identity.ino === right.identity.ino && + left.bytes.equals(right.bytes) + ); +} + +function unlinkExactArtifact( + target: string, + expected: ExactFile, + beforeUnlink?: () => void, + maximumBytes = MAX_RECEIPT_BYTES, +): void { + beforeUnlink?.(); + const current = readPublicationArtifact(target, maximumBytes); + if (!current || !sameArtifact(current, expected)) { + fail("artifact changed before exact detach"); + } + fs.unlinkSync(target); +} + +function detachPublishedStage( + canonical: string, + staged: string, + cleanup: string, + expectedBytes: Buffer, + hooks: HermesPortableReceiptPublicationHooks, + maximumBytes = MAX_RECEIPT_BYTES, +): void { + const canonicalFile = readPublicationArtifact(canonical, maximumBytes); + const stagedFile = readPublicationArtifact(staged, maximumBytes); + if (!canonicalFile || !stagedFile) fail("publication is missing canonical or staged authority"); + if ( + !sameArtifact(canonicalFile, stagedFile) || + !canonicalFile.bytes.equals(expectedBytes) || + !stagedFile.bytes.equals(expectedBytes) + ) { + fail("publication artifacts disagree"); + } + const existingCleanup = readPublicationArtifact(cleanup, maximumBytes); + if (existingCleanup) { + if (!sameArtifact(canonicalFile, existingCleanup)) fail("cleanup artifact disagrees"); + } else { + fs.linkSync(staged, cleanup); + hooks.afterCleanupLink?.(); + } + const linkedStage = readPublicationArtifact(staged, maximumBytes); + if (!linkedStage || !sameArtifact(canonicalFile, linkedStage)) { + fail("staged authority changed before detach"); + } + unlinkExactArtifact(staged, linkedStage, undefined, maximumBytes); + hooks.afterStageDetach?.(); + const linkedCleanup = readPublicationArtifact(cleanup, maximumBytes); + if (!linkedCleanup || !sameArtifact(canonicalFile, linkedCleanup)) { + fail("cleanup authority changed before detach"); + } + unlinkExactArtifact(cleanup, linkedCleanup, hooks.beforeCleanupUnlink, maximumBytes); +} + +function reconcilePublicationArtifacts( + canonical: string, + staged: string, + cleanup: string, + expectedBytes: Buffer, + hooks: HermesPortableReceiptPublicationHooks, + maximumBytes = MAX_RECEIPT_BYTES, +): "complete" | "staged" | "absent" { + const canonicalFile = readPublicationArtifact(canonical, maximumBytes); + const stagedFile = readPublicationArtifact(staged, maximumBytes); + const cleanupFile = readPublicationArtifact(cleanup, maximumBytes); + const artifacts = [canonicalFile, stagedFile, cleanupFile].filter( + (artifact): artifact is ExactFile => artifact !== null, + ); + if (artifacts.some((artifact) => !artifact.bytes.equals(expectedBytes))) { + fail("publication artifacts disagree"); + } + if ( + artifacts.length > 1 && + artifacts.some((artifact) => !sameArtifact(artifacts[0]!, artifact)) + ) { + fail("publication artifacts have different generations"); + } + const expectedLinks = BigInt(artifacts.length); + if (artifacts.some((artifact) => artifact.identity.nlink !== expectedLinks)) { + fail("publication artifacts have unaccounted links"); + } + if (canonicalFile) { + if (stagedFile) { + detachPublishedStage(canonical, staged, cleanup, expectedBytes, hooks, maximumBytes); + } else if (cleanupFile) { + unlinkExactArtifact(cleanup, cleanupFile, hooks.beforeCleanupUnlink, maximumBytes); + } + return "complete"; + } + if (stagedFile && cleanupFile) { + unlinkExactArtifact(cleanup, cleanupFile, undefined, maximumBytes); + return "staged"; + } + if (cleanupFile) { + fs.linkSync(cleanup, staged); + const restored = readPublicationArtifact(staged, maximumBytes); + if (!restored || !sameArtifact(cleanupFile, restored)) + fail("could not restore staged authority"); + unlinkExactArtifact(cleanup, cleanupFile, undefined, maximumBytes); + return "staged"; + } + return stagedFile ? "staged" : "absent"; +} + +function stageLinkCount(target: string): bigint | null { + try { + const stat = fs.lstatSync(target, { bigint: true }); + if (!stat.isFile() || stat.isSymbolicLink()) fail(`publication artifact is unsafe: ${target}`); + return stat.nlink; + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; + } +} + +/** Copy an exact temporary create policy into private durable transaction custody. */ +export function publishHermesPortableDurablePolicySource(input: { + readonly sandboxName: string; + readonly transactionId: string; + readonly stateDir: string; + readonly intendedSemanticSha256: string; + readonly source: HermesPortablePolicyPublicationSource; + readonly hooks?: HermesPortableReceiptPublicationHooks; +}): HermesPortablePolicyAuthority { + const hooks = input.hooks ?? {}; + const assertLifecycleLock = + hooks.assertLifecycleLock ?? + (() => { + if (!isMcpLifecycleLockHeld(input.sandboxName, path.join(input.stateDir, "state"))) { + fail(`policy publication requires the sandbox lifecycle lock for '${input.sandboxName}'`); + } + }); + assertLifecycleLock(); + assertHermesPortablePolicyPublicationSource(input.source); + if (existingPath(portableDemoReceiptPath(input.sandboxName, input.stateDir))) { + fail(`will not reserve policy over OpenClaw authority for '${input.sandboxName}'`); + } + const directory = ensureReceiptDirectory(input.sandboxName, input.stateDir); + const target = hermesPortablePolicySourcePath( + input.sandboxName, + input.transactionId, + input.stateDir, + ); + const staged = policyStagePath( + directory.path, + input.transactionId, + input.source.sha256, + input.intendedSemanticSha256, + ); + const cleanup = cleanupPath(staged); + try { + revalidateDirectory(directory); + assertLifecycleLock(); + const allowedEntries = new Set([ + "active.json", + "configuring.json", + "pending.json", + path.basename(target), + path.basename(staged), + path.basename(cleanup), + ]); + const unexpected = fs + .readdirSync(directory.path) + .filter( + (entry) => + !allowedEntries.has(entry) && + pendingPublicationTransactionId(entry) !== input.transactionId, + ); + if (unexpected.length > 0) { + fail(`directory contains other policy authority for '${input.sandboxName}'`); + } + retireInterruptedEmptyStage( + target, + staged, + cleanup, + directory, + assertLifecycleLock, + MAX_POLICY_BYTES, + ); + retireInterruptedExactPrefixStage( + target, + staged, + cleanup, + directory, + input.source.bytes, + assertLifecycleLock, + hooks, + MAX_POLICY_BYTES, + ); + const disposition = reconcilePublicationArtifacts( + target, + staged, + cleanup, + input.source.bytes, + hooks, + MAX_POLICY_BYTES, + ); + const reconciled = readExactFile(target, 1n, MAX_POLICY_BYTES); + if (reconciled) { + if (!reconciled.bytes.equals(input.source.bytes)) fail("durable policy has other authority"); + assertHermesPortablePolicyPublicationSource(input.source); + fs.fsyncSync(directory.descriptor); + return policyAuthorityFromFile(target, reconciled, input.intendedSemanticSha256); + } + if (disposition === "complete") fail("completed policy publication has no readable authority"); + if (disposition === "absent") writeStage(staged, input.source.bytes, hooks); + revalidateDirectory(directory); + assertHermesPortablePolicyPublicationSource(input.source); + assertLifecycleLock(); + fsyncExactStage(staged, input.source.bytes, hooks, MAX_POLICY_BYTES); + revalidateDirectory(directory); + try { + fs.linkSync(staged, target); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + const raced = readExactFile(target, 1n, MAX_POLICY_BYTES); + if (!raced || !raced.bytes.equals(input.source.bytes)) { + fail("durable policy publication raced other authority"); + } + } + hooks.afterCanonicalLink?.(); + assertLifecycleLock(); + fs.fsyncSync(directory.descriptor); + hooks.afterDirectoryFsync?.(); + detachPublishedStage(target, staged, cleanup, input.source.bytes, hooks, MAX_POLICY_BYTES); + assertLifecycleLock(); + assertHermesPortablePolicyPublicationSource(input.source); + fs.fsyncSync(directory.descriptor); + const published = readExactFile(target, 1n, MAX_POLICY_BYTES); + if (!published || !published.bytes.equals(input.source.bytes)) { + fail("durable policy publication did not preserve exact bytes"); + } + return policyAuthorityFromFile(target, published, input.intendedSemanticSha256); + } finally { + fs.closeSync(directory.descriptor); + } +} + +/** + * Find one private policy generation whose pending receipt was not yet linked. + * The caller must resume it with the exact current policy bytes under the same + * sandbox lifecycle lock. Receipt readers continue to reject this state. + */ +export function recoverableHermesPortablePolicyTransactionId( + sandboxName: string, + stateDir: string, +): string | null { + if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { + fail(`policy recovery requires the sandbox lifecycle lock for '${sandboxName}'`); + } + const directoryPath = hermesPortableReceiptDirectory(sandboxName, stateDir); + let directory: OpenReceiptDirectory; + try { + directory = validateDirectory(directoryPath); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; + } + try { + const entries = fs.readdirSync(directory.path).sort(); + if (entries.length === 0 || entries.some((entry) => entry.endsWith(".json"))) return null; + const transactionIds = entries.map( + (entry) => policyPublicationTransactionId(entry) ?? pendingPublicationTransactionId(entry), + ); + if ( + entries.length > 5 || + !entries.some((entry) => policyPublicationTransactionId(entry) !== null) || + transactionIds.some((transactionId) => transactionId === null) || + new Set(transactionIds).size !== 1 + ) { + fail(`directory has ambiguous pre-receipt policy authority for '${sandboxName}'`); + } + revalidateDirectory(directory); + return transactionIds[0]!; + } finally { + fs.closeSync(directory.descriptor); + } +} + +function validateReceiptPolicySource( + directoryPath: string, + receipt: HermesPortableLifecycleReceipt, +): void { + const expected = path.join(directoryPath, policySourceBasename(receipt.transactionId)); + if (receipt.policy.sourcePath !== expected) fail("policy source path is outside receipt custody"); + assertHermesPortableDurablePolicyAuthority(receipt.policy); +} + +function readPhase( + directory: string, + phase: HermesPortableReceiptPhase, + allowPublicationLinks = false, +): HermesPortableReceiptSnapshot | null { + const target = phasePath(directory, phase); + const file = allowPublicationLinks ? readPublicationArtifact(target) : readExactFile(target); + if (!file) return null; + const receipt = parseReceiptBytes(file.bytes); + if (receipt.phase !== phase) fail(`phase file '${phase}' contains another phase`); + return { + receipt, + bytes: file.bytes, + sha256: receiptHash(file.bytes), + path: target, + identity: { dev: file.identity.dev, ino: file.identity.ino }, + }; +} + +function sameTransaction( + left: HermesPortableLifecycleReceipt, + right: HermesPortableLifecycleReceipt, +): boolean { + const transactionAuthority = (receipt: HermesPortableLifecycleReceipt) => { + if (receipt.phase === "pending") { + const { phase: _phase, ...common } = receipt; + return common; + } + const { + phase: _phase, + container: _container, + previousPhaseSha256: _previous, + verifiedLivePolicySemanticSha256: _verified, + ...common + } = receipt; + return common; + }; + return isDeepStrictEqual(transactionAuthority(left), transactionAuthority(right)); +} + +function validateRecoverablePhaseArtifacts( + directoryPath: string, + entries: readonly string[], + pending: HermesPortableReceiptSnapshot, + highest: HermesPortableReceiptPhase, +): void { + const artifacts = entries + .map((entry) => ({ entry, identity: phasePublicationIdentity(entry) })) + .filter( + ( + candidate, + ): candidate is { + readonly entry: string; + readonly identity: NonNullable>; + } => candidate.identity !== null, + ); + const allowedPhases: readonly HermesPortableReceiptPhase[] = + highest === "pending" + ? ["pending", "configuring"] + : highest === "configuring" + ? ["configuring", "active"] + : ["active"]; + const generationSha256 = receiptHash(Buffer.from(pending.receipt.lifecycleGeneration, "utf8")); + if ( + artifacts.length > 2 || + artifacts.some( + ({ identity }) => + identity.transactionId !== pending.receipt.transactionId || + identity.createIntentSha256 !== pending.receipt.createIntentSha256 || + identity.generationSha256 !== generationSha256 || + !allowedPhases.includes(identity.phase), + ) || + (artifacts.length > 0 && new Set(artifacts.map(({ identity }) => identity.phase)).size !== 1) || + (artifacts.length === 2 && artifacts.filter(({ identity }) => identity.cleanup).length !== 1) + ) { + fail("phase publication recovery evidence disagrees with the stable transaction"); + } + for (const phase of ["pending", "configuring", "active"] as const) { + const canonical = readPublicationArtifact(phasePath(directoryPath, phase)); + const phaseArtifactEntries = artifacts.filter(({ identity }) => identity.phase === phase); + const phaseArtifacts = phaseArtifactEntries + .map(({ entry }) => readPublicationArtifact(path.join(directoryPath, entry))) + .filter((artifact): artifact is ExactFile => artifact !== null); + const expectedLinks = BigInt(phaseArtifacts.length + (canonical ? 1 : 0)); + const authority = canonical ?? phaseArtifacts[0] ?? null; + if ( + authority && + (phaseArtifacts.length !== phaseArtifactEntries.length || + authority.identity.nlink !== expectedLinks || + phaseArtifacts.some( + (artifact) => + artifact.identity.nlink !== expectedLinks || !sameArtifact(authority, artifact), + )) + ) { + fail("phase publication recovery artifacts have unaccounted or different generations"); + } + } +} + +function readHermesPortableLifecycleReceiptInternal( + sandboxName: string, + stateDir: string, + allowPublicationRecovery: boolean, +): HermesPortableReceiptSnapshot | null { + const directoryPath = hermesPortableReceiptDirectory(sandboxName, stateDir); + let directory: OpenReceiptDirectory; + try { + directory = validateDirectory(directoryPath); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; + } + try { + const entries = fs.readdirSync(directory.path).sort(); + const completePolicy = /^policy\.[a-f0-9-]{36}\.yaml$/u; + if ( + entries.length > MAX_DIRECTORY_ENTRIES || + entries.some( + (entry) => + !["active.json", "configuring.json", "pending.json"].includes(entry) && + !completePolicy.test(entry) && + !(allowPublicationRecovery && phasePublicationIdentity(entry)), + ) + ) { + fail(`directory contains incomplete or unknown publication evidence for '${sandboxName}'`); + } + const pending = readPhase(directoryPath, "pending", allowPublicationRecovery); + const configuring = readPhase(directoryPath, "configuring", allowPublicationRecovery); + const active = readPhase(directoryPath, "active", allowPublicationRecovery); + if (!pending) { + if (entries.length > 0) { + fail(`directory contains incomplete or unknown publication evidence for '${sandboxName}'`); + } + revalidateDirectory(directory); + return null; + } + const allowedEntries = new Set([ + "active.json", + "configuring.json", + "pending.json", + policySourceBasename(pending.receipt.transactionId), + ]); + const highestPhase = active ? "active" : configuring ? "configuring" : "pending"; + if (allowPublicationRecovery) { + validateRecoverablePhaseArtifacts(directoryPath, entries, pending, highestPhase); + } + if ( + entries.some( + (entry) => + !allowedEntries.has(entry) && + !(allowPublicationRecovery && phasePublicationIdentity(entry)), + ) + ) { + fail(`directory contains incomplete or unknown publication evidence for '${sandboxName}'`); + } + validateReceiptPolicySource(directoryPath, pending.receipt); + revalidateDirectory(directory); + if (!configuring && active) fail("phase chain is missing configuring authority"); + if (pending.receipt.sandboxName !== sandboxName) + fail("sandbox identity does not match its path"); + if (configuring) { + if ( + configuring.receipt.phase !== "configuring" || + configuring.receipt.previousPhaseSha256 !== pending.sha256 || + !sameTransaction(pending.receipt, configuring.receipt) + ) { + fail("configuring phase does not extend pending authority"); + } + } + if (active) { + if (!configuring) fail("active phase has no configuring authority"); + const configuringReceipt = configuring.receipt; + const activeReceipt = active.receipt; + if (configuringReceipt.phase !== "configuring" || activeReceipt.phase !== "active") { + fail("active phase files contain invalid phase authority"); + } + if ( + activeReceipt.previousPhaseSha256 !== configuring.sha256 || + !sameTransaction(configuringReceipt, activeReceipt) || + activeReceipt.container.containerId !== configuringReceipt.container.containerId || + activeReceipt.container.sandboxId !== configuringReceipt.container.sandboxId || + activeReceipt.container.imageId !== configuringReceipt.container.imageId || + activeReceipt.verifiedLivePolicySemanticSha256 !== + configuringReceipt.verifiedLivePolicySemanticSha256 + ) { + fail("active phase does not extend configuring authority"); + } + } + return active ?? configuring ?? pending; + } finally { + fs.closeSync(directory.descriptor); + } +} + +/** Read the highest complete Hermes phase. Any unknown or interrupted artifact blocks. */ +export function readHermesPortableLifecycleReceipt( + sandboxName: string, + stateDir: string, +): HermesPortableReceiptSnapshot | null { + return readHermesPortableLifecycleReceiptInternal(sandboxName, stateDir, false); +} + +/** Select stable receipt authority while its same-transaction publisher resumes under the lock. */ +export function inspectPortableAgentReceiptAuthorityForPublicationRecovery( + sandboxName: string, + stateDir: string, +): PortableAgentReceiptAuthority { + if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { + fail(`publication recovery requires the sandbox lifecycle lock for '${sandboxName}'`); + } + const legacyPath = portableDemoReceiptPath(sandboxName, stateDir); + const openclaw = existingPath(legacyPath); + const hermes = readHermesPortableLifecycleReceiptInternal(sandboxName, stateDir, true); + if (openclaw && hermes) fail(`agent authority is ambiguous for '${sandboxName}'`); + if (hermes) return { kind: "hermes", snapshot: hermes }; + if (openclaw) return { kind: "openclaw", path: legacyPath }; + return { kind: "none" }; +} + +/** Detach only the stable phase's exact same-transaction publication artifacts. */ +export function reconcileHermesPortableCurrentPhasePublication( + snapshot: HermesPortableReceiptSnapshot, + stateDir: string, +): HermesPortableReceiptSnapshot { + const directory = path.dirname(snapshot.path); + const staged = stagePath(directory, snapshot.receipt); + if (stageLinkCount(staged) === null && stageLinkCount(cleanupPath(staged)) === null) { + return snapshot; + } + return publishHermesPortableLifecycleReceipt(snapshot.receipt, stateDir); +} + +/** Publish one immutable phase without replacing an earlier phase generation. */ +export function publishHermesPortableLifecycleReceipt( + receipt: HermesPortableLifecycleReceipt, + stateDir: string, + hooks: HermesPortableReceiptPublicationHooks = {}, +): HermesPortableReceiptSnapshot { + const bytes = serializeReceipt(receipt); + const assertLifecycleLock = + hooks.assertLifecycleLock ?? + (() => { + if (!isMcpLifecycleLockHeld(receipt.sandboxName, path.join(stateDir, "state"))) { + fail(`publication requires the sandbox lifecycle lock for '${receipt.sandboxName}'`); + } + }); + assertLifecycleLock(); + if (existingPath(portableDemoReceiptPath(receipt.sandboxName, stateDir))) { + fail(`will not publish over OpenClaw authority for '${receipt.sandboxName}'`); + } + const directory = ensureReceiptDirectory(receipt.sandboxName, stateDir); + const target = phasePath(directory.path, receipt.phase); + const staged = stagePath(directory.path, receipt); + const cleanup = cleanupPath(staged); + try { + revalidateDirectory(directory); + assertLifecycleLock(); + const allowedEntries = new Set([ + "active.json", + "configuring.json", + "pending.json", + policySourceBasename(receipt.transactionId), + path.basename(staged), + path.basename(cleanup), + ]); + const unexpected = fs.readdirSync(directory.path).filter((entry) => !allowedEntries.has(entry)); + if (unexpected.length > 0) { + fail(`directory contains other publication evidence for '${receipt.sandboxName}'`); + } + const prior = + receipt.phase === "pending" + ? null + : readPhase(directory.path, receipt.phase === "configuring" ? "pending" : "configuring"); + if (receipt.phase !== "pending") { + if (!prior || receipt.previousPhaseSha256 !== prior.sha256) { + fail(`${receipt.phase} publication does not match its prior phase`); + } + if (!sameTransaction(prior.receipt, receipt)) fail("phase transaction changed"); + } + + retireInterruptedEmptyStage(target, staged, cleanup, directory, assertLifecycleLock); + retireInterruptedExactPrefixStage( + target, + staged, + cleanup, + directory, + bytes, + assertLifecycleLock, + hooks, + ); + const disposition = reconcilePublicationArtifacts(target, staged, cleanup, bytes, hooks); + const reconciled = readPhase(directory.path, receipt.phase); + if (reconciled) { + if (!reconciled.bytes.equals(bytes)) fail(`${receipt.phase} phase has other authority`); + fs.fsyncSync(directory.descriptor); + return reconciled; + } + + if (disposition === "complete") fail("completed publication has no readable phase"); + if (disposition === "absent") { + writeStage(staged, bytes, hooks); + } + revalidateDirectory(directory); + assertLifecycleLock(); + fsyncExactStage(staged, bytes, hooks); + revalidateDirectory(directory); + try { + fs.linkSync(staged, target); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + const raced = readPhase(directory.path, receipt.phase); + if (!raced || !raced.bytes.equals(bytes)) fail("phase publication raced other authority"); + } + hooks.afterCanonicalLink?.(); + assertLifecycleLock(); + fs.fsyncSync(directory.descriptor); + hooks.afterDirectoryFsync?.(); + detachPublishedStage(target, staged, cleanup, bytes, hooks); + assertLifecycleLock(); + fs.fsyncSync(directory.descriptor); + return readPhase(directory.path, receipt.phase)!; + } finally { + fs.closeSync(directory.descriptor); + } +} + +function existingPath(target: string): boolean { + try { + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink() || !stat.isFile()) fail(`legacy receipt path is unsafe: ${target}`); + return true; + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return false; + throw error; + } +} + +/** Select receipt authority by durable agent identity and reject duplicate ownership. */ +export function inspectPortableAgentReceiptAuthority( + sandboxName: string, + stateDir: string, +): PortableAgentReceiptAuthority { + const legacyPath = portableDemoReceiptPath(sandboxName, stateDir); + const openclaw = existingPath(legacyPath); + const hermes = readHermesPortableLifecycleReceipt(sandboxName, stateDir); + if (openclaw && hermes) fail(`agent authority is ambiguous for '${sandboxName}'`); + if (hermes) return { kind: "hermes", snapshot: hermes }; + if (openclaw) return { kind: "openclaw", path: legacyPath }; + return { kind: "none" }; +} + +export function createHermesPortableTransactionId(): string { + return randomUUID(); +} + +export const hermesPortableReceiptInternals = { + parseReceiptBytes, + phasePath, + policyStagePath, + stagePath, +}; diff --git a/src/lib/onboard/experimental/portable-agent-lifecycle.test.ts b/src/lib/onboard/experimental/portable-agent-lifecycle.test.ts new file mode 100644 index 00000000000..09c527b6136 --- /dev/null +++ b/src/lib/onboard/experimental/portable-agent-lifecycle.test.ts @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isLifecycleLockHeld: vi.fn(), + inspect: vi.fn(), + readRegistry: vi.fn(), + buildOpenShellCommandAuthority: vi.fn(), + buildOpenShellEnv: vi.fn(), + assertHermesAuthority: vi.fn(), + recoverHermes: vi.fn(), + recoverOpenClaw: vi.fn(), + stopHermes: vi.fn(), + stopOpenClaw: vi.fn(), +})); + +vi.mock("../../state/mcp-lifecycle-lock-acquisition", () => ({ + isMcpLifecycleLockHeld: mocks.isLifecycleLockHeld, +})); + +vi.mock("./hermes-portable-receipt", () => ({ + inspectPortableAgentReceiptAuthority: mocks.inspect, +})); +vi.mock("./hermes-portable-lifecycle", () => ({ + assertHermesPortableSandboxLifecycleAuthority: mocks.assertHermesAuthority, + buildHermesPortableOpenShellCommandAuthority: mocks.buildOpenShellCommandAuthority, + buildHermesPortableOpenShellEnv: mocks.buildOpenShellEnv, + recoverHermesPortableSandboxLifecycle: mocks.recoverHermes, + stopHermesPortableSandboxLifecycle: mocks.stopHermes, +})); +vi.mock("./portable-demo-lifecycle", () => ({ + recoverPortableDemoSandboxLifecycle: mocks.recoverOpenClaw, + stopPortableDemoSandboxLifecycle: mocks.stopOpenClaw, +})); + +import { + buildHermesPortableCommandAuthority, + buildHermesPortableCommandEnvironment, + buildHermesPortableOnboardingCommandAuthority, + assertHermesPortableAgentLifecycleAuthority, + inspectPortableAgentReceiptDisposition, + qualifyPortableAgentLifecycleAuthority, + recoverPortableAgentSandboxLifecycle, + requireHermesPortableActiveLifecycleAuthority, + stopPortableAgentSandboxLifecycle, +} from "./portable-agent-lifecycle"; + +const context = { + agent: "hermes", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + openshellDriver: "docker", + provider: "ollama", +}; + +function hermes(phase: "pending" | "configuring" | "active") { + return { + kind: "hermes", + snapshot: { + receipt: { + phase, + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + runtimeAuthority: { + homeDir: "/home/test", + configHome: "/home/test/.config", + runtimeDir: "/run/user/1000", + }, + ...(phase === "pending" ? {} : { container: { sandboxId: "sandbox-id" } }), + }, + }, + }; +} + +function hermesDisposition(phase: "pending" | "configuring" | "active") { + return { + kind: "hermes" as const, + phase, + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + liveIdentityFingerprint: + phase === "pending" ? null : createHash("sha256").update("sandbox-id").digest("hex"), + }; +} + +function hermesRegistryEntry(overrides: Record = {}) { + return { + name: "alpha", + agent: "hermes", + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: createHash("sha256").update("sandbox-id").digest("hex"), + ...overrides, + }; +} + +const lifecycleAuthorityDeps = { + readRegistry: (sandboxName: string) => mocks.readRegistry(sandboxName), +}; + +describe("portable agent lifecycle dispatch", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.buildOpenShellEnv.mockImplementation( + (env: NodeJS.ProcessEnv, authority: Record) => ({ + PATH: env.PATH, + HOME: authority.homeDir, + XDG_CONFIG_HOME: authority.configHome, + XDG_RUNTIME_DIR: authority.runtimeDir, + }), + ); + mocks.recoverHermes.mockReturnValue({ kind: "already-running" }); + mocks.stopHermes.mockReturnValue({ kind: "stopped" }); + mocks.isLifecycleLockHeld.mockReturnValue(true); + mocks.buildOpenShellCommandAuthority.mockReturnValue({ + env: { HOME: "/home/test" }, + executablePath: "/usr/bin/openshell", + }); + mocks.readRegistry.mockReturnValue(null); + }); + + it.each([ + [{ kind: "none" }, { kind: "absent" }], + [{ kind: "openclaw" }, { kind: "openclaw" }], + [hermes("pending"), hermesDisposition("pending")], + [hermes("configuring"), hermesDisposition("configuring")], + [hermes("active"), hermesDisposition("active")], + ])("strictly classifies receipt authority %# (#9203)", (authority, expected) => { + mocks.inspect.mockReturnValue(authority); + expect(inspectPortableAgentReceiptDisposition("alpha")).toEqual(expected); + }); + + it.each(["configuring", "active"] as const)( + "returns the matching schema-5 %s receipt and registry authority (#9203)", + (phase) => { + mocks.inspect.mockReturnValue(hermes(phase)); + const entry = hermesRegistryEntry(); + mocks.readRegistry.mockReturnValue(entry); + + expect(qualifyPortableAgentLifecycleAuthority("alpha", lifecycleAuthorityDeps)).toEqual({ + ...hermesDisposition(phase), + entry, + }); + }, + ); + + it("permits an incomplete receipt without a registry row and rejects active absence (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("pending")); + expect(qualifyPortableAgentLifecycleAuthority("alpha", lifecycleAuthorityDeps)).toEqual({ + ...hermesDisposition("pending"), + entry: null, + }); + + mocks.inspect.mockReturnValue(hermes("active")); + expect(() => qualifyPortableAgentLifecycleAuthority("alpha", lifecycleAuthorityDeps)).toThrow( + "active receipt is missing its registry authority", + ); + }); + + it.each([ + { name: "other-sandbox" }, + { agent: "openclaw" }, + { openshellDriver: "kubernetes" }, + { gatewayName: "other-gateway" }, + { lifecycleGeneration: "generation-2" }, + { lifecycleLiveIdentityFingerprint: "other-fingerprint" }, + ])("rejects schema-5 receipt and registry disagreement %# (#9203)", (overrides) => { + mocks.inspect.mockReturnValue(hermes("active")); + mocks.readRegistry.mockReturnValue(hermesRegistryEntry(overrides)); + + expect(() => qualifyPortableAgentLifecycleAuthority("alpha", lifecycleAuthorityDeps)).toThrow( + "receipt and registry authority disagree", + ); + }); + + it("rejects a registry row while the schema-5 receipt is pending (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("pending")); + mocks.readRegistry.mockReturnValue(hermesRegistryEntry()); + + expect(() => qualifyPortableAgentLifecycleAuthority("alpha", lifecycleAuthorityDeps)).toThrow( + "pending receipt conflicts with an existing registry entry", + ); + }); + + it("requalifies the exact active receipt and registry authority (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("active")); + const entry = hermesRegistryEntry(); + mocks.readRegistry.mockReturnValue(entry); + const expected = requireHermesPortableActiveLifecycleAuthority( + "alpha", + undefined, + lifecycleAuthorityDeps, + ); + + expect( + requireHermesPortableActiveLifecycleAuthority("alpha", expected, lifecycleAuthorityDeps) + .entry, + ).toBe(entry); + + mocks.inspect.mockReturnValue({ + ...hermes("active"), + snapshot: { + receipt: { + ...hermes("active").snapshot.receipt, + lifecycleGeneration: "generation-2", + }, + }, + }); + mocks.readRegistry.mockReturnValue( + hermesRegistryEntry({ lifecycleGeneration: "generation-2" }), + ); + expect(() => + requireHermesPortableActiveLifecycleAuthority("alpha", expected, lifecycleAuthorityDeps), + ).toThrow("changed during verification"); + }); + + it("binds schema-5 command children to the receipt runtime namespace (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("active")); + expect( + buildHermesPortableCommandEnvironment("alpha", { + HOME: "/home/test", + PATH: "/usr/bin", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + XDG_CACHE_HOME: "/tmp/ambient-cache", + }), + ).toEqual({ + HOME: "/home/test", + PATH: "/usr/bin", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + }); + }); + + it.each(["configuring", "active"] as const)( + "builds exact OpenShell command authority for the locked %s phase (#9203)", + (phase) => { + const receipt = hermes(phase); + mocks.inspect.mockReturnValue(receipt); + + expect( + buildHermesPortableCommandAuthority("alpha", { HOME: "/home/test" }, "/state"), + ).toEqual({ + env: { HOME: "/home/test" }, + executablePath: "/usr/bin/openshell", + }); + expect(mocks.buildOpenShellCommandAuthority).toHaveBeenCalledWith(receipt.snapshot.receipt, { + HOME: "/home/test", + }); + }, + ); + + it("rejects command authority before the lifecycle lock or configuring phase (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("pending")); + expect(() => + buildHermesPortableCommandAuthority("alpha", { HOME: "/home/test" }, "/state"), + ).toThrow("missing or incomplete"); + + mocks.inspect.mockReturnValue(hermes("configuring")); + mocks.isLifecycleLockHeld.mockReturnValue(false); + expect(() => + buildHermesPortableCommandAuthority("alpha", { HOME: "/home/test" }, "/state"), + ).toThrow("requires the sandbox lifecycle lock"); + expect(mocks.buildOpenShellCommandAuthority).not.toHaveBeenCalled(); + }); + + it.each(["pending", "configuring"] as const)( + "builds exact onboarding-only command authority for the locked %s phase (#9203)", + (phase) => { + const receipt = hermes(phase); + mocks.inspect.mockReturnValue(receipt); + + expect( + buildHermesPortableOnboardingCommandAuthority( + "alpha", + "nemoclaw", + "generation-1", + { HOME: "/home/test" }, + "/state", + ), + ).toEqual({ env: { HOME: "/home/test" }, executablePath: "/usr/bin/openshell" }); + expect(mocks.buildOpenShellCommandAuthority).toHaveBeenCalledWith(receipt.snapshot.receipt, { + HOME: "/home/test", + }); + }, + ); + + it("rejects active or mismatched onboarding command authority (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("active")); + expect(() => + buildHermesPortableOnboardingCommandAuthority( + "alpha", + "nemoclaw", + "generation-1", + {}, + "/state", + ), + ).toThrow("missing or disagrees"); + + mocks.inspect.mockReturnValue(hermes("pending")); + expect(() => + buildHermesPortableOnboardingCommandAuthority( + "alpha", + "other-gateway", + "generation-1", + {}, + "/state", + ), + ).toThrow("missing or disagrees"); + expect(mocks.buildOpenShellCommandAuthority).not.toHaveBeenCalled(); + }); + + it("routes active Hermes recovery without OpenClaw or Docker fallthrough (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("active")); + + expect(recoverPortableAgentSandboxLifecycle("alpha", context)).toEqual({ + kind: "already-running", + }); + expect(mocks.recoverHermes).toHaveBeenCalledOnce(); + expect(mocks.recoverOpenClaw).not.toHaveBeenCalled(); + }); + + it("delegates active Hermes authority to the exact lifecycle qualifier (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("active")); + + expect(() => + assertHermesPortableAgentLifecycleAuthority("alpha", context, { + stateDir: "/state", + }), + ).not.toThrow(); + + expect(mocks.assertHermesAuthority).toHaveBeenCalledWith( + "alpha", + context, + expect.objectContaining({ stateDir: "/state" }), + ); + }); + + it.each([ + [{ kind: "none" }, context, "missing or incomplete"], + [hermes("configuring"), context, "missing or incomplete"], + [hermes("active"), { ...context, agent: "openclaw" }, "does not match registry agent"], + ] as const)( + "rejects invalid Hermes command authority %# (#9203)", + (authority, authorityContext, message) => { + mocks.inspect.mockReturnValue(authority); + + expect(() => + assertHermesPortableAgentLifecycleAuthority("alpha", authorityContext, { + stateDir: "/state", + }), + ).toThrow(message); + expect(mocks.assertHermesAuthority).not.toHaveBeenCalled(); + }, + ); + + it.each(["pending", "configuring"] as const)( + "rejects incomplete Hermes phase %s before recovery (#9203)", + (phase) => { + mocks.inspect.mockReturnValue(hermes(phase)); + expect(() => recoverPortableAgentSandboxLifecycle("alpha", context)).toThrow( + `phase '${phase}' is incomplete`, + ); + expect(mocks.recoverHermes).not.toHaveBeenCalled(); + expect(mocks.recoverOpenClaw).not.toHaveBeenCalled(); + }, + ); + + it("stops active Hermes without invoking the Docker-capable channel callback (#9203)", () => { + mocks.inspect.mockReturnValue(hermes("active")); + const beforeStop = vi.fn(); + + expect(stopPortableAgentSandboxLifecycle("alpha", context, beforeStop)).toEqual({ + kind: "stopped", + portableAgent: "hermes", + }); + expect(mocks.stopHermes).toHaveBeenCalledOnce(); + const hermesBeforeStop = mocks.stopHermes.mock.calls[0]?.[2] as () => void; + hermesBeforeStop(); + expect(beforeStop).not.toHaveBeenCalled(); + expect(mocks.stopOpenClaw).not.toHaveBeenCalled(); + }); + + it("preserves schema-4 OpenClaw dispatch and its stop callback (#9203)", () => { + mocks.inspect.mockReturnValue({ kind: "openclaw" }); + mocks.stopOpenClaw.mockReturnValue({ kind: "stopped" }); + const beforeStop = vi.fn(); + + stopPortableAgentSandboxLifecycle("alpha", { ...context, agent: "openclaw" }, beforeStop); + expect(mocks.stopOpenClaw).toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ agent: "openclaw" }), + beforeStop, + expect.any(Object), + ); + expect(mocks.stopHermes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/experimental/portable-agent-lifecycle.ts b/src/lib/onboard/experimental/portable-agent-lifecycle.ts new file mode 100644 index 00000000000..75cf31a1aa7 --- /dev/null +++ b/src/lib/onboard/experimental/portable-agent-lifecycle.ts @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { isMcpLifecycleLockHeld } from "../../state/mcp-lifecycle-lock-acquisition"; +import type { SandboxEntry } from "../../state/registry/types"; +import { + assertHermesPortableSandboxLifecycleAuthority, + buildHermesPortableOpenShellCommandAuthority, + buildHermesPortableOpenShellEnv, + recoverHermesPortableSandboxLifecycle, + stopHermesPortableSandboxLifecycle, + type HermesPortableLifecycleDeps, +} from "./hermes-portable-lifecycle"; +import { inspectPortableAgentReceiptAuthority } from "./hermes-portable-receipt"; +import { + recoverPortableDemoSandboxLifecycle, + stopPortableDemoSandboxLifecycle, + type PortableDemoLifecycleContext, + type PortableDemoLifecycleDeps, + type PortableDemoLifecycleRecoveryResult, + type PortableDemoLifecycleStopResult, +} from "./portable-demo-lifecycle"; +import { defaultPortableDemoStateDir } from "./portable-runtime-receipt-readiness"; + +export type PortableAgentLifecycleDeps = PortableDemoLifecycleDeps & HermesPortableLifecycleDeps; +export type PortableAgentLifecycleStopResult = PortableDemoLifecycleStopResult & { + readonly portableAgent?: "hermes"; +}; + +export const HERMES_PORTABLE_UNSUPPORTED_COMMAND_MESSAGE = + "This command is not supported for an experimental Hermes portable sandbox."; +export const HERMES_PORTABLE_UNSUPPORTED_DOCTOR_FIX_MESSAGE = + "The --fix option is not supported for an experimental Hermes portable sandbox."; + +const HERMES_PORTABLE_COMMANDS = new Set([ + "launch", + "sandbox:connect", + "sandbox:doctor", + "sandbox:recover", + "sandbox:start", + "sandbox:status", + "sandbox:stop", +]); + +const RAW_SANDBOX_NAME_COMMANDS = new Set([ + "sandbox:agent", + "sandbox:agents", + "sandbox:agents:add", + "sandbox:agents:apply", + "sandbox:agents:delete", + "sandbox:agents:list", + "sandbox:mcp", + "sandbox:sessions", + "sandbox:sessions:list", + "sandbox:skill", +]); + +const MULTI_SANDBOX_LIFECYCLE_COMMANDS = new Set(["sandbox:snapshot:restore"]); + +const HERMES_PORTABLE_UNSUPPORTED_HOST_EFFECTS = new Set([ + "debug", + "inference:get", + "list", + "stop", + "tunnel:start", + "tunnel:stop", + "upgrade-sandboxes", + "use", +]); + +const HERMES_PORTABLE_HOST_FENCED_READS = new Set(["status"]); + +export type HermesPortableCommandPolicy = { + readonly helpRequested: boolean; + readonly hostFence: "read" | "deny" | null; + readonly multiSandboxLifecycle: boolean; + readonly rawSandboxName: boolean; +}; + +/** Classify one CLI invocation without treating payload arguments as host help. */ +export function classifyHermesPortableCommand( + commandId: string, + argv: readonly string[], +): HermesPortableCommandPolicy { + const separator = argv.indexOf("--"); + const hostArgv = separator === -1 ? argv : argv.slice(0, separator); + return { + helpRequested: hostArgv.includes("--help") || hostArgv.includes("-h"), + hostFence: HERMES_PORTABLE_HOST_FENCED_READS.has(commandId) + ? "read" + : HERMES_PORTABLE_UNSUPPORTED_HOST_EFFECTS.has(commandId) + ? "deny" + : null, + multiSandboxLifecycle: MULTI_SANDBOX_LIFECYCLE_COMMANDS.has(commandId), + rawSandboxName: RAW_SANDBOX_NAME_COMMANDS.has(commandId), + }; +} + +/** Reject one unsupported command while schema-5 receipt authority exists. */ +export function assertHermesPortableCommandSupported( + commandId: string, + sandboxName: string, + argv: readonly string[], +): void { + const authority = inspectPortableAgentReceiptAuthority( + sandboxName, + defaultPortableDemoStateDir(process.env), + ); + const separator = argv.indexOf("--"); + const hostArgv = separator === -1 ? argv : argv.slice(0, separator); + const doctorFix = commandId === "sandbox:doctor" && hostArgv.includes("--fix"); + const supported = HERMES_PORTABLE_COMMANDS.has(commandId) && !doctorFix; + if (authority.kind !== "hermes" || supported) return; + if (doctorFix) { + throw new Error(`${HERMES_PORTABLE_UNSUPPORTED_DOCTOR_FIX_MESSAGE} Command: ${commandId}`); + } + throw new Error(`${HERMES_PORTABLE_UNSUPPORTED_COMMAND_MESSAGE} Command: ${commandId}`); +} + +export type PortableAgentReceiptDisposition = + | { readonly kind: "absent" } + | { readonly kind: "openclaw" } + | { + readonly kind: "hermes"; + readonly phase: "pending" | "configuring" | "active"; + readonly gatewayName: string; + readonly lifecycleGeneration: string; + readonly liveIdentityFingerprint: string | null; + }; + +export type HermesPortableAgentLifecycleAuthority = Extract< + PortableAgentReceiptDisposition, + { readonly kind: "hermes" } +> & { + readonly entry: SandboxEntry | null; +}; + +export type PortableAgentLifecycleAuthority = + | Exclude + | HermesPortableAgentLifecycleAuthority; + +export type HermesPortableActiveLifecycleAuthority = Omit< + HermesPortableAgentLifecycleAuthority, + "entry" | "phase" +> & { + readonly phase: "active"; + readonly entry: SandboxEntry; +}; + +export interface PortableAgentLifecycleAuthorityDeps { + readonly env?: NodeJS.ProcessEnv; + readonly stateDir?: string; + readonly inspectReceiptDisposition?: (sandboxName: string) => PortableAgentReceiptDisposition; + readonly readRegistry: (sandboxName: string) => SandboxEntry | null; +} +/** Strictly distinguish absent, schema-4 OpenClaw, and schema-5 Hermes authority. */ +export function inspectPortableAgentReceiptDisposition( + sandboxName: string, + env: NodeJS.ProcessEnv = process.env, + stateDir = defaultPortableDemoStateDir(env), +): PortableAgentReceiptDisposition { + const authority = inspectPortableAgentReceiptAuthority(sandboxName, stateDir); + if (authority.kind === "none") return { kind: "absent" }; + if (authority.kind === "openclaw") return { kind: "openclaw" }; + const { receipt } = authority.snapshot; + return { + kind: "hermes", + phase: receipt.phase, + gatewayName: receipt.gatewayName, + lifecycleGeneration: receipt.lifecycleGeneration, + liveIdentityFingerprint: + receipt.phase === "pending" + ? null + : createHash("sha256").update(receipt.container.sandboxId).digest("hex"), + }; +} + +/** Classify receipt authority and enforce the shared schema-5 registry invariant. */ +export function qualifyPortableAgentLifecycleAuthority( + sandboxName: string, + deps: PortableAgentLifecycleAuthorityDeps, +): PortableAgentLifecycleAuthority { + const disposition = deps.inspectReceiptDisposition + ? deps.inspectReceiptDisposition(sandboxName) + : inspectPortableAgentReceiptDisposition(sandboxName, deps.env ?? process.env, deps.stateDir); + if (disposition.kind !== "hermes") return disposition; + + if (!deps.readRegistry) { + throw new Error("Hermes portable registry authority reader is required."); + } + const entry = deps.readRegistry(sandboxName); + if (!entry) { + if (disposition.phase !== "active") return { ...disposition, entry: null }; + throw new Error("Hermes portable active receipt is missing its registry authority."); + } + if ( + entry.name !== sandboxName || + entry.agent !== "hermes" || + entry.openshellDriver !== "docker" || + entry.gatewayName !== disposition.gatewayName || + entry.lifecycleGeneration !== disposition.lifecycleGeneration || + (disposition.phase !== "pending" && + entry.lifecycleLiveIdentityFingerprint !== disposition.liveIdentityFingerprint) + ) { + throw new Error("Hermes portable receipt and registry authority disagree."); + } + if (disposition.phase === "pending") { + throw new Error("Hermes portable pending receipt conflicts with an existing registry entry."); + } + return { ...disposition, entry }; +} + +/** Require an active schema-5 receipt and its exact registry authority. */ +export function requireHermesPortableActiveLifecycleAuthority( + sandboxName: string, + expected: HermesPortableActiveLifecycleAuthority | undefined, + deps: PortableAgentLifecycleAuthorityDeps, +): HermesPortableActiveLifecycleAuthority { + const current = qualifyPortableAgentLifecycleAuthority(sandboxName, deps); + if (current.kind !== "hermes" || current.phase !== "active" || !current.entry) { + throw new Error("Hermes portable lifecycle authority is missing or incomplete."); + } + if ( + expected && + (current.gatewayName !== expected.gatewayName || + current.lifecycleGeneration !== expected.lifecycleGeneration || + current.liveIdentityFingerprint !== expected.liveIdentityFingerprint) + ) { + throw new Error("Hermes portable lifecycle authority changed during verification."); + } + return current as HermesPortableActiveLifecycleAuthority; +} + +/** Build a child environment from the exact active schema-5 runtime authority. */ +export function buildHermesPortableCommandEnvironment( + sandboxName: string, + env: NodeJS.ProcessEnv = process.env, + stateDir = defaultPortableDemoStateDir(env), +): NodeJS.ProcessEnv { + const authority = inspectPortableAgentReceiptAuthority(sandboxName, stateDir); + if (authority.kind !== "hermes" || authority.snapshot.receipt.phase !== "active") { + throw new Error("Hermes portable lifecycle authority is missing or incomplete"); + } + return buildHermesPortableOpenShellEnv(env, authority.snapshot.receipt.runtimeAuthority); +} + +/** Requalify the exact executable and environment for one direct schema-5 child. */ +export function buildHermesPortableCommandAuthority( + sandboxName: string, + env: NodeJS.ProcessEnv = process.env, + stateDir = defaultPortableDemoStateDir(env), +) { + if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { + throw new Error("Hermes portable command authority requires the sandbox lifecycle lock"); + } + const authority = inspectPortableAgentReceiptAuthority(sandboxName, stateDir); + if (authority.kind !== "hermes" || authority.snapshot.receipt.phase === "pending") { + throw new Error("Hermes portable lifecycle authority is missing or incomplete"); + } + return buildHermesPortableOpenShellCommandAuthority(authority.snapshot.receipt, env); +} + +/** Requalify a pending/configuring receipt only for its schema-5 onboarding child. */ +export function buildHermesPortableOnboardingCommandAuthority( + sandboxName: string, + gatewayName: string, + lifecycleGeneration: string, + env: NodeJS.ProcessEnv = process.env, + stateDir = defaultPortableDemoStateDir(env), +) { + if (!isMcpLifecycleLockHeld(sandboxName, path.join(stateDir, "state"))) { + throw new Error("Hermes portable onboarding command authority requires the lifecycle lock"); + } + const authority = inspectPortableAgentReceiptAuthority(sandboxName, stateDir); + const receipt = authority.kind === "hermes" ? authority.snapshot.receipt : null; + if ( + !receipt || + receipt.phase === "active" || + receipt.gatewayName !== gatewayName || + receipt.lifecycleGeneration !== lifecycleGeneration + ) { + throw new Error("Hermes portable onboarding command authority is missing or disagrees"); + } + return buildHermesPortableOpenShellCommandAuthority(receipt, env); +} + +/** Whether recognized portable authority must bypass Docker preflight. */ +export function hasPortableAgentSandboxLifecycleReceipt( + sandboxName: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return inspectPortableAgentReceiptDisposition(sandboxName, env).kind !== "absent"; +} + +/** Reject an unimplemented command from inside its existing sandbox lifecycle fence. */ +export function assertHermesPortableCommandUnavailable( + sandboxName: string, + commandId: string, + env: NodeJS.ProcessEnv = process.env, +): void { + if (inspectPortableAgentReceiptDisposition(sandboxName, env).kind !== "hermes") return; + throw new Error(`${HERMES_PORTABLE_UNSUPPORTED_COMMAND_MESSAGE} Command: ${commandId}`); +} + +function requireMatchingAgent( + disposition: Exclude, + context: PortableDemoLifecycleContext, +): void { + const registryAgent = context.agent ?? "openclaw"; + if (disposition.kind !== registryAgent) { + throw new Error( + `Portable lifecycle receipt agent '${disposition.kind}' does not match registry agent '${registryAgent}'`, + ); + } +} + +/** Route one portable start/recovery without permitting a Docker fallback. */ +export function recoverPortableAgentSandboxLifecycle( + sandboxName: string, + context: PortableDemoLifecycleContext, + deps: PortableAgentLifecycleDeps = {}, +): PortableDemoLifecycleRecoveryResult { + const disposition = inspectPortableAgentReceiptDisposition( + sandboxName, + deps.env ?? process.env, + deps.stateDir, + ); + if (disposition.kind === "absent") return { kind: "not-installed" }; + requireMatchingAgent(disposition, context); + if (disposition.kind === "openclaw") { + return recoverPortableDemoSandboxLifecycle(sandboxName, context, deps); + } + if (disposition.phase !== "active") { + throw new Error( + `Hermes portable lifecycle receipt phase '${disposition.phase}' is incomplete; resume onboarding before running lifecycle commands`, + ); + } + return recoverHermesPortableSandboxLifecycle(sandboxName, context, deps); +} + +/** Requalify schema-5 authority without permitting lifecycle recovery or fallback. */ +export function assertHermesPortableAgentLifecycleAuthority( + sandboxName: string, + context: PortableDemoLifecycleContext, + deps: PortableAgentLifecycleDeps = {}, +): void { + const disposition = inspectPortableAgentReceiptDisposition( + sandboxName, + deps.env ?? process.env, + deps.stateDir, + ); + if (disposition.kind !== "hermes" || disposition.phase !== "active") { + throw new Error("Hermes portable lifecycle authority is missing or incomplete"); + } + requireMatchingAgent(disposition, context); + assertHermesPortableSandboxLifecycleAuthority(sandboxName, context, deps); +} + +/** Route one portable stop without permitting a Docker fallback. */ +export function stopPortableAgentSandboxLifecycle( + sandboxName: string, + context: PortableDemoLifecycleContext, + beforeStop: () => void, + deps: PortableAgentLifecycleDeps = {}, +): PortableAgentLifecycleStopResult { + const disposition = inspectPortableAgentReceiptDisposition( + sandboxName, + deps.env ?? process.env, + deps.stateDir, + ); + if (disposition.kind === "absent") return { kind: "not-installed" }; + requireMatchingAgent(disposition, context); + if (disposition.kind === "openclaw") { + return stopPortableDemoSandboxLifecycle(sandboxName, context, beforeStop, deps); + } + if (disposition.phase !== "active") { + throw new Error( + `Hermes portable lifecycle receipt phase '${disposition.phase}' is incomplete; resume onboarding before running lifecycle commands`, + ); + } + // Schema-5 owns only the exact Podman container. The Docker provider's + // channel hook can select Docker transport, so it is never part of Hermes + // portable stop authority. + return { + ...stopHermesPortableSandboxLifecycle(sandboxName, context, () => undefined, deps), + portableAgent: "hermes", + }; +} diff --git a/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts b/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts index f066204e568..245daf565b9 100644 --- a/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts +++ b/src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts @@ -11,6 +11,7 @@ import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; import { hardenPodmanSocketDirectory, type PodmanSocketAuthorityDeps } from "../../adapters/podman"; import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types"; import { parsePortableRuntimeAuthority } from "../../state/onboard/portable-runtime-authority"; +import { defaultPortableStateDir } from "../../state/portable-uninstall-retirement"; import { inspectPortablePodmanReadiness, portablePodmanCommandEnvironment, @@ -76,15 +77,7 @@ export function portableDemoReceiptDirectory(stateDir: string): string { } export function defaultPortableDemoStateDir(env: NodeJS.ProcessEnv): string { - if ( - env.VITEST === "true" && - (env.HOME ?? "") === env.NEMOCLAW_TEST_BASE_HOME && - env.NEMOCLAW_TEST_STATE_DIR && - path.isAbsolute(env.NEMOCLAW_TEST_STATE_DIR) - ) { - return env.NEMOCLAW_TEST_STATE_DIR; - } - return path.join(env.HOME ?? os.homedir(), ".nemoclaw"); + return defaultPortableStateDir(env); } function exactReceiptKeys(receipt: Record): boolean { diff --git a/src/lib/onboard/gateway-gpu-passthrough.test.ts b/src/lib/onboard/gateway-gpu-passthrough.test.ts index 87a51b40a2f..a4271da1bea 100644 --- a/src/lib/onboard/gateway-gpu-passthrough.test.ts +++ b/src/lib/onboard/gateway-gpu-passthrough.test.ts @@ -4,7 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../adapters/docker", () => ({ + dockerCapture: vi.fn(), + dockerForceRm: vi.fn(), dockerInspect: vi.fn(), + dockerRunDetached: vi.fn(), })); vi.mock("../state/registry", () => ({ diff --git a/src/lib/onboard/hermes-api-port.test.ts b/src/lib/onboard/hermes-api-port.test.ts index 74592f060f1..e6251e3cfcf 100644 --- a/src/lib/onboard/hermes-api-port.test.ts +++ b/src/lib/onboard/hermes-api-port.test.ts @@ -49,7 +49,7 @@ describe("Hermes API and dashboard port creation scopes", () => { const entryPoints = createHermesApiPortScopedSandboxEntryPoints({ createBaseImageResolutionContext: () => ({ fresh: false }), createSandboxWithBaseImageResolution, - resolvePortableRuntimeAuthority: () => ({ socketPath: "/run/user/1001/podman.sock" }), + resolvePortableRuntimeContext: () => ({ socketPath: "/run/user/1001/podman.sock" }), resolveComputePlan: () => ({ sequence: ++sequence }), }); diff --git a/src/lib/onboard/hermes-api-port.ts b/src/lib/onboard/hermes-api-port.ts index e461eef43e5..fd6b23455aa 100644 --- a/src/lib/onboard/hermes-api-port.ts +++ b/src/lib/onboard/hermes-api-port.ts @@ -72,13 +72,13 @@ interface HermesApiPortScopedSandboxEntryPointDeps< Args extends unknown[], Result, BaseImageResolutionContext, - PortableRuntimeAuthority, + PortableRuntimeContext, ComputePlan, > { createBaseImageResolutionContext(): BaseImageResolutionContext; createSandboxWithBaseImageResolution( baseImageResolutionContext: BaseImageResolutionContext, - portableRuntimeAuthority: PortableRuntimeAuthority, + portableRuntimeContext: PortableRuntimeContext, computePlan: ComputePlan, managedWorkloadRebuild: null, temporaryManagedRuntime: boolean, @@ -87,7 +87,7 @@ interface HermesApiPortScopedSandboxEntryPointDeps< hermesApiPortReservationScope: HermesApiPortReservationScope, ...args: Args ): Promise; - resolvePortableRuntimeAuthority(): PortableRuntimeAuthority; + resolvePortableRuntimeContext(): PortableRuntimeContext; resolveComputePlan(): ComputePlan; } @@ -96,14 +96,14 @@ export function createHermesApiPortScopedSandboxEntryPoints< Args extends unknown[], Result, BaseImageResolutionContext, - PortableRuntimeAuthority, + PortableRuntimeContext, ComputePlan, >( deps: HermesApiPortScopedSandboxEntryPointDeps< Args, Result, BaseImageResolutionContext, - PortableRuntimeAuthority, + PortableRuntimeContext, ComputePlan >, ): { @@ -114,7 +114,7 @@ export function createHermesApiPortScopedSandboxEntryPoints< createBaseImageResolutionContext: deps.createBaseImageResolutionContext, createSandboxWithBaseImageResolution: ( baseImageResolutionContext, - portableRuntimeAuthority, + portableRuntimeContext, computePlan, managedWorkloadRebuild, temporaryManagedRuntime, @@ -125,7 +125,7 @@ export function createHermesApiPortScopedSandboxEntryPoints< withHermesApiPortReservationScope((hermesApiPortReservationScope) => deps.createSandboxWithBaseImageResolution( baseImageResolutionContext, - portableRuntimeAuthority, + portableRuntimeContext, computePlan, managedWorkloadRebuild, temporaryManagedRuntime, @@ -136,7 +136,7 @@ export function createHermesApiPortScopedSandboxEntryPoints< ), ), resolveComputePlan: deps.resolveComputePlan, - resolvePortableRuntimeAuthority: deps.resolvePortableRuntimeAuthority, + resolvePortableRuntimeContext: deps.resolvePortableRuntimeContext, }); } diff --git a/src/lib/onboard/hermes-portable-dockerfile-settings.test.ts b/src/lib/onboard/hermes-portable-dockerfile-settings.test.ts new file mode 100644 index 00000000000..3b4396c9f40 --- /dev/null +++ b/src/lib/onboard/hermes-portable-dockerfile-settings.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { renderHermesPortableDockerfileBuildSettings } from "./dockerfile-patch"; + +const DOCKERFILE = [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_INFERENCE_PROVIDER_ID=old", + "ARG NEMOCLAW_UPSTREAM_PROVIDER=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_TOOL_DISCLOSURE=progressive", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "", +].join("\n"); + +const SETTINGS = { + model: "qwen3-vl:4b", + provider: "ollama-local", + preferredInferenceApi: "openai-completions", + toolDisclosure: "direct", +} as const; + +describe("Hermes portable Dockerfile settings", () => { + it("renders schema-5 settings through the shared inference owner (#9203)", () => { + const rendered = renderHermesPortableDockerfileBuildSettings(DOCKERFILE, SETTINGS); + + expect(rendered).toContain("ARG NEMOCLAW_MODEL=qwen3-vl:4b"); + expect(rendered).toContain("ARG NEMOCLAW_INFERENCE_PROVIDER_ID=inference"); + expect(rendered).toContain("ARG NEMOCLAW_UPSTREAM_PROVIDER=ollama-local"); + expect(rendered).toContain("ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1"); + expect(rendered).toContain("ARG NEMOCLAW_INFERENCE_API=openai-completions"); + expect(rendered).toContain("ARG NEMOCLAW_TOOL_DISCLOSURE=direct"); + expect(rendered).toContain("ARG CHAT_UI_URL=\n"); + }); + + it("rejects injected or incomplete schema-5 settings (#9203)", () => { + expect(() => + renderHermesPortableDockerfileBuildSettings(DOCKERFILE, { + ...SETTINGS, + model: "qwen3-vl:4b\nRUN false", + }), + ).toThrow("model build setting is invalid"); + expect(() => + renderHermesPortableDockerfileBuildSettings( + DOCKERFILE.replace("ARG CHAT_UI_URL=http://127.0.0.1:18789\n", ""), + SETTINGS, + ), + ).toThrow("must declare exactly one CHAT_UI_URL"); + }); +}); diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index a1e0409fcc3..59d73c840f5 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; +const logPresetScopeMock = vi.hoisted(() => vi.fn()); vi.mock("../policy", () => ({ mergePresetNamesIntoPolicy: (policy: string, presetNames: string[]) => ({ policy: `${policy.trimEnd()}\n${presetNames @@ -16,15 +17,18 @@ vi.mock("../policy", () => ({ appliedPresets: presetNames, missingPresets: [], }), + logPresetScope: logPresetScopeMock, })); import { buildDirectGpuPolicyYaml, buildDirectSandboxGpuProofCommands, + discloseInitialSandboxPolicy, discoverHostStationGb300SysfsReadOnlyPaths, discoverStationGb300SysfsReadOnlyPaths, getNetworkPolicyNames, isStationGb300ProductName, + planHermesPortableInitialSandboxPolicy, prepareInitialSandboxCreatePolicy, } from "./initial-policy"; @@ -65,6 +69,7 @@ const originalOtelEnv = { }; beforeEach(() => { + logPresetScopeMock.mockReset(); delete process.env.NEMOCLAW_OPENCLAW_OTEL; delete process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT; delete process.env.NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME; @@ -79,6 +84,23 @@ function tmpPolicy(content: string): string { return file; } +function tmpHostedInstallerPolicy(content: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hosted-policy-test-")); + tmpRoots.push(root); + const policies = path.join(root, "nemoclaw-blueprint", "policies"); + const presets = path.join(policies, "presets"); + fs.mkdirSync(presets, { recursive: true }); + fs.chmodSync(policies, 0o775); + fs.chmodSync(presets, 0o775); + const basePolicyPath = path.join(policies, "base.yaml"); + fs.writeFileSync(basePolicyPath, content, { mode: 0o664 }); + fs.chmodSync(basePolicyPath, 0o664); + const presetPath = path.join(presets, "personal-open-internet.yaml"); + fs.writeFileSync(presetPath, "version: 1\nnetwork_policies: {}\n", { mode: 0o664 }); + fs.chmodSync(presetPath, 0o664); + return basePolicyPath; +} + function tmpSysfsRoot(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-sysfs-test-")); tmpRoots.push(dir); @@ -104,6 +126,7 @@ function addPciDevice( } afterEach(() => { + vi.unstubAllEnvs(); for (const dir of tmpRoots.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -120,6 +143,134 @@ afterEach(() => { }); describe("initial sandbox policy helpers", () => { + it("discloses the effective in-memory policy when exact source bytes are available (#9203)", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + const effectivePolicy = Buffer.from( + "version: 1\nnetwork_policies:\n personal-open-internet: {}\n", + ); + + discloseInitialSandboxPolicy({ + policyPath: basePolicyPath, + appliedPresets: ["personal-open-internet"], + sourceBytes: effectivePolicy, + }); + + expect(logPresetScopeMock).toHaveBeenCalledWith(effectivePolicy.toString("utf8")); + }); + + it("plans schema-5 Personal policy bytes without creating a temporary file (#9203)", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + const mkdtemp = vi.spyOn(fs, "mkdtempSync"); + const writeFile = vi.spyOn(fs, "writeFileSync"); + + const planned = planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { + agentName: "hermes", + policyTier: "personal", + additionalPresets: ["personal-open-internet", "slack"], + }); + + expect(planned.policyPath).toBe(basePolicyPath); + expect(planned.appliedPresets).toEqual(["personal-open-internet", "slack"]); + expect(planned.sourceBytes?.toString("utf8")).toContain("personal-open-internet"); + expect(planned.cleanup).toBeUndefined(); + expect(planned.cleanupExact).toBeUndefined(); + expect(mkdtemp).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it("accepts the hosted installer policy source owned by the current user and group (#9203)", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const basePolicyPath = tmpHostedInstallerPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + + const planned = planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { + agentName: "hermes", + policyTier: "personal", + additionalPresets: ["personal-open-internet"], + }); + + const policies = path.dirname(basePolicyPath); + const presets = path.join(policies, "presets"); + const presetPath = path.join(presets, "personal-open-internet.yaml"); + const baseStat = fs.statSync(basePolicyPath); + const presetStat = fs.statSync(presetPath); + expect(fs.statSync(policies).mode & 0o777).toBe(0o775); + expect(fs.statSync(presets).mode & 0o777).toBe(0o775); + expect(baseStat.mode & 0o777).toBe(0o664); + expect(baseStat.uid).toBe(process.getuid?.()); + expect(baseStat.gid).toBe(process.getgid?.()); + expect(presetStat.mode & 0o777).toBe(0o664); + expect(presetStat.uid).toBe(process.getuid?.()); + expect(presetStat.gid).toBe(process.getgid?.()); + expect(planned.sourceBytes?.toString("utf8")).toContain("personal-open-internet"); + }); + + it("rejects malformed schema-5 base policy bytes before planning effects (#9203)", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync(basePolicyPath, Buffer.from([0xff, 0xfe])); + + expect(() => + planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { + agentName: "hermes", + policyTier: "personal", + additionalPresets: ["personal-open-internet"], + }), + ).toThrow("not strict UTF-8"); + }); + + it("rejects a UTF-8 byte-order mark before schema-5 policy planning (#9203)", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync( + basePolicyPath, + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from("version: 1\nnetwork_policies: {}\n"), + ]), + ); + + expect(() => + planHermesPortableInitialSandboxPolicy(basePolicyPath, [], { + agentName: "hermes", + policyTier: "personal", + additionalPresets: ["personal-open-internet"], + }), + ).toThrow("must not include a UTF-8 byte-order mark"); + }); + + it("rejects replaced, linked, or writable schema-5 policy authority (#9203)", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const original = tmpPolicy("version: 1\nnetwork_policies: {}\n"); + const replacement = path.join(path.dirname(original), "replacement.yaml"); + const plan = (policyPath: string) => + planHermesPortableInitialSandboxPolicy(policyPath, [], { + agentName: "hermes", + policyTier: "personal", + additionalPresets: ["personal-open-internet"], + }); + + fs.writeFileSync(replacement, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); + fs.unlinkSync(original); + fs.symlinkSync(replacement, original); + expect(() => plan(original)).toThrow("source authority is unsafe"); + + fs.unlinkSync(original); + fs.linkSync(replacement, original); + expect(() => plan(original)).toThrow("source authority is unsafe"); + + fs.unlinkSync(original); + fs.writeFileSync(original, "version: 1\nnetwork_policies: {}\n", { mode: 0o666 }); + fs.chmodSync(original, 0o666); + expect(() => plan(original)).toThrow("source authority is unsafe"); + + fs.chmodSync(original, 0o620); + expect(() => plan(original)).toThrow("source authority is unsafe"); + + fs.chmodSync(original, 0o602); + expect(() => plan(original)).toThrow("source authority is unsafe"); + }); + it.each([ ["Dell Pro Max with Station GB300", true], ["NVIDIA DGX Station GB300", true], @@ -200,16 +351,14 @@ describe("initial sandbox policy helpers", () => { ).toThrow("an available GB300 GPU"); }); - it.each( - [ - "/sys/class", - "/sys/class/net", - "/sys/bus/pci/devices", - "/sys/firmware", - "/sys/fs", - "/sys/kernel", - ], - )( + it.each([ + "/sys/class", + "/sys/class/net", + "/sys/bus/pci/devices", + "/sys/firmware", + "/sys/fs", + "/sys/kernel", + ])( "scopes sysfs read access and lets OpenShell own /proc GPU enrichment [%s] (#7103)", (unrelatedPath) => { const gpuPolicy = buildDirectGpuPolicyYaml(BASE_POLICY_FIXTURE, { @@ -365,11 +514,11 @@ network_policies: {} expect(gpuDoc.filesystem_policy.read_only).not.toContain(writablePath); expectSingleOccurrence(gpuDoc.filesystem_policy.read_write, writablePath); - STATION_GB300_SYSFS_READ_ONLY_PATHS.filter( - (candidate) => candidate !== writablePath, - ).forEach((sysfsPath) => { - expectSingleOccurrence(gpuDoc.filesystem_policy.read_only, sysfsPath); - }); + STATION_GB300_SYSFS_READ_ONLY_PATHS.filter((candidate) => candidate !== writablePath).forEach( + (sysfsPath) => { + expectSingleOccurrence(gpuDoc.filesystem_policy.read_only, sysfsPath); + }, + ); }); it.each(Array.from(STATION_GB300_SYSFS_READ_ONLY_PATHS, (value) => [value]))( @@ -463,6 +612,18 @@ network_policies: {} commands.forEach((command) => { expect(command.args.every((arg) => !/[\r\n]/.test(arg))).toBe(true); }); + expect(buildDirectSandboxGpuProofCommands("alpha", "nemoclaw")[0]?.args).toEqual([ + "sandbox", + "exec", + "-g", + "nemoclaw", + "-n", + "alpha", + "--", + "sh", + "-lc", + expect.stringContaining("command -v nvidia-smi"), + ]); }); it("returns network policy names from a policy document", () => { @@ -530,6 +691,29 @@ network_policies: {} expect(fs.existsSync(prepared.policyPath)).toBe(false); }); + it("gives only portable Hermes an exact replacement-safe policy cleanup (#9203)", () => { + vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n discord: {}\n slack: {}\n"); + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["discord"], { + agentName: "hermes", + }); + const parent = path.dirname(prepared.policyPath); + const original = path.join(parent, "original.yaml"); + tmpRoots.push(parent); + + expect(prepared.cleanupExact).toEqual(expect.any(Function)); + fs.renameSync(prepared.policyPath, original); + fs.writeFileSync(prepared.policyPath, "replacement\n", { mode: 0o600 }); + expect(prepared.cleanupExact?.()).toBe(false); + expect(fs.readFileSync(prepared.policyPath, "utf8")).toBe("replacement\n"); + expect(fs.existsSync(original)).toBe(true); + + const ordinary = prepareInitialSandboxCreatePolicy(basePolicyPath, ["discord"], { + agentName: "openclaw", + }); + expect(ordinary).not.toHaveProperty("cleanupExact"); + }); + it("filters inactive Hermes messaging policies from the relative Hermes policy path", () => { const hermesPolicyPath = path.relative( process.cwd(), diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 4d56cecb127..da861d68322 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; +import { TextDecoder } from "node:util"; import YAML from "yaml"; import { isObjectRecord } from "../core/json-types"; @@ -29,18 +30,23 @@ import { } from "./messaging-policy-presets"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { filterSuppressedAgentRequiredPresets } from "./policy-tier-suppression"; -import { cleanupTempDir, secureTempFile } from "./temp-files"; +import { cleanupTempDir, createExactTempFileCleanup, secureTempFile } from "./temp-files"; +import { isPortableExperimentalProfile } from "./experimental/portable-profile"; export type InitialSandboxPolicy = { policyPath: string; appliedPresets: string[]; + sourceBytes?: Buffer; cleanup?: () => boolean; + cleanupExact?: () => boolean; }; export function discloseInitialSandboxPolicy(policy: InitialSandboxPolicy): void { if (policy.appliedPresets.length === 0) return; console.log(" Including policy preset(s) at sandbox boot:", policy.appliedPresets.join(", ")); - policies.logPresetScope(fs.readFileSync(policy.policyPath, "utf8")); + policies.logPresetScope( + policy.sourceBytes?.toString("utf8") ?? fs.readFileSync(policy.policyPath, "utf8"), + ); } const HERMES_MESSAGING_POLICY_KEYS = getMessagingPolicyKeysByChannel({ agent: "hermes" }); @@ -274,24 +280,26 @@ export type DirectSandboxGpuProofCommand = { export function buildDirectSandboxGpuProofCommands( sandboxName: string, + gatewayName?: string, ): DirectSandboxGpuProofCommand[] { + const exec = ["sandbox", "exec", ...(gatewayName ? ["-g", gatewayName] : []), "-n", sandboxName]; return [ { id: "nvidia-smi", label: "nvidia-smi when available", - args: ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", NVIDIA_SMI_OPTIONAL_PROBE], + args: [...exec, "--", "sh", "-lc", NVIDIA_SMI_OPTIONAL_PROBE], }, { id: "proc-comm-write", label: "/proc//task//comm write", optional: true, - args: ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", PROC_COMM_WRITE_PROBE], + args: [...exec, "--", "sh", "-lc", PROC_COMM_WRITE_PROBE], }, { id: "cuda-init", label: "cuInit(0) via libcuda.so.1", optional: true, - args: ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", CUDA_INIT_PROBE], + args: [...exec, "--", "sh", "-lc", CUDA_INIT_PROBE], }, ]; } @@ -307,26 +315,35 @@ function createPolicyTempCleanup(policyPath: string, expectedPrefix: string): () }; } -function prepareDirectGpuSandboxPolicy( - basePolicyPath: string, - options: DirectGpuPolicyOptions = {}, -): InitialSandboxPolicy { - const basePolicy = fs.readFileSync(basePolicyPath, "utf-8"); - const policyPath = secureTempFile("nemoclaw-gpu-policy", ".yaml"); - const cleanup = createPolicyTempCleanup(policyPath, "nemoclaw-gpu-policy"); - try { - fs.writeFileSync(policyPath, buildDirectGpuPolicyYaml(basePolicy, options), { - encoding: "utf-8", - mode: 0o600, - }); - } catch (error) { - cleanup(); - throw error; - } - return { - policyPath, - appliedPresets: [], - cleanup, +type InitialPolicyOptions = { + directGpu?: boolean; + dockerGpuPatch?: boolean; + hostGpuAvailable?: boolean; + stationGb300SysfsReadOnlyPaths?: readonly string[]; + additionalPresets?: string[]; + agentName?: string | null; + policyTier?: string | null; + baselineExclusions?: readonly BaselineExclusionRequest[]; +}; + +type PolicyMaterializer = (content: string, prefix: string) => InitialSandboxPolicy; + +function createTempPolicyMaterializer(exactCleanup: boolean): PolicyMaterializer { + return (content, prefix) => { + const policyPath = secureTempFile(prefix, ".yaml"); + const cleanup = createPolicyTempCleanup(policyPath, prefix); + try { + fs.writeFileSync(policyPath, content, { encoding: "utf-8", mode: 0o600 }); + } catch (error) { + cleanup(); + throw error; + } + return { + policyPath, + appliedPresets: [], + cleanup, + ...(exactCleanup ? { cleanupExact: createExactTempFileCleanup(policyPath, prefix) } : {}), + }; }; } @@ -375,34 +392,63 @@ function isHermesPolicyPath(policyPath: string): boolean { return /(^|\/)agents\/hermes\/policy-additions\.yaml$/.test(normalized); } -export function prepareInitialSandboxCreatePolicy( +function resolveInitialSandboxCreatePolicy( basePolicyPath: string, activeMessagingChannels: string[], - options: { - directGpu?: boolean; - dockerGpuPatch?: boolean; - hostGpuAvailable?: boolean; - stationGb300SysfsReadOnlyPaths?: readonly string[]; - additionalPresets?: string[]; - agentName?: string | null; - policyTier?: string | null; - baselineExclusions?: readonly BaselineExclusionRequest[]; - } = {}, + options: InitialPolicyOptions, + resolution: { + readonly materialize: PolicyMaterializer; + readonly exactCleanup: boolean; + readonly includeSourceBytes: boolean; + readonly initialContent?: string; + }, ): InitialSandboxPolicy { - const directGpuPolicy = options.directGpu - ? prepareDirectGpuSandboxPolicy(basePolicyPath, { + const { materialize, exactCleanup, includeSourceBytes, initialContent } = resolution; + let basePolicy = initialContent ?? fs.readFileSync(basePolicyPath, "utf-8"); + let effectivePolicy: InitialSandboxPolicy = { + policyPath: basePolicyPath, + appliedPresets: [], + ...(includeSourceBytes ? { sourceBytes: Buffer.from(basePolicy) } : {}), + }; + const cleanupFns: Array<() => boolean> = []; + const exactCleanupFns: Array<() => boolean> = []; + const adoptPolicy = (content: string, prefix: string): void => { + const next = materialize(content, prefix); + if (next.cleanup) cleanupFns.push(next.cleanup); + if (next.cleanupExact) exactCleanupFns.push(next.cleanupExact); + effectivePolicy = next; + basePolicy = content; + }; + if (options.directGpu) { + adoptPolicy( + buildDirectGpuPolicyYaml(basePolicy, { procReadWrite: options.dockerGpuPatch === true, sysfsReadOnlyPaths: options.stationGb300SysfsReadOnlyPaths ?? discoverHostStationGb300SysfsReadOnlyPaths({ hasNvidiaGpu: options.hostGpuAvailable, }), - }) - : null; - let effectiveBasePolicyPath = directGpuPolicy?.policyPath || basePolicyPath; - const cleanupFns = directGpuPolicy?.cleanup ? [directGpuPolicy.cleanup] : []; + }), + "nemoclaw-gpu-policy", + ); + } const buildCleanup = () => cleanupFns.length > 0 ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined; + const buildExactCleanup = () => + exactCleanupFns.length > 0 + ? () => + [...exactCleanupFns] + .reverse() + .map((cleanup) => cleanup()) + .every(Boolean) + : undefined; + const exactCleanupResult = () => (exactCleanup ? { cleanupExact: buildExactCleanup() } : {}); + const result = (appliedPresets: string[]): InitialSandboxPolicy => ({ + ...effectivePolicy, + appliedPresets, + cleanup: buildCleanup(), + ...exactCleanupResult(), + }); const cleanupOnError = () => { for (const cleanup of [...cleanupFns].reverse()) { try { @@ -444,15 +490,10 @@ export function prepareInitialSandboxCreatePolicy( ); const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))]; - let basePolicy = fs.readFileSync(effectiveBasePolicyPath, "utf-8"); if (isHermesPolicy) { const filtered = filterHermesInactiveMessagingPolicies(basePolicy, activeMessagingChannels); if (filtered.changed) { - const policyPath = secureTempFile("nemoclaw-agent-policy", ".yaml"); - cleanupFns.push(createPolicyTempCleanup(policyPath, "nemoclaw-agent-policy")); - fs.writeFileSync(policyPath, filtered.content, { encoding: "utf-8", mode: 0o600 }); - effectiveBasePolicyPath = policyPath; - basePolicy = filtered.content; + adoptPolicy(filtered.content, "nemoclaw-agent-policy"); } } @@ -467,32 +508,20 @@ export function prepareInitialSandboxCreatePolicy( policyAgent ?? "openclaw", ); if (excluded.excludedKeys.length > 0) { - const policyPath = secureTempFile("nemoclaw-agent-policy", ".yaml"); - cleanupFns.push(createPolicyTempCleanup(policyPath, "nemoclaw-agent-policy")); - fs.writeFileSync(policyPath, excluded.content, { encoding: "utf-8", mode: 0o600 }); - effectiveBasePolicyPath = policyPath; - basePolicy = excluded.content; + adoptPolicy(excluded.content, "nemoclaw-agent-policy"); } } const basePolicyNames = getNetworkPolicyNames(basePolicy); if (basePolicyNames === null) { - return { - policyPath: effectiveBasePolicyPath, - appliedPresets: [], - cleanup: buildCleanup(), - }; + return result([]); } const existingChannelPresets = activeMessagingChannels.filter((channel) => basePolicyNames.has(channel), ); if (requestedCreateTimePresets.length === 0) { - return { - policyPath: effectiveBasePolicyPath, - appliedPresets: dedupe(existingChannelPresets), - cleanup: buildCleanup(), - }; + return result(dedupe(existingChannelPresets)); } const existingCreateTimePresets = requestedCreateTimePresets.filter((preset) => @@ -502,11 +531,7 @@ export function prepareInitialSandboxCreatePolicy( (preset) => !basePolicyNames.has(preset), ); if (createTimePresets.length === 0) { - return { - policyPath: effectiveBasePolicyPath, - appliedPresets: dedupe([...existingChannelPresets, ...existingCreateTimePresets]), - cleanup: buildCleanup(), - }; + return result(dedupe([...existingChannelPresets, ...existingCreateTimePresets])); } const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets, { @@ -519,21 +544,147 @@ export function prepareInitialSandboxCreatePolicy( ); } - const policyPath = secureTempFile("nemoclaw-initial-policy", ".yaml"); - cleanupFns.push(createPolicyTempCleanup(policyPath, "nemoclaw-initial-policy")); - fs.writeFileSync(policyPath, mergedPolicy.policy, { encoding: "utf-8", mode: 0o600 }); - - return { - policyPath, - appliedPresets: dedupe([ + adoptPolicy(mergedPolicy.policy, "nemoclaw-initial-policy"); + return result( + dedupe([ ...existingChannelPresets, ...existingCreateTimePresets, ...mergedPolicy.appliedPresets, ]), - cleanup: buildCleanup(), - }; + ); } catch (error) { cleanupOnError(); throw error; } } + +export function prepareInitialSandboxCreatePolicy( + basePolicyPath: string, + activeMessagingChannels: string[], + options: InitialPolicyOptions = {}, +): InitialSandboxPolicy { + const exactCleanup = options.agentName === "hermes" && isPortableExperimentalProfile(); + return resolveInitialSandboxCreatePolicy(basePolicyPath, activeMessagingChannels, options, { + materialize: createTempPolicyMaterializer(exactCleanup), + exactCleanup, + includeSourceBytes: false, + }); +} + +function hasSafeHermesPortablePolicySourceMode( + stat: { readonly gid: bigint; readonly mode: bigint; readonly uid: bigint }, + uid: number, + gid: number, + hostedInstallerMode: bigint, +): boolean { + const permissions = stat.mode & 0o777n; + if ((permissions & 0o002n) !== 0n) return false; + if ((permissions & 0o020n) === 0n) return true; + return ( + (stat.mode & 0o7777n) === hostedInstallerMode && + stat.uid === BigInt(uid) && + stat.gid === BigInt(gid) + ); +} + +/** Read one policy source while holding exact current-user file authority. */ +export function readHermesPortableInitialPolicySource(basePolicyPath: string): string { + const uid = process.getuid?.(); + const gid = process.getgid?.(); + if (uid === undefined || gid === undefined) { + throw new Error("Hermes portable policy source has no current-user authority."); + } + const parentPath = path.dirname(basePolicyPath); + const parentBefore = fs.lstatSync(parentPath, { bigint: true }); + const named = fs.lstatSync(basePolicyPath, { bigint: true }); + if ( + !parentBefore.isDirectory() || + parentBefore.isSymbolicLink() || + (parentBefore.uid !== 0n && parentBefore.uid !== BigInt(uid)) || + !hasSafeHermesPortablePolicySourceMode(parentBefore, uid, gid, 0o775n) || + !named.isFile() || + named.isSymbolicLink() + ) { + throw new Error("Hermes portable policy source authority is unsafe."); + } + const descriptor = fs.openSync( + basePolicyPath, + fs.constants.O_RDONLY | + fs.constants.O_NOFOLLOW | + (typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0), + ); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== 1n || + (before.uid !== 0n && before.uid !== BigInt(uid)) || + !hasSafeHermesPortablePolicySourceMode(before, uid, gid, 0o664n) || + before.size < 1n || + before.size > 256n * 1024n || + named.dev !== before.dev || + named.ino !== before.ino + ) { + throw new Error("Hermes portable policy source authority is unsafe."); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const finalNamed = fs.lstatSync(basePolicyPath, { bigint: true }); + const parentAfter = fs.lstatSync(parentPath, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.uid !== after.uid || + before.gid !== after.gid || + before.nlink !== after.nlink || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + finalNamed.dev !== after.dev || + finalNamed.ino !== after.ino || + parentBefore.dev !== parentAfter.dev || + parentBefore.ino !== parentAfter.ino || + parentBefore.mode !== parentAfter.mode || + parentBefore.uid !== parentAfter.uid || + parentBefore.gid !== parentAfter.gid || + parentBefore.mtimeNs !== parentAfter.mtimeNs || + parentBefore.ctimeNs !== parentAfter.ctimeNs || + BigInt(bytes.byteLength) !== after.size + ) { + throw new Error("Hermes portable policy source authority changed while reading."); + } + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + throw new Error("Hermes portable policy source must not include a UTF-8 byte-order mark."); + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("Hermes portable policy source is not strict UTF-8."); + } + } finally { + fs.closeSync(descriptor); + } +} + +/** Plan exact schema-5 policy bytes without creating temporary files. */ +export function planHermesPortableInitialSandboxPolicy( + basePolicyPath: string, + activeMessagingChannels: string[], + options: InitialPolicyOptions, +): InitialSandboxPolicy { + if (options.agentName !== "hermes" || !isPortableExperimentalProfile()) { + throw new Error("Hermes portable policy planning requires the schema-5 profile."); + } + return resolveInitialSandboxCreatePolicy(basePolicyPath, activeMessagingChannels, options, { + materialize: (content) => ({ + policyPath: basePolicyPath, + sourceBytes: Buffer.from(content), + appliedPresets: [], + }), + exactCleanup: false, + includeSourceBytes: true, + initialContent: readHermesPortableInitialPolicySource(basePolicyPath), + }); +} diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index deaa29b0af0..6f736f5b941 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -3,7 +3,18 @@ import { describe, expect, it, vi } from "vitest"; +import { + type InferenceEndpointSource, + normalizeInferenceSelection, +} from "../../inference/selection"; import { createSession, type Session, type SessionUpdates } from "../../state/onboard-session"; +import { + getSandbox, + isPendingReservationForSession, + removeSandbox, + reserveSandboxInferenceRoute, +} from "../../state/registry"; +import { classifySandboxInferenceRouteReservation } from "../../state/registry/route-reservation"; import { type CoreOnboardFlowPhases, createProviderInferenceOnboardFlowPhase, @@ -311,6 +322,120 @@ function createPhases( } describe("core onboard flow phases", () => { + it("preserves the fresh install-ollama reservation endpoint source for Hermes portable creation (#9203)", async () => { + const durableSession = createSession(); + const sandboxName = `hermes-route-${durableSession.sessionId}`; + const recordStepComplete = vi.fn(async (_stepName: string, updates: SessionUpdates = {}) => { + Object.assign(durableSession, updates); + return durableSession; + }); + const createSandbox = vi.fn(async (...args: unknown[]) => { + const authority = args.at(-2) as { sessionId?: unknown } | null; + const createIntent = args.at(-1) as { + endpointSource?: InferenceEndpointSource | null; + }; + const reservation = getSandbox(sandboxName); + expect(authority).toEqual({ sessionId: durableSession.sessionId }); + expect(createIntent.endpointSource).toBeNull(); + expect(isPendingReservationForSession(reservation, authority?.sessionId as string)).toBe( + true, + ); + expect( + classifySandboxInferenceRouteReservation( + { + sandboxName, + gatewayName: "nemoclaw", + sessionId: authority?.sessionId as string, + selection: normalizeInferenceSelection({ + ...reservation, + endpointSource: "onboard", + }), + }, + reservation, + ).kind, + ).toBe("conflict"); + expect( + classifySandboxInferenceRouteReservation( + { + sandboxName, + gatewayName: "nemoclaw", + sessionId: authority?.sessionId as string, + selection: normalizeInferenceSelection({ + ...reservation, + endpointSource: createIntent.endpointSource, + }), + }, + reservation, + ).kind, + ).toBe("owned"); + return "created-sandbox"; + }); + const { providerInference: providerPhase, sandbox: sandboxPhase } = createPhases({ + providerDeps: { + setupNim: vi.fn(async () => ({ + model: "qwen3-vl:4b", + provider: "ollama-local", + endpointUrl: "http://inference.local/v1", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + })), + recordStepComplete, + promptValidatedSandboxName: vi.fn(async () => sandboxName), + setupInference: vi.fn( + async (name, model, provider, endpointUrl, credentialEnv, _auth, _gateways, options) => { + expect(options?.reservationSessionId).toBe(durableSession.sessionId); + expect( + reserveSandboxInferenceRoute(name, { + provider, + model, + endpointUrl, + endpointSource: options?.endpointSource ?? null, + credentialEnv, + preferredInferenceApi: options?.preferredInferenceApi ?? null, + gatewayName: options?.gatewayName ?? "nemoclaw", + reservationSessionId: options?.reservationSessionId, + }), + ).toBe(true); + return { ok: true as const }; + }, + ), + }, + sandboxDeps: { + createSandbox, + getSandboxRegistryEntry: getSandbox, + promptValidatedSandboxName: vi.fn(async () => sandboxName), + }, + sandboxOptions: { hermesPortableLifecycle: true }, + }); + + try { + const providerResult = await providerPhase.run( + context({ + fresh: true, + session: durableSession, + agent: { name: "hermes" }, + sandboxName, + }), + ); + expect(providerResult.context).toMatchObject({ + provider: "ollama-local", + model: "qwen3-vl:4b", + endpointSource: null, + hostLocalInferenceRouteOnly: false, + }); + await sandboxPhase.run(providerResult.context); + + expect(createSandbox).toHaveBeenCalledOnce(); + } finally { + removeSandbox(sandboxName); + } + }); + it("carries provider selection output into sandbox setup", async () => { const updateSandboxRegistry = vi.fn(); const createSandbox = vi.fn(async () => "created-sandbox"); diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index 1df5a76cd57..ff67b03eaec 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -67,6 +67,8 @@ export interface SandboxOnboardFlowPhaseOptions< ResourceProfile = unknown, > { gatewayName: string; + /** Internal schema-5 lifecycle selection from the locked portable runtime. */ + hermesPortableLifecycle?: boolean; authoritativeResumeConfig?: boolean; authoritativePolicyTier?: string | null; @@ -224,6 +226,7 @@ export function createSandboxOnboardFlowPhase< resume: context.resume, fresh: context.fresh, gatewayName: options.gatewayName, + hermesPortableLifecycle: options.hermesPortableLifecycle === true, authoritativeResumeConfig: options.authoritativeResumeConfig, authoritativePolicyTier: options.authoritativePolicyTier, diff --git a/src/lib/onboard/machine/handlers/sandbox-hermes-portable-endpoint-source.test.ts b/src/lib/onboard/machine/handlers/sandbox-hermes-portable-endpoint-source.test.ts new file mode 100644 index 00000000000..9f7d7f38a51 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-hermes-portable-endpoint-source.test.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), + detectUnconfiguredMessagingChannels: vi.fn(() => []), +})); + +describe("Hermes portable sandbox endpoint provenance", () => { + it("preserves the selected endpoint source for fresh Hermes portable creation (#9203)", async () => { + const { deps, calls } = createDeps(); + + await handleSandboxState({ + ...baseOptions(deps), + fresh: true, + agent: { name: "hermes" }, + endpointSource: null, + hostLocalInferenceRouteOnly: false, + hermesPortableLifecycle: true, + }); + + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ endpointSource: null }); + expect(calls.updateSandbox).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ endpointSource: null }), + ); + }); + + it("keeps ordinary fresh Hermes endpoint provenance unchanged (#9203)", async () => { + const { deps, calls } = createDeps(); + + await handleSandboxState({ + ...baseOptions(deps), + fresh: true, + agent: { name: "hermes" }, + endpointSource: null, + hostLocalInferenceRouteOnly: false, + }); + + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ + endpointSource: "onboard", + }); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-recreate-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-recreate-resume.test.ts index fc942170d69..a0a4e21376a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-recreate-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-recreate-resume.test.ts @@ -69,6 +69,7 @@ describe("handleSandboxState resume recreation", () => { null, [], null, + { sessionId: session.sessionId }, expect.objectContaining({ compatibleEndpointReasoning: "true", recreate: true }), ); }); @@ -119,7 +120,8 @@ describe("handleSandboxState resume recreation", () => { expect(journal.completeCreate).toHaveBeenCalledTimes(1); const createSandboxCall = journal.completeCreate.mock.calls[0] as unknown[]; expect(createSandboxCall[4]).toBe("saved"); - expect(createSandboxCall[14]).toMatchObject({ + expect(createSandboxCall[14]).toEqual({ sessionId: session.sessionId }); + expect(createSandboxCall[15]).toMatchObject({ extraProviders: ["healthy-extra-provider"], recreate: true, }); @@ -155,7 +157,8 @@ describe("handleSandboxState resume recreation", () => { expect(deps.planRegisteredExtraProviders).toHaveBeenCalledWith("nemoclaw"); expect(journal.completeCreate).toHaveBeenCalledTimes(1); const createSandboxCall = journal.completeCreate.mock.calls[0] as unknown[]; - expect(createSandboxCall[14]).toMatchObject({ + expect(createSandboxCall[14]).toEqual({ sessionId: session.sessionId }); + expect(createSandboxCall[15]).toMatchObject({ extraProviders: [], recreate: true, resolved: expect.objectContaining({ staleExtraProviders: ["stale-extra-provider"] }), diff --git a/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts index 5d49ec77937..6c6b607474f 100644 --- a/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts @@ -122,6 +122,7 @@ describe("handleSandboxState tool disclosure", () => { null, [], null, + { sessionId: session.sessionId }, { resolved: expect.any(Object), recreate: true, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index e3c882a451a..5871fbbb89c 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -79,6 +79,7 @@ describe("handleSandboxState", () => { null, [], null, + { sessionId: expect.any(String) }, { resolved: expect.any(Object), recreate: false, @@ -121,6 +122,20 @@ describe("handleSandboxState", () => { expect(result.session?.checkpoint?.messaging).toEqual(decisionDeclined()); }); + it("preserves a null endpoint source for fresh host-local inference-only creation (#9203)", async () => { + const { deps, calls } = createDeps(); + + await handleSandboxState({ + ...baseOptions(deps), + fresh: true, + endpointUrl: "http://host.openshell.internal:11435/v1", + endpointSource: null, + hostLocalInferenceRouteOnly: true, + }); + + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toMatchObject({ endpointSource: null }); + }); + it("records credential-provider bindings and the resource-profile decision in the checkpoint (#7022)", async () => { const { deps } = createDeps({ configureWebSearch: vi.fn(async () => ({ fetchEnabled: true as const })), @@ -539,6 +554,7 @@ describe("handleSandboxState", () => { null, ["nous-audio"], null, + { sessionId: expect.any(String) }, { resolved: expect.any(Object), recreate: false, @@ -824,6 +840,7 @@ describe("handleSandboxState", () => { null, [], null, + { sessionId: session.sessionId }, { resolved: expect.any(Object), recreate: true, @@ -991,6 +1008,7 @@ describe("handleSandboxState", () => { null, [], null, + { sessionId: session.sessionId }, expect.objectContaining({ resolved: expect.any(Object), recreate: true, @@ -1123,6 +1141,7 @@ describe("handleSandboxState", () => { null, [], null, + { sessionId: session.sessionId }, expect.objectContaining({ resolved: expect.any(Object), recreate: true, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 0dc7ffee986..eec0ff461d6 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -102,7 +102,7 @@ import { } from "../../sandbox-registration"; import { withSandboxPhaseTrace } from "../../tracing"; -import type { SandboxCreateIntent } from "../../types"; +import type { InferenceRouteReservationAuthority, SandboxCreateIntent } from "../../types"; import { branchTo, type OnboardStateTransitionResult } from "../result"; import * as dcodeResume from "./sandbox-dcode-resume"; import { @@ -187,6 +187,8 @@ export interface SandboxStateOptions< > { resume: boolean; fresh: boolean; + /** Exact schema-5 lifecycle selection owned by the locked portable runtime. */ + hermesPortableLifecycle?: boolean; /** Internal rebuild mode: null web-search state is an authoritative disable, not a prompt. */ authoritativeResumeConfig?: boolean; /** Internal rebuild tier that must govern create-time and resumed policy selection. */ @@ -348,6 +350,7 @@ export interface SandboxStateOptions< resourceProfile: ResourceProfile | null, hermesToolGateways: string[], hermesAuthMethod: HermesAuthMethod | null, + inferenceRouteReservationAuthority: InferenceRouteReservationAuthority | null, createIntent: CompleteSandboxCreateIntent, ): Promise; updateSandboxRegistry(sandboxName: string, updates: Record): void; @@ -467,8 +470,9 @@ function hasResourceProfileEnvOverride(env: NodeJS.ProcessEnv): boolean { function endpointSourceForCreateIntent( fresh: boolean, endpointSource: InferenceEndpointSource | null | undefined, + preserveSelectedEndpointSource: boolean, ): InferenceEndpointSource | null { - return fresh ? "onboard" : (endpointSource ?? null); + return fresh && !preserveSelectedEndpointSource ? "onboard" : (endpointSource ?? null); } function compatibleEndpointReasoningForCreateIntent( @@ -1575,6 +1579,8 @@ class SandboxStateFlow< endpointSource: endpointSourceForCreateIntent( this.options.fresh, this.options.endpointSource, + this.options.hostLocalInferenceRouteOnly === true || + this.options.hermesPortableLifecycle === true, ), ...(state.session?.observabilityRequestedExplicitly === true ? { observabilityRequestedExplicitly: true as const } @@ -1898,6 +1904,7 @@ class SandboxStateFlow< resourceProfile, effectiveHermesToolGateways, this.options.hermesAuthMethod, + this.options.session ? { sessionId: this.options.session.sessionId } : null, effectiveCreateIntent, ), ); diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts index 182a325b228..9452bcfbbc4 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts @@ -21,6 +21,7 @@ vi.mock("../../core/version", () => ({ getVersion: () => "v0.0.0" })); import { createManagedHermesStateVolumeOnboardLifecycle, createManagedWorkloadOnboardRuntime, + prepareHermesPortableSandboxWorkloadForLifecycle, prepareOnboardSandboxWorkloadLaunch, } from "./onboard-orchestration"; @@ -64,7 +65,61 @@ function createFreshOnboardingRuntime(environment: Readonly[0], + prepared: { + source: { + kind: "legacy-dockerfile"; + dockerfilePath: string; + reason: "runtime-unsupported"; + }; + release: string; + fallbackDiagnostic: null; + }, + expectedDockerfilePath: string, +): Promise { + await Promise.all( + [ + { ...prepared.source, reason: "custom-dockerfile" as const }, + { ...prepared.source, dockerfilePath: "/workspace/replacement/Dockerfile" }, + ].map((source) => + expect( + prepareHermesPortableSandboxWorkloadForLifecycle( + { ...runtime, ensurePreparedWorkload: vi.fn(async () => ({ ...prepared, source })) }, + expectedDockerfilePath, + ), + ).rejects.toThrow("requires the shipped Hermes Dockerfile source"), + ), + ); +} + describe("managed workload onboard orchestration", () => { + it("selects only the shipped Hermes Dockerfile fallback without profile or prebuild work", async () => { + const expectedDockerfilePath = "/workspace/agents/hermes/Dockerfile"; + const ensurePreparedProfile = vi.fn(() => null); + const prepared = { + source: { + kind: "legacy-dockerfile" as const, + dockerfilePath: expectedDockerfilePath, + reason: "runtime-unsupported" as const, + }, + release: "v0.0.0", + fallbackDiagnostic: null, + }; + const runtime = { + runtimeProvider: null, + ensurePreparedWorkload: vi.fn(async () => prepared), + ensurePreparedProfile, + }; + + await expect( + prepareHermesPortableSandboxWorkloadForLifecycle(runtime, expectedDockerfilePath), + ).resolves.toBe(prepared); + expect(ensurePreparedProfile).not.toHaveBeenCalled(); + + await expectUnsupportedHermesPortableSources(runtime, prepared, expectedDockerfilePath); + }); + it("keeps failure cleanup armed until the caller commits registration", () => { const docker = createHermesStateVolumeDockerHarness(); let exitCleanup: (() => void) | null = null; diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.ts b/src/lib/onboard/managed-workload/onboard-orchestration.ts index 51c234a7f13..f137efcd80b 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.ts @@ -40,7 +40,10 @@ import type { MaterializeSandboxCreatePlanInput, SandboxCreateIntent, } from "../sandbox-create-intent-types"; -import type { SandboxCreatePlan } from "../sandbox-create-plan-materialization"; +import { + materializeHermesPortableCreatePlan, + type SandboxCreatePlan, +} from "../sandbox-create-plan-materialization"; import { OPENSHELL_SANDBOX_SUPERVISOR_ARGV, prepareSandboxCreateLaunch, @@ -164,6 +167,28 @@ export async function prepareSandboxWorkloadForPortableLifecycle( return workload; } +/** Select the existing legacy Hermes source without staging, profile, prebuild, or Docker work. */ +export async function prepareHermesPortableSandboxWorkloadForLifecycle( + runtime: ManagedWorkloadOnboardRuntime, + expectedDockerfilePath: string, +): Promise { + const workload = await runtime.ensurePreparedWorkload(); + if (workload.source.kind === "managed-image") { + throw new Error( + "Hermes portable onboarding cannot use managed-image bootstrap because that path requires Docker lifecycle operations.", + ); + } + if ( + workload.source.reason !== "runtime-unsupported" || + workload.source.dockerfilePath !== expectedDockerfilePath + ) { + throw new Error( + "Hermes portable onboarding requires the shipped Hermes Dockerfile source selected for the current runtime.", + ); + } + return workload; +} + function requireBootstrapProvider(provider: RuntimeProviderBundle | null): BootstrapProvider { if (!provider || !provider.bootstrap.supported) { throw new Error("Selected runtime provider does not support managed bootstrap onboarding."); @@ -465,6 +490,43 @@ export async function prepareOnboardSandboxWorkloadLaunch( }; } +/** Build the complete schema-5 launch descriptor before any shared onboarding effect. */ +export function prepareHermesPortableOnboardSandboxLaunch(input: { + readonly intent: SandboxCreateIntent; + readonly fromRef: string; + readonly launchInput: Omit; + readonly gpuConfig: SandboxGpuConfig; +}): PreparedOnboardSandboxWorkloadLaunch { + const createPlan = materializeHermesPortableCreatePlan({ + intent: input.intent, + fromRef: input.fromRef, + }); + const launch = prepareSandboxCreateLaunch({ + ...input.launchInput, + createArgs: createPlan.createArgs, + }); + return { + ...createPlan, + initialGpuRoute: initialDockerGpuRoute(createPlan.gpuRoutePlan), + sandboxReadyTimeoutSecs: getSandboxReadyTimeoutSecs(input.gpuConfig), + buildId: "hermes-portable", + dashboardRemoteBindPrepared: false, + legacyBuildContext: null, + launch: { + ...launch, + prebuild: { createArgs: [...createPlan.createArgs], imageRef: null, imageId: null }, + }, + }; +} + +export async function prepareSelectedOnboardSandboxWorkloadLaunch( + hermesPortable: boolean, + prepareHermes: () => PreparedOnboardSandboxWorkloadLaunch, + prepareOrdinary: () => Promise, +): Promise { + return hermesPortable ? prepareHermes() : await prepareOrdinary(); +} + export function resolveOnboardManagedBootstrapLaunch(input: { readonly runtime: ManagedWorkloadOnboardRuntime; readonly workload: PreparedSandboxWorkloadSource; diff --git a/src/lib/onboard/portable-environment-scope.test.ts b/src/lib/onboard/portable-environment-scope.test.ts index 7dfcfcaa4c9..f29b1d927f6 100644 --- a/src/lib/onboard/portable-environment-scope.test.ts +++ b/src/lib/onboard/portable-environment-scope.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; +import type { CheckpointPortableRuntimeAuthority } from "../state/onboard-checkpoint-types"; import { createDefaultResumeProfileEnvironmentScope, createPortableOnboardEnvironmentScope, @@ -17,6 +18,20 @@ const CLEARED_PORTABLE_RUNTIME_ENV_KEYS = PORTABLE_RUNTIME_ENV_KEYS.filter( (key) => key !== "NEMOCLAW_EXPERIMENTAL_PROFILE", ); +function portableRuntimeAuthority(): CheckpointPortableRuntimeAuthority { + const uid = process.getuid!(); + return { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid, + homeDir: "/home/alice", + configHome: "/home/alice/.config", + runtimeDir: `/run/user/${String(uid)}`, + socketPath: `/run/user/${String(uid)}/podman/podman.sock`, + }; +} + describe("portable onboarding environment scope", () => { it("preserves an explicit model during fresh portable onboarding (#9200)", () => { const env: NodeJS.ProcessEnv = { NEMOCLAW_MODEL: "qwen3.6:35b" }; @@ -176,6 +191,54 @@ describe("portable onboarding environment scope", () => { }, ); + it("removes exact scope-owned selectors only from the Hermes Podman authority source", () => { + const runtime = portableRuntimeAuthority(); + const containersConf = "/home/alice/.config/nemoclaw/portable/containers.conf"; + const env: NodeJS.ProcessEnv = { HOME: runtime.homeDir, PATH: "/usr/bin" }; + const scope = createPortableOnboardEnvironmentScope(env, null); + scope.installRuntime({ containersConf, socketPath: runtime.socketPath }); + + const source = scope.createHermesPortablePodmanSourceEnvironment(runtime); + + expect(source).not.toHaveProperty("DOCKER_HOST"); + expect(source).not.toHaveProperty("CONTAINERS_CONF"); + expect(source).toMatchObject({ HOME: runtime.homeDir, PATH: "/usr/bin" }); + expect(env).toMatchObject({ + DOCKER_HOST: `unix://${runtime.socketPath}`, + CONTAINERS_CONF: containersConf, + NETAVARK_FW: "iptables", + }); + }); + + it.each([ + ["DOCKER_HOST", "unix:///run/user/1000/replaced/podman.sock"], + ["CONTAINERS_CONF", "/home/alice/.config/replaced/containers.conf"], + ] as const)("keeps a replaced %s selector for Hermes authority rejection", (name, value) => { + const runtime = portableRuntimeAuthority(); + const env: NodeJS.ProcessEnv = { HOME: runtime.homeDir, PATH: "/usr/bin" }; + const scope = createPortableOnboardEnvironmentScope(env, null); + scope.installRuntime({ + containersConf: "/home/alice/.config/nemoclaw/portable/containers.conf", + socketPath: runtime.socketPath, + }); + env[name] = value; + + expect(scope.createHermesPortablePodmanSourceEnvironment(runtime)[name]).toBe(value); + }); + + it("rejects a scope whose installed selectors disagree with runtime authority", () => { + const runtime = portableRuntimeAuthority(); + const scope = createPortableOnboardEnvironmentScope({}, null); + scope.installRuntime({ + containersConf: "/home/alice/.config/nemoclaw/portable/containers.conf", + socketPath: "/run/user/1000/replaced/podman.sock", + }); + + expect(() => scope.createHermesPortablePodmanSourceEnvironment(runtime)).toThrow( + "disagrees with runtime authority", + ); + }); + it("restores absent, empty, and valued keys exactly after success or failure", () => { const env: NodeJS.ProcessEnv = { DOCKER_HOST: "", diff --git a/src/lib/onboard/resume/locked-runtime.ts b/src/lib/onboard/resume/locked-runtime.ts index 081adde05aa..fad5c38c920 100644 --- a/src/lib/onboard/resume/locked-runtime.ts +++ b/src/lib/onboard/resume/locked-runtime.ts @@ -13,6 +13,7 @@ import { createPortableOnboardEnvironmentScope, preparePortableExperimentalHost, type PortableOnboardEnvironmentScope, + type PortableOnboardRuntimeContext, } from "../session-bootstrap"; import type { OnboardOptions } from "../types"; import { ensureUsageNoticeConsent } from "../usage-notice"; @@ -20,7 +21,7 @@ import { ensureUsageNoticeConsent } from "../usage-notice"; export interface LockedOnboardRuntimePreparation { readonly checkpointProfile: CheckpointOnboardProfile; readonly environmentScope: PortableOnboardEnvironmentScope | null; - readonly preparedPortableAuthority: CheckpointPortableRuntimeAuthority | null; + readonly portableRuntimeContext: PortableOnboardRuntimeContext | null; } async function ensureNoticeAccepted( @@ -87,12 +88,12 @@ function prepareEnvironment( expectedPortableAuthority: CheckpointPortableRuntimeAuthority | null, ): { environmentScope: PortableOnboardEnvironmentScope | null; - preparedPortableAuthority: CheckpointPortableRuntimeAuthority | null; + portableRuntimeContext: PortableOnboardRuntimeContext | null; } { if (checkpointProfile !== "portable") { return { environmentScope: resume ? createDefaultResumeProfileEnvironmentScope(process.env) : null, - preparedPortableAuthority: null, + portableRuntimeContext: null, }; } const environmentScope = createPortableOnboardEnvironmentScope( @@ -111,7 +112,10 @@ function prepareEnvironment( containersConf: prepared.containersConf, socketPath: prepared.authority.socketPath, }); - return { environmentScope, preparedPortableAuthority: prepared.authority }; + return { + environmentScope, + portableRuntimeContext: { authority: prepared.authority, environmentScope }, + }; } catch (error) { environmentScope.restore(); throw error; diff --git a/src/lib/onboard/runtime-provider/docker.test.ts b/src/lib/onboard/runtime-provider/docker.test.ts index 97922b67895..98b8ee83972 100644 --- a/src/lib/onboard/runtime-provider/docker.test.ts +++ b/src/lib/onboard/runtime-provider/docker.test.ts @@ -4,6 +4,31 @@ import { describe, expect, it, vi } from "vitest"; import { createDockerRuntimeProviderBundle } from "./docker"; +import type { RuntimeProviderLifecycleInput } from "./contract"; + +function lifecycleInput(): RuntimeProviderLifecycleInput { + return { + environment: {}, + log: vi.fn(), + sandboxName: "alpha", + sandbox: { + name: "alpha", + agent: "hermes", + openshellDriver: "docker", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + } as RuntimeProviderLifecycleInput["sandbox"], + }; +} + +function poison(): never { + throw new Error("Docker dependency must not be called"); +} + +function supportedLifecycle(provider: ReturnType) { + expect(provider.lifecycle.supported).toBe(true); + return provider.lifecycle as Extract; +} function inspectDockerHost(stdout: string, status = 0, stderr = "") { const captureHostCommand = vi.fn(() => ({ status, stdout, stderr })); @@ -70,3 +95,46 @@ describe("Docker runtime provider host doctor", () => { }); }); }); + +describe("Docker provider portable lifecycle dispatch", () => { + it("routes active Hermes start before every Docker dependency (#9203)", () => { + const recoverPortableSandbox = vi.fn(() => ({ kind: "already-running" as const })); + const provider = createDockerRuntimeProviderBundle({ + hasPortableLifecycleReceipt: () => true, + recoverPortableSandbox, + findLabeledSandboxContainers: poison, + recoverSandbox: poison, + unpauseContainer: poison, + withLifecycleLockSync: (_sandboxName, operation) => operation(), + }); + const lifecycle = supportedLifecycle(provider); + + expect(lifecycle.start(lifecycleInput())).toEqual({ + exitCode: 0, + hermesPortableVerified: true, + }); + expect(recoverPortableSandbox).toHaveBeenCalledOnce(); + }); + + it("routes active Hermes stop before Docker capture or mutation (#9203)", () => { + const stopPortableSandbox = vi.fn(() => ({ + kind: "stopped" as const, + portableAgent: "hermes" as const, + })); + const provider = createDockerRuntimeProviderBundle({ + hasPortableLifecycleReceipt: () => true, + stopPortableSandbox, + findLabeledSandboxContainers: poison, + stopContainer: poison, + withLifecycleLockSync: (_sandboxName, operation) => operation(), + }); + const lifecycle = supportedLifecycle(provider); + + expect(lifecycle.stop(lifecycleInput(), { beforeStop: poison })).toEqual({ + exitCode: 0, + state: "stopped", + hermesPortableVerified: true, + }); + expect(stopPortableSandbox).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/onboard/runtime-provider/docker.ts b/src/lib/onboard/runtime-provider/docker.ts index 08cc525b78e..1bc2511cbbe 100644 --- a/src/lib/onboard/runtime-provider/docker.ts +++ b/src/lib/onboard/runtime-provider/docker.ts @@ -14,10 +14,11 @@ import { } from "../docker-driver-sandbox-recovery"; import { createDockerManagedBootstrapSurface } from "../managed-bootstrap/docker-runtime"; import { - hasPortableDemoSandboxLifecycleReceipt, - recoverPortableDemoSandboxLifecycle, - stopPortableDemoSandboxLifecycle, -} from "../experimental/portable-demo-lifecycle"; + hasPortableAgentSandboxLifecycleReceipt, + recoverPortableAgentSandboxLifecycle, + stopPortableAgentSandboxLifecycle, +} from "../experimental/portable-agent-lifecycle"; +import { withMcpLifecycleLockSync } from "../../state/mcp-lifecycle-lock-acquisition"; import { MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, MANAGED_IMAGE_PLATFORMS, @@ -58,16 +59,17 @@ export interface DockerRuntimeProviderDependencies { timeout?: number, ) => RuntimeProviderCommandCapture; readonly findLabeledSandboxContainers: typeof findLabeledSandboxContainers; - readonly hasPortableLifecycleReceipt: typeof hasPortableDemoSandboxLifecycleReceipt; + readonly hasPortableLifecycleReceipt: typeof hasPortableAgentSandboxLifecycleReceipt; readonly isRuntimeDown: typeof isDockerRuntimeDown; readonly printRuntimeDownGuidance: typeof printDockerRuntimeDownGuidance; readonly recoverSandbox: typeof recoverDockerDriverSandbox; - readonly recoverPortableSandbox: typeof recoverPortableDemoSandboxLifecycle; + readonly recoverPortableSandbox: typeof recoverPortableAgentSandboxLifecycle; readonly queryRuntimeSnapshot: typeof queryOpenShellDockerSandboxRuntimeSnapshot; readonly removeImage: DockerRemoveImage; readonly stopContainer: DockerStop; - readonly stopPortableSandbox: typeof stopPortableDemoSandboxLifecycle; + readonly stopPortableSandbox: typeof stopPortableAgentSandboxLifecycle; readonly unpauseContainer: DockerUnpause; + readonly withLifecycleLockSync: typeof withMcpLifecycleLockSync; } const DOCKER_OPERATION_TIMEOUT_MS = 30_000; @@ -95,22 +97,22 @@ function resolveDependencies( findLabeledSandboxContainers: overrides.findLabeledSandboxContainers ?? findLabeledSandboxContainers, hasPortableLifecycleReceipt: - overrides.hasPortableLifecycleReceipt ?? hasPortableDemoSandboxLifecycleReceipt, + overrides.hasPortableLifecycleReceipt ?? hasPortableAgentSandboxLifecycleReceipt, isRuntimeDown: overrides.isRuntimeDown ?? isDockerRuntimeDown, printRuntimeDownGuidance: overrides.printRuntimeDownGuidance ?? printDockerRuntimeDownGuidance, recoverSandbox: overrides.recoverSandbox ?? recoverDockerDriverSandbox, recoverPortableSandbox: - overrides.recoverPortableSandbox ?? recoverPortableDemoSandboxLifecycle, + overrides.recoverPortableSandbox ?? recoverPortableAgentSandboxLifecycle, queryRuntimeSnapshot: overrides.queryRuntimeSnapshot ?? queryOpenShellDockerSandboxRuntimeSnapshot, removeImage: overrides.removeImage ?? ((reference, options) => loadDockerRemoveImage()(reference, options)), stopContainer: overrides.stopContainer ?? ((name, options) => loadDockerStop()(name, options)), - stopPortableSandbox: - overrides.stopPortableSandbox ?? stopPortableDemoSandboxLifecycle, + stopPortableSandbox: overrides.stopPortableSandbox ?? stopPortableAgentSandboxLifecycle, unpauseContainer: overrides.unpauseContainer ?? ((name, options) => loadDockerUnpause()(name, options)), + withLifecycleLockSync: overrides.withLifecycleLockSync ?? withMcpLifecycleLockSync, }; } @@ -162,6 +164,15 @@ function isAtRestStatus(status: string): boolean { function startDockerSandbox( input: RuntimeProviderLifecycleInput, deps: DockerRuntimeProviderDependencies, +): RuntimeProviderLifecycleResult { + return deps.withLifecycleLockSync(input.sandboxName, () => + startDockerSandboxUnlocked(input, deps), + ); +} + +function startDockerSandboxUnlocked( + input: RuntimeProviderLifecycleInput, + deps: DockerRuntimeProviderDependencies, ): RuntimeProviderLifecycleResult { try { const portable = deps.recoverPortableSandbox( @@ -173,9 +184,19 @@ function startDockerSandbox( openshellDriver: input.sandbox.openshellDriver, provider: input.sandbox.provider, }, - { env: input.environment, log: input.log }, + { + env: input.environment, + log: input.log, + readRegistry: (sandboxName) => (sandboxName === input.sandboxName ? input.sandbox : null), + }, ); - if (portable.kind !== "not-installed") return { exitCode: 0 }; + if (portable.kind !== "not-installed") { + return input.sandbox.agent === "hermes" + ? ({ exitCode: 0, hermesPortableVerified: true } as RuntimeProviderLifecycleResult & { + readonly hermesPortableVerified: true; + }) + : { exitCode: 0 }; + } } catch (error) { return { exitCode: 1, message: error instanceof Error ? error.message : String(error) }; } @@ -223,6 +244,16 @@ function stopDockerSandbox( input: RuntimeProviderLifecycleInput, hooks: RuntimeProviderLifecycleStopHooks, deps: DockerRuntimeProviderDependencies, +): RuntimeProviderLifecycleStopOutcome { + return deps.withLifecycleLockSync(input.sandboxName, () => + stopDockerSandboxUnlocked(input, hooks, deps), + ); +} + +function stopDockerSandboxUnlocked( + input: RuntimeProviderLifecycleInput, + hooks: RuntimeProviderLifecycleStopHooks, + deps: DockerRuntimeProviderDependencies, ): RuntimeProviderLifecycleStopOutcome { try { const portable = deps.stopPortableSandbox( @@ -235,12 +266,40 @@ function stopDockerSandbox( provider: input.sandbox.provider, }, hooks.beforeStop, - { env: input.environment, log: input.log }, + { + env: input.environment, + log: input.log, + readRegistry: (sandboxName) => (sandboxName === input.sandboxName ? input.sandbox : null), + }, ); if (portable.kind === "already-stopped") { - return { exitCode: 0, state: "already-stopped" }; + const registryHermes = input.sandbox.agent === "hermes"; + const portableHermes = portable.portableAgent === "hermes"; + if (registryHermes !== portableHermes) { + throw new Error("Portable stop authority disagrees with the registered sandbox agent"); + } + return portableHermes + ? ({ + exitCode: 0, + state: "already-stopped", + hermesPortableVerified: true, + } as RuntimeProviderLifecycleStopOutcome & { readonly hermesPortableVerified: true }) + : { exitCode: 0, state: "already-stopped" }; + } + if (portable.kind === "stopped") { + const registryHermes = input.sandbox.agent === "hermes"; + const portableHermes = portable.portableAgent === "hermes"; + if (registryHermes !== portableHermes) { + throw new Error("Portable stop authority disagrees with the registered sandbox agent"); + } + return portableHermes + ? ({ + exitCode: 0, + state: "stopped", + hermesPortableVerified: true, + } as RuntimeProviderLifecycleStopOutcome & { readonly hermesPortableVerified: true }) + : { exitCode: 0, state: "stopped" }; } - if (portable.kind === "stopped") return { exitCode: 0, state: "stopped" }; } catch (error) { return { exitCode: 1, message: error instanceof Error ? error.message : String(error) }; } diff --git a/src/lib/onboard/runtime-provider/podman-preflight.test.ts b/src/lib/onboard/runtime-provider/podman-preflight.test.ts index c37a9280a39..b53efc21e50 100644 --- a/src/lib/onboard/runtime-provider/podman-preflight.test.ts +++ b/src/lib/onboard/runtime-provider/podman-preflight.test.ts @@ -9,6 +9,7 @@ import { isPodmanVersionSupported, normalizePodmanInferenceAuthorityReceipt, PodmanHostPreflightError, + qualifyPodmanEndpointHost, qualifyPodmanHost, qualifyPodmanInferenceAuthority, revalidatePodmanInferenceAuthority, @@ -107,6 +108,43 @@ describe("Podman host preflight", () => { expect(runtime.captureHost).toHaveBeenCalledTimes(1); }); + it("qualifies the exact socket-bound Hermes portable Podman matrix", () => { + const runtime = engine({ operation: "state-mutation", version: "5.7.0" }); + + expect( + qualifyPodmanEndpointHost(runtime, { + expectedVersion: "5.7.0", + expectedNetworkBackend: "netavark", + platform: "linux", + architecture: "x64", + }), + ).toEqual({ + providerId: "podman", + clientVersion: "5.7.0", + serverVersion: "5.7.0", + rootless: true, + cgroupVersion: "v2", + os: "linux", + architecture: "amd64", + networkBackend: "netavark", + }); + expect(runtime.captureHost).not.toHaveBeenCalled(); + }); + + it.each([ + ["client mismatch", { version: "5.6.2", serverVersion: "5.7.0" }, "client version"], + ["server mismatch", { version: "5.7.0", serverVersion: "5.6.2" }, "server version"], + ])("rejects exact endpoint $0", (_label, versions, message) => { + expect(() => + qualifyPodmanEndpointHost(engine({ operation: "state-mutation", ...versions }), { + expectedVersion: "5.7.0", + expectedNetworkBackend: "netavark", + platform: "linux", + architecture: "x64", + }), + ).toThrow(message); + }); + it("keeps the CPU receipt server version canonical while preserving exact inference authority", () => { expect( qualifyPodmanHost(engine({ version: "6.0.0-dev" }), { diff --git a/src/lib/onboard/runtime-provider/podman-preflight.ts b/src/lib/onboard/runtime-provider/podman-preflight.ts index e393cf0d95a..9baa439754b 100644 --- a/src/lib/onboard/runtime-provider/podman-preflight.ts +++ b/src/lib/onboard/runtime-provider/podman-preflight.ts @@ -53,6 +53,11 @@ export interface PodmanHostPreflightOptions { readonly architecture?: NodeJS.Architecture; } +export interface PodmanEndpointHostQualificationOptions extends PodmanHostPreflightOptions { + readonly expectedVersion: string; + readonly expectedNetworkBackend: string; +} + export class PodmanHostPreflightError extends Error { constructor(message: string) { super(`Podman preflight failed: ${message}`); @@ -150,6 +155,28 @@ interface InspectedServerVersion { readonly reportedVersion: unknown; } +interface QualifiedPodmanHostIdentity { + readonly rootless: true; + readonly cgroupVersion: "v2"; + readonly os: "linux"; + readonly architecture: "amd64" | "arm64"; + readonly networkBackend: string; +} + +function requireSupportedHostPlatform(options: PodmanHostPreflightOptions): { + readonly platform: "linux"; + readonly architecture: "x64" | "arm64"; +} { + const platform = options.platform ?? process.platform; + const architecture = options.architecture ?? process.arch; + if (platform !== "linux" || (architecture !== "x64" && architecture !== "arm64")) { + throw new PodmanHostPreflightError( + `basic native lifecycle requires Linux amd64 or arm64; detected ${platform} ${architecture}`, + ); + } + return { platform, architecture }; +} + function inspectServerVersion(engine: ContainerEngine, minimum: string): InspectedServerVersion { const result = requireSuccessful( "server version inspection", @@ -171,6 +198,20 @@ function inspectInfo(engine: ContainerEngine, label: string): JsonRecord { return parsed; } +function requireExactVersion( + value: unknown, + subject: "client" | "server", + expected: string, +): string { + const actual = typeof value === "string" ? value.trim() : ""; + if (actual !== expected) { + throw new PodmanHostPreflightError( + `Podman ${subject} version must be exactly ${expected}; detected '${actual || "unavailable"}'`, + ); + } + return actual; +} + function normalizeArchitecture(value: string): "amd64" | "arm64" | null { if (value === "amd64" || value === "x86_64") return "amd64"; if (value === "arm64" || value === "aarch64") return "arm64"; @@ -183,11 +224,7 @@ function requireSubordinateIdMappings(host: unknown): void { // authority than an explicitly bound service endpoint. const mappings = field(host, "idMappings", "IDMappings"); for (const mapping of ["uidmap", "gidmap"] as const) { - const entries = field( - mappings, - mapping, - mapping === "uidmap" ? "UIDMap" : "GIDMap", - ); + const entries = field(mappings, mapping, mapping === "uidmap" ? "UIDMap" : "GIDMap"); if (!Array.isArray(entries) || entries.length === 0 || entries.length > 1_024) { throw new PodmanHostPreflightError(`the Podman API returned malformed ${mapping}`); } @@ -218,6 +255,57 @@ function requireSubordinateIdMappings(host: unknown): void { } } +function qualifyPodmanHostIdentity( + info: JsonRecord, + options: PodmanHostPreflightOptions, +): QualifiedPodmanHostIdentity { + const { architecture } = requireSupportedHostPlatform(options); + const host = field(info, "host", "Host"); + const security = field(host, "security", "Security"); + if (booleanField(security, "rootless", "Rootless") !== true) { + throw new PodmanHostPreflightError("a rootless Podman API service is required"); + } + const cgroupVersion = textField( + host, + "cgroupVersion", + "cgroupsVersion", + "CgroupVersion", + "CgroupsVersion", + ).toLowerCase(); + if (cgroupVersion !== "v2") { + throw new PodmanHostPreflightError( + `cgroups v2 is required; detected '${cgroupVersion || "unknown"}'`, + ); + } + const hostOs = textField(host, "os", "OS").toLowerCase(); + if (hostOs !== "linux") { + throw new PodmanHostPreflightError( + `the Podman service must run Linux; detected '${hostOs || "unknown"}'`, + ); + } + const reportedArchitecture = textField(host, "arch", "Arch").toLowerCase(); + const normalizedArchitecture = normalizeArchitecture(reportedArchitecture); + if (!normalizedArchitecture) { + throw new PodmanHostPreflightError( + `the Podman service must report amd64 or arm64; detected '${reportedArchitecture || "unknown"}'`, + ); + } + const expectedArchitecture = architecture === "x64" ? "amd64" : "arm64"; + if (normalizedArchitecture !== expectedArchitecture) { + throw new PodmanHostPreflightError( + `the Podman service architecture '${normalizedArchitecture}' does not match host '${expectedArchitecture}'`, + ); + } + requireSubordinateIdMappings(host); + return Object.freeze({ + rootless: true, + cgroupVersion: "v2", + os: "linux", + architecture: normalizedArchitecture, + networkBackend: textField(host, "networkBackend", "NetworkBackend") || "unknown", + }); +} + function safeSchemaText(value: unknown, label: string): string { if ( typeof value !== "string" || @@ -403,14 +491,7 @@ export function qualifyPodmanHost( if (engine.operation !== "host-doctor" || engine.engineId !== "podman") { throw new PodmanHostPreflightError("host qualification requires a Podman host-doctor engine"); } - const platform = options.platform ?? process.platform; - const architecture = options.architecture ?? process.arch; - if (platform !== "linux" || !["x64", "arm64"].includes(architecture)) { - throw new PodmanHostPreflightError( - `basic native lifecycle requires Linux amd64 or arm64; detected ${platform} ${architecture}`, - ); - } - + requireSupportedHostPlatform(options); const clientVersionResult = requireSuccessful( "client version inspection", engine.captureHost(["--version"], 10_000), @@ -418,53 +499,56 @@ export function qualifyPodmanHost( const clientVersion = requireSupportedVersion(clientVersionResult.stdout, "client"); const { canonicalVersion: serverVersion } = inspectServerVersion(engine, MINIMUM_PODMAN_VERSION); const info = inspectInfo(engine, "rootless API inspection"); - const host = field(info, "host", "Host"); - const security = field(host, "security", "Security"); - if (booleanField(security, "rootless", "Rootless") !== true) { - throw new PodmanHostPreflightError("a rootless Podman API service is required"); - } - const cgroupVersion = textField( - host, - "cgroupVersion", - "cgroupsVersion", - "CgroupVersion", - "CgroupsVersion", - ).toLowerCase(); - if (cgroupVersion !== "v2") { - throw new PodmanHostPreflightError( - `cgroups v2 is required; detected '${cgroupVersion || "unknown"}'`, - ); - } - const hostOs = textField(host, "os", "OS").toLowerCase(); - if (hostOs !== "linux") { - throw new PodmanHostPreflightError( - `the Podman service must run Linux; detected '${hostOs || "unknown"}'`, - ); - } - const reportedArchitecture = textField(host, "arch", "Arch").toLowerCase(); - const normalizedArchitecture = normalizeArchitecture(reportedArchitecture); - if (!normalizedArchitecture) { + const identity = qualifyPodmanHostIdentity(info, options); + + return Object.freeze({ + providerId: "podman", + clientVersion, + serverVersion, + ...identity, + }); +} + +/** Qualify one exact socket-bound Podman client and service for schema-specific authority. */ +export function qualifyPodmanEndpointHost( + engine: ContainerEngine, + options: PodmanEndpointHostQualificationOptions, +): PodmanHostPreflightReceipt { + if (engine.operation !== "state-mutation" || engine.engineId !== "podman") { throw new PodmanHostPreflightError( - `the Podman service must report amd64 or arm64; detected '${reportedArchitecture || "unknown"}'`, + "endpoint qualification requires a Podman state-mutation engine", ); } - const expectedArchitecture = architecture === "x64" ? "amd64" : "arm64"; - if (normalizedArchitecture !== expectedArchitecture) { + requireSupportedHostPlatform(options); + const versionResult = requireSuccessful( + "client and server version inspection", + engine.capture(["version", "--format", "json"], 10_000), + ); + const versionInfo = parseJsonResult(versionResult, "version information"); + const clientVersion = requireExactVersion( + field(field(versionInfo, "Client", "client"), "Version", "version"), + "client", + options.expectedVersion, + ); + const serverVersion = requireExactVersion( + field(field(versionInfo, "Server", "server"), "Version", "version"), + "server", + options.expectedVersion, + ); + const identity = qualifyPodmanHostIdentity( + inspectInfo(engine, "rootless API inspection"), + options, + ); + if (identity.networkBackend !== options.expectedNetworkBackend) { throw new PodmanHostPreflightError( - `the Podman service architecture '${normalizedArchitecture}' does not match host '${expectedArchitecture}'`, + `network backend must be '${options.expectedNetworkBackend}'; detected '${identity.networkBackend}'`, ); } - requireSubordinateIdMappings(host); - return Object.freeze({ providerId: "podman", clientVersion, serverVersion, - rootless: true, - cgroupVersion: "v2", - os: "linux", - architecture: normalizedArchitecture, - networkBackend: textField(host, "networkBackend", "NetworkBackend") || "unknown", + ...identity, }); } diff --git a/src/lib/onboard/sandbox-create-intent-resolution.ts b/src/lib/onboard/sandbox-create-intent-resolution.ts index 88f2f868d20..2d34fe67553 100644 --- a/src/lib/onboard/sandbox-create-intent-resolution.ts +++ b/src/lib/onboard/sandbox-create-intent-resolution.ts @@ -12,7 +12,11 @@ import { } from "./sandbox-create-intent"; import type { SandboxCreateIntent } from "./sandbox-create-intent-types"; import { resolveSandboxCreatePolicyTier } from "./sandbox-create-plan"; -import { validateSandboxCreateIntentBindings } from "./sandbox-create-plan-materialization"; +import { + selectHermesPortableExtraProviderPlan, + selectHermesPortableMessagingCapabilities, + validateSandboxCreateIntentBindings, +} from "./sandbox-create-plan-materialization"; import { buildSandboxGpuCreateArgs, type SandboxGpuCreateConfig } from "./sandbox-gpu-create"; import { prepareSandboxMessagingPreflight, @@ -45,7 +49,10 @@ export interface SandboxCreateIntentResolverDeps { filterEnabledChannelsByAgent(enabledChannels: string[] | null, agent: Agent): string[] | null; defaultPolicyPath: string; getAgentPolicyPath(agent: Agent): string | null; - resolveGpuPlan(config: SandboxGpuCreateConfig): { + resolveGpuPlan( + config: SandboxGpuCreateConfig, + agent: Agent, + ): { gpuRoutePlan: DockerGpuRoutePlan; logMessage: string | null; }; @@ -116,6 +123,7 @@ export function createSandboxCreateIntentResolver< const messaging = await prepareMessagingCapabilities(input); const { gpuRoutePlan, logMessage: sandboxGpuLogMessage } = deps.resolveGpuPlan( input.sandboxGpuConfig, + input.agent, ); const resourceCreateArgs: string[] = []; deps.appendResourceCreateArgs(resourceCreateArgs, input.resourceProfile); @@ -150,8 +158,43 @@ export function createSandboxCreateIntentResolver< }); } + async function resolvePortableLifecycle( + input: Omit< + CompleteSandboxCreateIntentInput, + "extraProviders" | "staleExtraProviders" + >, + options: { + readonly hermesPortable: boolean; + readonly requestedExtraProviders?: readonly string[]; + readonly resolvedIntent?: SandboxCreateIntent; + readonly planOrdinaryExtraProviders: () => { + readonly extraProviders: readonly string[]; + readonly staleExtraProviders: readonly string[]; + }; + }, + ) { + const extraProviderPlan = selectHermesPortableExtraProviderPlan( + options.hermesPortable, + options.requestedExtraProviders, + options.planOrdinaryExtraProviders, + ); + const intent = + options.resolvedIntent ?? + (await resolve({ + ...input, + extraProviders: extraProviderPlan.extraProviders, + staleExtraProviders: extraProviderPlan.staleExtraProviders, + })); + const messagingCapabilities = await selectHermesPortableMessagingCapabilities( + options.hermesPortable, + () => prepareMessagingCapabilities(input, intent), + ); + return { intent, messagingCapabilities }; + } + return { resolve, + resolvePortableLifecycle, rebind: prepareMessagingCapabilities, prepareCredentialProviders: ( input: Pick< diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index 54365f19b5c..ea5b3e3cc32 100644 --- a/src/lib/onboard/sandbox-create-launch.ts +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -1,15 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { AgentDefinition } from "../agent/defs"; -import { formatEnvAssignment } from "../core/url-utils"; +import type { AgentDefinition } from "../agent/definition-types"; import { buildSubprocessEnv } from "../subprocess-env"; -import { isValidProxyHost, isValidProxyPort } from "./dockerfile-patch"; -import { appendExtraPlaceholderKeysEnvArg } from "./extra-placeholder-keys"; -import { HERMES_API_PORT_ENV, resolveOnboardHermesApiPort } from "./hermes-api-port"; +import { + buildSandboxRuntimeEnvArgs, + type SandboxRuntimeEnvArgsInput, +} from "./docker-startup-command-env"; import type { HermesDashboardOnboardState } from "./hermes-dashboard"; -import { appendHermesDashboardEnvArgs } from "./hermes-dashboard"; -import { appendHostProxyEnvArgs } from "./host-proxy-env"; import { createManagedBootstrapIdentity, MANAGED_BOOTSTRAP_IDENTITY_ENV, @@ -17,7 +15,6 @@ import { } from "./managed-bootstrap/adapter"; import { MANAGED_STARTUP_EXECUTABLE } from "./managed-startup/hold"; import type { ManagedStartupRootApplyRequest } from "./managed-startup/root-apply"; -import { appendOpenClawRuntimeEnvArgs } from "./openclaw-runtime-env"; import { prebuildSandboxImageIfEligible, type SandboxPrebuildInput, @@ -33,78 +30,6 @@ export const OPENSHELL_SANDBOX_SUPERVISOR_ARGV = Object.freeze([ "/sandbox", ] as const); -// These non-secret scheduler controls are intentionally forwarded for bounded -// live-test and operator tuning. Keep this as an exact allowlist: the host's -// broader NEMOCLAW_* environment must not become sandbox runtime input. -const OPENCLAW_AUTO_PAIR_RUNTIME_ENV_KEYS = [ - "NEMOCLAW_AUTO_PAIR_DEADLINE_SECS", - "NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS", - "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS", - "NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS", - "NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS", - "NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS", -] as const; - -// This opt-in emits MCP success timing events from the reviewed OpenClaw -// dist patch. Accept only the literal enabled value, and only for OpenClaw, so -// the broader host environment never becomes sandbox runtime input. -const OPENCLAW_DIAGNOSTIC_RUNTIME_ENV_KEYS = ["NEMOCLAW_MCP_SHADOW_DIAGNOSTICS"] as const; -const OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV = "NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS"; -const OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS = 1500; -const OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS = 10_000; - -function appendOpenClawAutoPairRuntimeEnvArgs( - envArgs: string[], - agent: AgentDefinition | null, - env: NodeJS.ProcessEnv, -): void { - // A null definition is the legacy OpenClaw path; keep this aligned with - // appendOpenClawRuntimeEnvArgs and the auto-pair compatibility settings. - if (agent && agent.name !== "openclaw") return; - for (const key of OPENCLAW_AUTO_PAIR_RUNTIME_ENV_KEYS) { - const value = env[key]?.trim(); - if (value) envArgs.push(formatEnvAssignment(key, value)); - } -} - -function appendOpenClawDiagnosticRuntimeEnvArgs( - envArgs: string[], - agent: AgentDefinition | null, - env: NodeJS.ProcessEnv, -): void { - if (agent && agent.name !== "openclaw") return; - for (const key of OPENCLAW_DIAGNOSTIC_RUNTIME_ENV_KEYS) { - if (env[key]?.trim() === "1") envArgs.push(formatEnvAssignment(key, "1")); - } -} - -function appendOpenClawMcpToolsListTimeoutRuntimeEnvArg( - envArgs: string[], - agent: AgentDefinition | null, - env: NodeJS.ProcessEnv, -): void { - if (agent && agent.name !== "openclaw") return; - const raw = env[OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV]; - if (raw === undefined || raw.trim() === "") return; - const value = raw.trim(); - if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) { - throw new Error( - `${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV} must be an integer from ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS} to ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS} milliseconds.`, - ); - } - const timeoutMs = Number(value); - if ( - !Number.isSafeInteger(timeoutMs) || - timeoutMs < OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS || - timeoutMs > OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS - ) { - throw new Error( - `${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV} must be an integer from ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MIN_MS} to ${OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_MAX_MS} milliseconds.`, - ); - } - envArgs.push(formatEnvAssignment(OPENCLAW_MCP_TOOLS_LIST_TIMEOUT_ENV, String(timeoutMs))); -} - export interface SandboxCreateLaunchInput { agent: AgentDefinition | null | undefined; observabilityEnabled?: boolean; @@ -189,110 +114,7 @@ function managedBootstrapCreateArgs( return [...createArgs, "--env", `${assignmentPrefix}${bootstrapIdentity}`]; } -export interface SandboxRuntimeEnvArgsInput { - agent: AgentDefinition | null; - chatUiUrl: string; - manageDashboard: boolean; - getDashboardForwardPort(chatUiUrl: string): string; - hermesDashboardState: HermesDashboardOnboardState; - /** Host port this sandbox exposes its OpenAI-compatible API on. */ - hermesApiPort?: number | null; - extraPlaceholderKeys: readonly string[]; - /** Allow a create/recreate launch to replace a registered Hermes API port. */ - allowHermesApiPortOverride?: boolean; - observabilityEnabled?: boolean; - sandboxName?: string; - env: NodeJS.ProcessEnv; - omitCredentialEnv?: boolean; -} - -export function buildSandboxRuntimeEnvArgs(input: SandboxRuntimeEnvArgsInput): { - envArgs: string[]; - effectiveDashboardPort: string; -} { - const { agent, env, manageDashboard } = input; - const envArgs = manageDashboard ? [formatEnvAssignment("CHAT_UI_URL", input.chatUiUrl)] : []; - - // When manageDashboard is enabled, pass the effective dashboard port into - // the sandbox so nemoclaw-start.sh starts the gateway on the correct port. - // If CHAT_UI_URL has a custom port (e.g. :18790), that port must reach the - // container; otherwise _DASHBOARD_PORT defaults to 18789 and the gateway - // listens on the wrong port. With manageDashboard disabled, CHAT_UI_URL and - // _DASHBOARD_PORT are intentionally not injected. (#2267, #1925) - const effectiveDashboardPort = manageDashboard - ? input.getDashboardForwardPort(input.chatUiUrl) - : "0"; - if (manageDashboard) { - envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); - if (env.NEMOCLAW_DASHBOARD_BIND === "0.0.0.0") { - envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0")); - } - } - - appendOpenClawRuntimeEnvArgs(envArgs, agent); - appendOpenClawAutoPairRuntimeEnvArgs(envArgs, agent, env); - appendOpenClawDiagnosticRuntimeEnvArgs(envArgs, agent, env); - appendOpenClawMcpToolsListTimeoutRuntimeEnvArg(envArgs, agent, env); - appendHermesDashboardEnvArgs(envArgs, input.hermesDashboardState, formatEnvAssignment); - // The sandbox and its host forward share the API port number, so the - // allocated value has to reach start.sh before the socat relay binds. - if (agent?.name === "hermes" && input.sandboxName) { - const apiPort = - input.hermesApiPort ?? - resolveOnboardHermesApiPort(input.sandboxName, { - env, - warn: console.warn, - allowRegisteredOverride: input.allowHermesApiPortOverride, - }); - envArgs.push(formatEnvAssignment(HERMES_API_PORT_ENV, String(apiPort))); - } - appendHostProxyEnvArgs(envArgs, env, { - dropCredentialBearingProxyUrls: - agent?.name === "langchain-deepagents-code" || input.omitCredentialEnv === true, - }); - - // Propagate NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT to runtime containers - // that consume them from sandbox-create env. patchStagedDockerfile() also - // substitutes the validated build args; dcode pins that build-time source in - // root-owned image files instead of trusting this runtime copy. Keep both - // paths in sync for the other agent images that still consume runtime env. - // Fixes #2424. Uses the shared isValidProxyHost / isValidProxyPort - // helpers so build-time and runtime validation stay aligned. - const sandboxProxyHost = env.NEMOCLAW_PROXY_HOST; - if (sandboxProxyHost && isValidProxyHost(sandboxProxyHost)) { - envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_HOST", sandboxProxyHost)); - } - const sandboxProxyPort = env.NEMOCLAW_PROXY_PORT; - if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { - envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); - } - - // Every sandbox needs to know its own name at runtime, not only the LangChain - // Deep Agents Code image. OpenShell exports OPENSHELL_SANDBOX as the boolean - // "1" to the processes it spawns inside the sandbox, so this injection is the - // only in-container source of the name. nemoclaw-start.sh bakes it into the - // connect-shell env so the in-sandbox hints can print a copyable host-side - // `nemoclaw …` command instead of a `` placeholder. (#7795) - const sandboxName = input.sandboxName; - if (sandboxName) { - envArgs.push(formatEnvAssignment("NEMOCLAW_SANDBOX_NAME", sandboxName)); - } - - if (agent?.name === "langchain-deepagents-code") { - envArgs.push( - formatEnvAssignment( - "NEMOCLAW_OBSERVABILITY", - input.observabilityEnabled === true ? "1" : "0", - ), - ); - } - - if (!input.omitCredentialEnv) { - appendExtraPlaceholderKeysEnvArg(envArgs, input.extraPlaceholderKeys, formatEnvAssignment); - } - - return { envArgs, effectiveDashboardPort }; -} +export { buildSandboxRuntimeEnvArgs, type SandboxRuntimeEnvArgsInput }; export function prepareSandboxCreateLaunch(input: SandboxCreateLaunchInput): SandboxCreateLaunch { const env = input.env ?? process.env; diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index 8cd982c5eb9..6cc4ea550c8 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -66,6 +66,42 @@ export type SandboxCreatePlan = { sandboxGpuLogMessage: string | null; }; +export function selectHermesPortableExtraProviderPlan( + hermesPortable: boolean, + requested: readonly string[] | undefined, + planOrdinary: () => { + readonly extraProviders: readonly string[]; + readonly staleExtraProviders: readonly string[]; + }, +): { readonly extraProviders: readonly string[]; readonly staleExtraProviders: readonly string[] } { + if (hermesPortable) { + return { extraProviders: [...(requested ?? [])], staleExtraProviders: [] }; + } + return requested ? { extraProviders: [...requested], staleExtraProviders: [] } : planOrdinary(); +} + +export async function selectHermesPortableMessagingCapabilities( + hermesPortable: boolean, + rebindOrdinary: () => Promise<{ + readonly messagingTokenDefs: MessagingTokenDef[]; + readonly hasMessagingTokens: boolean; + }>, +): Promise<{ + readonly messagingTokenDefs: MessagingTokenDef[]; + readonly hasMessagingTokens: boolean; +}> { + return hermesPortable + ? { messagingTokenDefs: [], hasMessagingTokens: false } + : await rebindOrdinary(); +} + +export function applyOrdinaryExtraProviderReconciliation( + hermesPortable: boolean, + reconcile: () => void, +): void { + if (!hermesPortable) reconcile(); +} + function getInitialSandboxCreatePolicy( ...args: Parameters ): ReturnType { @@ -74,6 +110,14 @@ function getInitialSandboxCreatePolicy( return prepareInitialSandboxCreatePolicy(...args); } +function getHermesPortableInitialSandboxPolicy( + ...args: Parameters +): ReturnType { + const { planHermesPortableInitialSandboxPolicy } = + require("./initial-policy") as typeof import("./initial-policy"); + return planHermesPortableInitialSandboxPolicy(...args); +} + function messagingProviderRequestKey( request: Pick, ): string { @@ -238,3 +282,61 @@ export function materializeSandboxCreatePlan({ sandboxGpuLogMessage: intent.sandboxGpuLogMessage, }; } + +/** Build the schema-5 create plan without provider, filesystem, Docker, or prebuild effects. */ +export function materializeHermesPortableCreatePlan(input: { + readonly intent: SandboxCreateIntent; + readonly fromRef: string; +}): SandboxCreatePlan { + const { intent, fromRef } = input; + if ( + intent.policy.options.agentName !== "hermes" || + !["none", "native-only"].includes(intent.gpuRoutePlan) || + (intent.hostMounts?.length ?? 0) > 0 || + intent.activeMessagingChannels.length > 0 || + intent.messagingProviderRequests.length > 0 || + intent.reusableMessagingProviders.length > 0 || + intent.extraProviders.length > 0 || + intent.staleExtraProviders.length > 0 || + intent.hermesToolGateways.length > 0 + ) { + throw new Error( + "Hermes portable create intent includes an effect that is not owned by its schema-5 receipt.", + ); + } + const initialSandboxPolicy = getHermesPortableInitialSandboxPolicy( + intent.policy.basePolicyPath, + [...intent.policy.activeMessagingChannels], + { + directGpu: intent.policy.options.directGpu, + hostGpuAvailable: intent.policy.options.hostGpuAvailable, + additionalPresets: intent.policy.options.hostLocalInferenceRouteOnly + ? intent.policy.options.additionalPresets.filter((name) => name !== "local-inference") + : [...intent.policy.options.additionalPresets], + agentName: "hermes", + policyTier: intent.policy.options.policyTier, + baselineExclusions: intent.policy.options.baselineExclusions.map((entry) => ({ ...entry })), + }, + ); + const createArgs = [ + "--from", + fromRef, + "--name", + intent.sandboxName, + "--policy", + initialSandboxPolicy.policyPath, + ...intent.gpuCreateArgs, + ...intent.resourceCreateArgs, + ]; + if (intent.inferenceProvider) createArgs.push("--provider", intent.inferenceProvider); + return { + activeMessagingChannels: [], + initialSandboxPolicy, + policyTier: intent.policy.options.policyTier, + createArgs, + messagingProviders: [], + gpuRoutePlan: intent.gpuRoutePlan, + compatibilityPolicyPath: null, + sandboxGpuLogMessage: intent.sandboxGpuLogMessage, + }; +} diff --git a/src/lib/onboard/sandbox-create/orchestration.ts b/src/lib/onboard/sandbox-create/orchestration.ts new file mode 100644 index 00000000000..40f4c570ff9 --- /dev/null +++ b/src/lib/onboard/sandbox-create/orchestration.ts @@ -0,0 +1,1174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxCreateOrchestrationRuntime } from "../../onboard"; +import type { AgentDefinition } from "../../agent/defs"; +import type { WebSearchConfig } from "../../inference/web-search"; +import type { BackupResult } from "../../state/sandbox"; +import type { SandboxEntry } from "../../state/registry"; +import type { HermesAuthMethod } from "../hermes-auth"; +import type { PreparedSandboxBuildContext } from "../build-context-stage"; +import type { OwnedSandboxRecreateRuntime } from "../onboard-recreate-journal"; +import type { SandboxGpuConfig } from "../sandbox-gpu-mode"; +import type { PortableOnboardRuntimeContext } from "../session-bootstrap"; +import type { InferenceRouteReservationAuthority } from "../types"; +import * as sandboxCreatePlanMaterialization from "../sandbox-create-plan-materialization"; + +type SandboxRecreateReasonInput = { + sandboxName: string; + recreateForAgentDrift: boolean; + existingAgentName: string | null | undefined; + requestedAgentName: string | null | undefined; + needsProviderMigration: boolean; + actionableSelectionDrift: boolean; + sandboxGpuDrift: boolean; + hermesToolGatewayDrift: boolean; + hermesDashboardDrift: boolean; + observabilityDrift: boolean; + dcodeAutoApprovalDrift: boolean; + toolDisclosureMigrationNote: string | null | undefined; + credentialRotationChanged: boolean; + existingSandboxState: string; +}; + +function reportSandboxRecreateReason( + input: SandboxRecreateReasonInput, + deps: { + formatSandboxAgentName(agentName: string | null | undefined): string; + note(message: string): void; + }, +): void { + const { sandboxName } = input; + if (input.recreateForAgentDrift) { + deps.note( + ` Sandbox '${sandboxName}' exists as ${deps.formatSandboxAgentName(input.existingAgentName)} — recreating as ${deps.formatSandboxAgentName(input.requestedAgentName)}.`, + ); + } else if (input.needsProviderMigration) { + console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); + console.log(" Recreating to ensure credentials flow through the provider pipeline."); + } else if (input.actionableSelectionDrift) { + deps.note( + ` Sandbox '${sandboxName}' exists — recreating because its live model/provider selection is stale or unreadable.`, + ); + } else if (input.sandboxGpuDrift) { + deps.note(` Sandbox '${sandboxName}' exists — recreating to apply sandbox GPU settings.`); + } else if (input.hermesToolGatewayDrift) { + deps.note( + ` Sandbox '${sandboxName}' exists — recreating to apply Hermes managed-tool changes.`, + ); + } else if (input.hermesDashboardDrift) { + deps.note(` Sandbox '${sandboxName}' exists — recreating to apply Hermes dashboard settings.`); + } else if (input.observabilityDrift) { + deps.note(` Sandbox '${sandboxName}' exists — recreating to apply observability settings.`); + } else if (input.dcodeAutoApprovalDrift) { + deps.note( + ` Sandbox '${sandboxName}' exists — recreating to apply DCode auto-approval settings.`, + ); + } else if (input.toolDisclosureMigrationNote) { + deps.note(input.toolDisclosureMigrationNote); + } else if (input.credentialRotationChanged) { + // Message already printed above during backup. + } else if (input.existingSandboxState === "ready") { + deps.note(` Sandbox '${sandboxName}' exists and is ready — recreating by explicit request.`); + } else { + deps.note(` Sandbox '${sandboxName}' exists but is not ready — recreating it.`); + } +} + +export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrchestrationRuntime) { + return async function createSandboxWithBaseImageResolution( + baseImageResolutionContext: import("../base-image-resolution-flow").BaseImageResolutionContext, + portableRuntimeContext: PortableOnboardRuntimeContext | null, + computePlan: import("../compute/plan").OpenShellComputePlan, + managedWorkloadRebuild: import("../workload/rebuild").ManagedWorkloadRebuildHandoff | null, + tempManagedRuntime: boolean, + tempManagedRuntimeCatalog: string | null, + dashboardPortReservationScope: import("../dashboard-port").DashboardPortReservationScope, + hermesApiPortReservationScope: import("../../agent/onboard").HermesApiPortReservationScope, + gpu: ReturnType, + model: string, + provider: string, + preferredInferenceApi: string | null = null, + sandboxNameOverride: string | null = null, + webSearchConfig: WebSearchConfig | null = null, + enabledChannels: string[] | null = null, + fromDockerfile: string | null = null, + agent: AgentDefinition | null = null, + controlUiPort: number | null = null, + sandboxGpuConfig: SandboxGpuConfig | null = null, + resourceProfile: import("../../resources-cmd").ResourceProfile | null = null, + hermesToolGateways: string[] = [], + hermesAuthMethod: HermesAuthMethod | null = null, + inferenceRouteReservationAuthority: InferenceRouteReservationAuthority | null = null, + createIntent: import("../types").SandboxCreateIntent | null = null, + preparedBuildContext: PreparedSandboxBuildContext | null = null, + ) { + const portableRuntimeAuthority = portableRuntimeContext?.authority ?? null; + const { + DASHBOARD_PORT, + GATEWAY_NAME, + GATEWAY_PORT, + ROOT, + SCRIPTS, + agentDefs, + agentOnboard, + applyExtraProviderReconciliation, + assessHost, + baseImageResolutionFlow, + cliDisplayName, + cliName, + completeOrdinaryOnboardSandboxCreation, + confirmRecreateForSelectionDrift, + createOnboardCreatedSandboxCompletion, + createOnboardCreatedSandboxRegistration, + createSandboxRecreateProtection, + dashboardRuntime, + dcodeAutoApprovalFlow, + detectMessagingCredentialRotation, + ensureAgentFixedForward, + ensureDashboardForward, + filterEnabledChannelsByAgent, + formatSandboxAgentName, + formatSandboxBuildEstimateNote, + getDashboardForwardPort, + getDcodeSelectionDrift, + getDefaultSandboxNameForAgent, + getDockerDriverGatewayStateDir, + getHermesToolGatewayBroker, + getRequestedSandboxAgentName, + getSandboxAgentDrift, + getSandboxRecreateObservation, + getSandboxReuseState, + getSandboxRuntimeRegistryFields, + getSelectionDrift, + hasSandboxGpuDrift, + inferenceConfig, + inspectSandboxForCreate, + isLinuxDockerDriverGatewayEnabled, + isNonInteractive, + isRecreateSandbox, + isWsl, + managedWorkloadOnboard, + messagingChannelSetup, + nim, + normalizeHermesAuthMethod, + normalizeHermesToolGatewaySelections, + note, + observabilityCommandFlag, + observabilityPolicy, + onboardHermesDashboard, + onboardSession, + onboardSessionBootstrap, + openshellArgv, + path, + planRegisteredExtraProviders, + policyPresetCarry, + preparedDcodeRebuild, + promptValidatedSandboxName, + promptYesNoOrDefault, + providerExistsInGateway, + recreateJournal, + registry, + requiresSelectionRecreate, + reserveCreateSandboxDashboardPort, + resolveSandboxGpuConfig, + runCaptureOpenshell, + runOpenshell, + runSandboxProviderPreDeleteCleanup, + sandboxAgent, + sandboxBuildPatchConfig, + sandboxCancelRollback, + sandboxCreateIntentResolver, + sandboxGpuCreateFlow, + sandboxLifecycle, + sandboxMutationLock, + sandboxRecreateTransaction, + sandboxRegistration, + sandboxRegistryMetadata, + sandboxReuse, + shouldSkipPreRecreateBackup, + sleepSeconds, + step, + stringSetsEqual, + toolDisclosureFlow, + upsertMessagingProviders, + usesManagedDcodeIdentity, + validateName, + verifyDirectSandboxGpu, + waitForSandboxRecreateDeleteAbsence, + wasSandboxDefault, + updateReusedSandboxMetadata, + getSandboxInferenceConfig, + redact, + openshellShellCommand, + discloseInitialSandboxPolicy, + compactText, + runFile, + dockerInfoFormat, + runCapture, + } = runtime; + + step(6, 8, "Creating sandbox"); + const sandboxName = validateName( + sandboxNameOverride ?? (await promptValidatedSandboxName(agent)), + "sandbox name", + ); + preparedDcodeRebuild.assertPreparedDcodeTarget(preparedBuildContext, agent, fromDockerfile); + const effectiveAgent = sandboxAgent.getEffectiveSandboxAgent(agent); + const requestedAgentName = getRequestedSandboxAgentName(effectiveAgent); + const legacyDockerfilePath = + effectiveAgent.dockerfilePath ?? + effectiveAgent.legacyPaths?.dockerfile ?? + path.join(ROOT, "Dockerfile"); + enabledChannels = filterEnabledChannelsByAgent(enabledChannels, agent); + const effectiveSandboxGpuConfig = + sandboxGpuConfig ?? resolveSandboxGpuConfig(gpu, { flag: null, device: null }); + const agentCreateInput = sandboxGpuCreateFlow.resolveAgentCreateInput( + agent, + isLinuxDockerDriverGatewayEnabled(), + ); + const preparedCreateIntent = await sandboxCreateIntentResolver.resolvePortableLifecycle( + { + sandboxName, + inferenceProvider: provider, + enabledChannels, + webSearchConfig, + agent, + sandboxGpuConfig: effectiveSandboxGpuConfig, + resourceProfile, + hermesToolGateways, + baselineExclusions: sandboxRegistration.baselineExclusionsForCreate(sandboxName), + ...(createIntent?.reuseRegisteredCredentials ? { reuseRegisteredCredentials: true } : {}), + ...(createIntent?.policyTier !== undefined ? { policyTier: createIntent.policyTier } : {}), + }, + { + hermesPortable: agentCreateInput.hermesPortableLifecycle, + requestedExtraProviders: createIntent?.extraProviders, + resolvedIntent: createIntent?.resolved, + planOrdinaryExtraProviders: () => + planRegisteredExtraProviders(GATEWAY_NAME, { runOpenshell }), + }, + ); + const resolvedCreateIntent = preparedCreateIntent.intent; + const messagingCapabilities = preparedCreateIntent.messagingCapabilities; + const manageDashboard = sandboxGpuCreateFlow.shouldManageHermesPortableDashboard( + dashboardRuntime.shouldManageDashboardForAgent(agent), + agent, + ); + const isManagedDcodeAgent = usesManagedDcodeIdentity(agent?.name, fromDockerfile); + let effectivePort = 0, + chatUiUrl = "", + hermesApiPortReservationInput = { + agentName: agent?.name, + sandboxName, + env: process.env, + getSandbox: registry.getSandbox, + captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }), + warn: (message: string) => console.warn(message), + }; + if (manageDashboard) { + const dashboardSelection = await reserveCreateSandboxDashboardPort({ + sandboxName, + controlUiPort, + chatUiUrlEnv: process.env.CHAT_UI_URL, + persistedPort: registry.getSandbox(sandboxName)?.dashboardPort ?? null, + agentForwardPort: dashboardRuntime.getAgentPrimaryForwardPort(agent, DASHBOARD_PORT), + defaultPort: DASHBOARD_PORT, + forwardListOutput: runCaptureOpenshell(["forward", "list"], { ignoreError: true }), + warn: (message: string) => console.warn(message), + }); + ({ effectivePort, chatUiUrl } = dashboardSelection); + dashboardPortReservationScope.current = dashboardSelection.reservation; + } + const hermesDashboardForwarding = onboardHermesDashboard.createHermesDashboardOnboardForwarding( + { + agentName: agent?.name, + env: process.env, + ensureForward: ensureAgentFixedForward, + note, + runOpenshell, + getApiForwardPort: () => getDashboardForwardPort(chatUiUrl), + }, + ); + const hermesDashboardState = hermesDashboardForwarding.resolveStateForPort(effectivePort); + const { messagingTokenDefs, hasMessagingTokens } = messagingCapabilities; + + const { + existingEntry, + preservedMcpState, + liveExists, + effectiveToolDisclosure, + toolDisclosureMigrationNeeded, + toolDisclosureMigrationNote, + } = agentCreateInput.hermesPortableLifecycle + ? toolDisclosureFlow.prepareHermesPortableToolDisclosure(createIntent?.toolDisclosure ?? null) + : toolDisclosureFlow.prepareSandboxToolDisclosure( + sandboxName, + preparedBuildContext?.rebuildTarget?.fromDockerfile + ? preparedBuildContext.stagedDockerfile + : fromDockerfile, + isRecreateSandbox(createIntent?.recreate), + inspectSandboxForCreate, + createIntent?.toolDisclosure ?? null, + ); + let recreateRuntime: + | import("../sandbox-recreate-transaction").SandboxRecreateRuntime + | OwnedSandboxRecreateRuntime = sandboxRecreateTransaction.createSandboxRecreateRuntime( + onboardSession, + createIntent?.recreateTransaction, + sandboxName, + GATEWAY_NAME, + existingEntry, + getSandboxRecreateObservation, + note, + ); + const restoreReusedSandboxDashboard = async (selectionVerified: boolean): Promise => { + await dashboardPortReservationScope.release(); + ({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({ + sandboxName, + chatUiUrl, + env: process.env, + agent, + model, + provider, + selectionVerified, + sandboxGpuConfig: effectiveSandboxGpuConfig, + gatewayName: GATEWAY_NAME, + gatewayPort: GATEWAY_PORT, + manageDashboard, + ensureDashboardForward, + hermesDashboardForwarding, + updateReusedSandboxMetadata, + })); + }; + if (recreateRuntime.acceptedTarget) { + await restoreReusedSandboxDashboard(true); + return sandboxName; + } + const observabilityDrift = observabilityPolicy.hasRegisteredDcodeObservabilityDrift( + liveExists, + isManagedDcodeAgent, + existingEntry, + createIntent?.observabilityEnabled, + ); + const dcodeAutoApprovalPlan = dcodeAutoApprovalFlow.prepareDcodeAutoApprovalCreatePlan( + { + sandboxName, + liveExists, + managedDcodeAgent: isManagedDcodeAgent, + registryEntry: existingEntry, + requestedMode: createIntent?.dcodeAutoApprovalMode, + }, + { error: console.error, exitProcess: (code) => process.exit(code) }, + ); + const envMessagingState = + messagingChannelSetup.MessagingHostStateApplier.readPlanStateFromEnv(); + const plannedMessagingState = + envMessagingState?.plan.sandboxName === sandboxName ? envMessagingState : undefined; + const managedWorkloadRuntime = managedWorkloadOnboard.createManagedWorkloadOnboardRuntime( + { + computePlan, + managedWorkloadRebuild, + tempManagedRuntime, + tempManagedRuntimeCatalog, + agentName: requestedAgentName, + legacyDockerfilePath, + customDockerfilePath: + fromDockerfile ?? (preparedBuildContext ? preparedBuildContext.stagedDockerfile : null), + rootDir: ROOT, + model, + provider, + preferredInferenceApi, + endpointUrl: createIntent?.endpointUrl ?? null, + startupProfile: { + chatUiUrl, + effectiveDashboardPort: effectivePort, + manageDashboard, + dashboardBindAddress: process.env.NEMOCLAW_DASHBOARD_BIND, + wslExposure: requestedAgentName === "openclaw" && isWsl(), + hermesDashboardState, + webSearch: webSearchConfig, + toolDisclosure: effectiveToolDisclosure, + hermesToolGateways, + messagingPlan: plannedMessagingState?.plan ?? null, + dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode, + observabilityEnabled: createIntent?.observabilityEnabled === true, + environment: process.env, + }, + note, + fallbackBuildEstimate: () => + process.env.NEMOCLAW_IGNORE_RUNTIME_RESOURCES === "1" + ? null + : formatSandboxBuildEstimateNote(assessHost()), + }, + { + resolveAgentInferenceApi: inferenceConfig.resolveAgentInferenceApi, + getSandboxInferenceConfig, + }, + ); + const ensurePreparedSandboxWorkload = () => + agentCreateInput.hermesPortableLifecycle + ? managedWorkloadOnboard.prepareHermesPortableSandboxWorkloadForLifecycle( + managedWorkloadRuntime, + legacyDockerfilePath, + ) + : managedWorkloadOnboard.prepareSandboxWorkloadForPortableLifecycle( + managedWorkloadRuntime, + sandboxGpuCreateFlow.resolvePortableLifecycleMode(agent), + ); + const prepareHermesStateVolumeLifecycle = ( + workload: Awaited>, + ) => + managedWorkloadOnboard.createManagedHermesStateVolumeOnboardLifecycle({ + agentName: requestedAgentName, + runtimeProvider: managedWorkloadRuntime.runtimeProvider, + sandboxName, + workloadKind: workload.source.kind, + }); + // #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox. + const sandboxWasLiveDefault = + liveExists && wasSandboxDefault(registry.getDefault(), sandboxName); + + let pendingStateRestore: BackupResult | null = null; + let notReadyRecreateInProgress = false; + const customOpenClawImage = + Boolean(fromDockerfile) && getRequestedSandboxAgentName(agent) === "openclaw"; + const recreateProtection = createSandboxRecreateProtection({ + sandboxName, + sandboxEntry: existingEntry, + customOpenClawImage, + note, + }); + const openRecreateJournal = (): OwnedSandboxRecreateRuntime => + recreateJournal.openOnboardRecreateJournal({ + target: { sandboxName, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT }, + agentName: getRequestedSandboxAgentName(agent) || "openclaw", + note, + observe: (probeTarget) => + getSandboxRecreateObservation(probeTarget.sandboxName, probeTarget.gatewayName), + intent: { + agent: getRequestedSandboxAgentName(agent) || null, + fromDockerfile: fromDockerfile ?? null, + provider: provider ?? null, + model: model ?? null, + preferredInferenceApi: preferredInferenceApi ?? null, + sandboxGpuConfig: effectiveSandboxGpuConfig ?? null, + gatewayName: GATEWAY_NAME, + gatewayPort: GATEWAY_PORT, + toolDisclosure: effectiveToolDisclosure, + dcodeAutoApprovalMode: createIntent?.dcodeAutoApprovalMode ?? null, + observabilityEnabled: createIntent?.observabilityEnabled === true, + policyTier: createIntent?.policyTier ?? null, + }, + }); + let pendingStateRestoreBackupPath: string | null = null, + preparedSandboxWorkload!: Awaited>, + hermesStateVolumeLifecycle!: ReturnType; + if (!liveExists && existingEntry) + ({ runtime: recreateRuntime, backupPath: pendingStateRestoreBackupPath } = + recreateProtection.selectJournalBoundPreUpgradeBackup({ + runtime: recreateRuntime, + openJournal: createIntent?.recreateTransaction ? null : openRecreateJournal, + gatewayName: GATEWAY_NAME, + gatewayPort: GATEWAY_PORT, + readRegistryEntry: () => registry.getSandbox(sandboxName), + observe: () => getSandboxRecreateObservation(sandboxName, GATEWAY_NAME), + })); + + if (liveExists && !agentCreateInput.hermesPortableLifecycle) { + const existingSandboxState = getSandboxReuseState(sandboxName); + const agentDrift = getSandboxAgentDrift(sandboxName, requestedAgentName); + let recreateForAgentDrift = agentDrift.changed && isRecreateSandbox(createIntent?.recreate); + + if (agentDrift.changed && !isRecreateSandbox(createIntent?.recreate)) { + console.log( + ` Sandbox '${sandboxName}' already exists as ${formatSandboxAgentName(agentDrift.existingAgentName)}.`, + ); + console.log( + ` ${cliDisplayName()} is onboarding ${formatSandboxAgentName(agentDrift.requestedAgentName)} for this sandbox name.`, + ); + console.log( + " Side-by-side agents are supported, but each sandbox name has one agent type.", + ); + if (isNonInteractive()) { + console.error( + ` Aborting: choose a different name or set NEMOCLAW_RECREATE_SANDBOX=1 to recreate '${sandboxName}'.`, + ); + console.error( + ` Example: ${cliName()} onboard --name ${getDefaultSandboxNameForAgent(agent)}`, + ); + process.exit(1); + } + if ( + await promptYesNoOrDefault( + ` Delete and recreate '${sandboxName}' as ${formatSandboxAgentName(agentDrift.requestedAgentName)}?`, + null, + false, + ) + ) { + recreateForAgentDrift = true; + } else { + console.error(" Aborted. Existing sandbox left unchanged."); + console.error( + ` Re-run with a different name, for example: ${cliName()} onboard --name ${getDefaultSandboxNameForAgent(agent)}`, + ); + process.exit(1); + } + } + + // Check whether messaging providers are missing from the gateway. Only + // force recreation when at least one required provider doesn't exist yet — + // this avoids destroying sandboxes already created with provider attachments. + const needsProviderMigration = + hasMessagingTokens && + messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); + const selectionDrift = isManagedDcodeAgent + ? getDcodeSelectionDrift(sandboxName, provider, model, preferredInferenceApi, { + runCaptureOpenshell, + }) + : getSelectionDrift(sandboxName, provider, model, { runOpenshell }); + const actionableSelectionDrift = requiresSelectionRecreate( + selectionDrift, + isManagedDcodeAgent, + ); + const sandboxGpuDrift = hasSandboxGpuDrift(sandboxName, effectiveSandboxGpuConfig); + const existingSandboxEntry = registry.getSandbox(sandboxName); + const recordedHermesToolGateways = normalizeHermesToolGatewaySelections( + existingSandboxEntry?.hermesToolGateways, + ); + const hermesToolGatewayDrift = !stringSetsEqual( + recordedHermesToolGateways, + hermesToolGateways, + ); + const hermesDashboardDrift = onboardHermesDashboard.hasHermesDashboardDrift({ + agentName: agent?.name, + existing: existingSandboxEntry, + state: hermesDashboardState, + }); + + // Detect whether any messaging credential has been rotated since the + // sandbox was created. Provider credentials are resolved once at sandbox + // startup, so a rotated token requires a rebuild to take effect. + const credentialRotation = hasMessagingTokens + ? detectMessagingCredentialRotation(sandboxName, messagingTokenDefs) + : { changed: false, changedProviders: [] }; + + if ( + !isRecreateSandbox(createIntent?.recreate) && + !recreateForAgentDrift && + !needsProviderMigration && + !sandboxGpuDrift && + !credentialRotation.changed && + !hermesToolGatewayDrift && + !hermesDashboardDrift && + !toolDisclosureMigrationNeeded && + !observabilityDrift && + !dcodeAutoApprovalPlan.hasDrift + ) { + // Guard against reusing a CPU-only sandbox when GPU passthrough is enabled. + // Placed before the non-interactive / interactive split so all reuse + // paths are covered (interactive prompt, non-interactive ready, unknown drift). + // Note: legacy registries had gpuEnabled always true (bug fixed in this PR), + // so gpuEnabled=true on a legacy entry doesn't guarantee GPU support. + // The gateway Docker-inspect check (above) catches legacy CPU-only gateways + // before we reach this point, so a legacy sandbox behind a verified GPU + // gateway is safe to reuse — the sandbox will be recreated if needed. + if (effectiveSandboxGpuConfig.sandboxGpuEnabled) { + const entry = registry.getSandbox(sandboxName); + if (entry && !entry.gpuEnabled) { + console.error( + ` Sandbox '${sandboxName}' exists but was created without GPU passthrough.`, + ); + console.error( + " Pass --recreate-sandbox to recreate with GPU, or destroy and re-onboard:", + ); + console.error(` nemoclaw onboard --recreate-sandbox`); + process.exit(1); + } + } + + if (isNonInteractive()) { + if (existingSandboxState === "ready") { + if (actionableSelectionDrift) { + note(" [non-interactive] Recreating sandbox due to provider/model drift."); + } else { + policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); + // Upsert messaging providers even on reuse so credential changes take + // effect without requiring a full sandbox recreation. + upsertMessagingProviders(messagingTokenDefs); + if (selectionDrift.unknown) { + note( + " [non-interactive] Existing provider/model selection is unreadable; reusing sandbox.", + ); + note( + " [non-interactive] Set NEMOCLAW_RECREATE_SANDBOX=1 (or --recreate-sandbox) to force recreation.", + ); + } else { + note( + ` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`, + ); + note( + " Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.", + ); + } + await restoreReusedSandboxDashboard(!selectionDrift.unknown); + return sandboxName; + } + } else { + notReadyRecreateInProgress = true; + const outcome = recreateProtection.resolveNotReadyOutcome(); + if (outcome.kind === "blocked") { + for (const hint of outcome.hints) console.error(hint); + process.exit(1); + } + pendingStateRestoreBackupPath = outcome.restoreBackupPath; + } + } else if (existingSandboxState === "ready") { + if (actionableSelectionDrift) { + const confirmed = await confirmRecreateForSelectionDrift( + sandboxName, + selectionDrift, + provider, + model, + ); + if (!confirmed) { + console.error(" Aborted. Existing sandbox left unchanged."); + process.exit(1); + } + } else { + console.log(` Sandbox '${sandboxName}' already exists.`); + console.log(" Choosing 'n' will delete the existing sandbox and create a new one."); + if (await promptYesNoOrDefault(" Reuse existing sandbox?", null, true)) { + policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); + upsertMessagingProviders(messagingTokenDefs); + await restoreReusedSandboxDashboard(!selectionDrift.unknown); + return sandboxName; + } + } + } else { + console.log(` Sandbox '${sandboxName}' exists but is not ready.`); + console.log(" Selecting 'n' will abort onboarding."); + if (!(await promptYesNoOrDefault(" Delete it and create a new one?", null, true))) { + console.log(" Aborting onboarding."); + process.exit(1); + } + } + } + + if (credentialRotation.changed && existingSandboxState === "ready") { + const rotatedNames = credentialRotation.changedProviders.join(", "); + console.log(` Messaging credential(s) rotated: ${rotatedNames}`); + console.log(" Rebuilding sandbox to propagate new credentials to the L7 proxy..."); + if (!shouldSkipPreRecreateBackup(process.env)) { + const result = recreateProtection.backup(); + if (!result.ok) { + console.error( + " Set NEMOCLAW_RECREATE_WITHOUT_BACKUP=1 to recreate without preserving state.", + ); + process.exit(1); + } + pendingStateRestore = result.backup; + } + } + reportSandboxRecreateReason( + { + sandboxName, + recreateForAgentDrift, + existingAgentName: agentDrift.existingAgentName, + requestedAgentName: agentDrift.requestedAgentName, + needsProviderMigration, + actionableSelectionDrift, + sandboxGpuDrift, + hermesToolGatewayDrift, + hermesDashboardDrift, + observabilityDrift, + dcodeAutoApprovalDrift: dcodeAutoApprovalPlan.hasDrift, + toolDisclosureMigrationNote, + credentialRotationChanged: credentialRotation.changed, + existingSandboxState, + }, + { formatSandboxAgentName, note }, + ); + if (preservedMcpState) { + for (const hint of recreateJournal.managedMcpRecreateRefusalHints({ + sandboxName, + cliName: cliName(), + toolDisclosure: effectiveToolDisclosure, + rebuildFlag: dcodeAutoApprovalPlan.rebuildFlag, + observabilityFlag: observabilityCommandFlag.explicitObservabilityFlag( + createIntent?.observabilityEnabled === true, + createIntent?.observabilityRequestedExplicitly === true, + ), + })) + console.error(hint); + process.exit(1); + } + // Resolve and validate immutable workload authority before opening a recreate journal or + // mutating a live sandbox. + preparedSandboxWorkload = await ensurePreparedSandboxWorkload(); + await hermesApiPortReservationScope.selectAndReserve(hermesApiPortReservationInput); + if (!createIntent?.recreateTransaction) recreateRuntime = openRecreateJournal(); + if (recreateRuntime.acceptedTarget) { + if ("complete" in recreateRuntime) recreateRuntime.complete(); + await restoreReusedSandboxDashboard(true); + return sandboxName; + } + const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName); + baseImageResolutionFlow.captureBaseResolution( + baseImageResolutionContext, + previousEntry?.imageTag, + ); + policyPresetCarry.applyRecreatePolicyCarryForward(sandboxName, isNonInteractive(), note); + + const noRestorePending = + pendingStateRestore === null && pendingStateRestoreBackupPath === null; + if ( + noRestorePending && + !notReadyRecreateInProgress && + !shouldSkipPreRecreateBackup(process.env) + ) { + note(" Backing up workspace state before recreating sandbox..."); + const result = recreateProtection.backup(); + if (!result.ok) { + console.error( + " Set NEMOCLAW_RECREATE_WITHOUT_BACKUP=1 to recreate without preserving state.", + ); + process.exit(1); + } + pendingStateRestore = result.backup; + } + + hermesStateVolumeLifecycle = prepareHermesStateVolumeLifecycle(preparedSandboxWorkload); + note(` Deleting and recreating sandbox '${sandboxName}'...`); + + if (recreateRuntime.beginDelete() === "source") { + runSandboxProviderPreDeleteCleanup(sandboxName, { runOpenshell, redact }); + runOpenshell( + [ + "sandbox", + "delete", + "-g", + recreateRuntime.journaledGatewayName ?? GATEWAY_NAME, + sandboxName, + ], + { ignoreError: true }, + ); + if ( + !waitForSandboxRecreateDeleteAbsence( + sandboxName, + recreateRuntime.journaledGatewayName ?? GATEWAY_NAME, + note, + ) + ) + throw new Error( + `Cannot continue sandbox '${sandboxName}' recreation: OpenShell did not confirm explicit source absence after delete.`, + ); + } + recreateRuntime.confirmDeleted(); + sandboxLifecycle.removeSandboxUnlessSessionReservation(previousEntry, sandboxName); + await hermesApiPortReservationScope.rebindAfterOwnedForwardDelete( + hermesApiPortReservationInput, + ); + } + if (!liveExists || agentCreateInput.hermesPortableLifecycle) { + if (!agentCreateInput.hermesPortableLifecycle) { + await hermesApiPortReservationScope.selectAndReserve(hermesApiPortReservationInput); + } + preparedSandboxWorkload = await ensurePreparedSandboxWorkload(); + hermesStateVolumeLifecycle = prepareHermesStateVolumeLifecycle(preparedSandboxWorkload); + } + sandboxCreatePlanMaterialization.applyOrdinaryExtraProviderReconciliation( + agentCreateInput.hermesPortableLifecycle, + () => + applyExtraProviderReconciliation({ + extraProviders: resolvedCreateIntent.extraProviders, + staleExtraProviders: resolvedCreateIntent.staleExtraProviders ?? [], + }), + ); + const preparedOnboardLaunch = + await managedWorkloadOnboard.prepareSelectedOnboardSandboxWorkloadLaunch( + agentCreateInput.hermesPortableLifecycle, + () => + managedWorkloadOnboard.prepareHermesPortableOnboardSandboxLaunch({ + intent: resolvedCreateIntent, + fromRef: + preparedSandboxWorkload.source.kind === "legacy-dockerfile" + ? preparedSandboxWorkload.source.dockerfilePath + : "", + launchInput: { + agent, + observabilityEnabled: false, + chatUiUrl: "", + sandboxName, + env: process.env, + extraPlaceholderKeys: resolvedCreateIntent.extraPlaceholderKeys, + getDashboardForwardPort, + hermesDashboardState: { enabled: false, config: null }, + hermesApiPort: null, + manageDashboard: false, + openshellShellCommand, + openshellArgv, + }, + gpuConfig: effectiveSandboxGpuConfig, + }), + () => + managedWorkloadOnboard.prepareOnboardSandboxWorkloadLaunch({ + runtime: managedWorkloadRuntime, + workload: preparedSandboxWorkload, + legacy: { + preparedBuildContext, + agent, + fromDockerfile, + createAgentSandbox: (selectedAgent) => + baseImageResolutionFlow.createAgentSandboxWithResolution( + baseImageResolutionContext, + selectedAgent, + agentOnboard.createAgentSandbox, + ), + resolvePatchInput: () => ({ + preparedBuildContext, + agent, + fromDockerfile, + model, + chatUiUrl, + provider, + endpointUrl: createIntent?.endpointUrl ?? null, + compatibleEndpointReasoning: createIntent?.compatibleEndpointReasoning, + preferredInferenceApi, + webSearchConfig, + toolDisclosure: effectiveToolDisclosure, + rebuildPreservedEnv: createIntent?.rebuildPreservedEnv, + ...(isManagedDcodeAgent + ? { dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode } + : {}), + hermesToolGateways, + sandboxGpuConfig: effectiveSandboxGpuConfig, + ...baseImageResolutionFlow.getBaseImageResolutionPatchOptions( + baseImageResolutionContext, + ), + gatewayPort: GATEWAY_PORT, + }), + }, + plan: { + intent: resolvedCreateIntent, + rebindMessagingTokenDefs: async () => + ( + await sandboxCreateIntentResolver.rebind( + { + sandboxName, + enabledChannels, + webSearchConfig, + agent, + ...(createIntent?.reuseRegisteredCredentials + ? { reuseRegisteredCredentials: true } + : {}), + }, + resolvedCreateIntent, + ) + ).messagingTokenDefs, + runProviderPreDeleteCleanup: () => + runSandboxProviderPreDeleteCleanup(sandboxName, { + runOpenshell, + redact, + tolerateMissingSandbox: true, + }), + upsertMessagingProviders, + getHermesToolGatewayProviderName: (targetSandbox) => + getHermesToolGatewayBroker().getHermesToolGatewayProviderName(targetSandbox), + discloseInitialSandboxPolicy, + }, + launchInput: { + agent, + observabilityEnabled: createIntent?.observabilityEnabled === true, + chatUiUrl, + sandboxName, + env: process.env, + extraPlaceholderKeys: resolvedCreateIntent.extraPlaceholderKeys, + getDashboardForwardPort, + hermesDashboardState: agentCreateInput.hermesPortableLifecycle + ? { enabled: false, config: null } + : hermesDashboardState, + hermesApiPort: hermesApiPortReservationScope.effectivePort, + manageDashboard, + openshellShellCommand, + openshellArgv, + }, + plannedMessagingPlan: plannedMessagingState?.plan ?? null, + gpu: { + provider, + config: effectiveSandboxGpuConfig, + dockerDriverGateway: agentCreateInput.dockerDriverGateway, + gatewayPort: GATEWAY_PORT, + }, + dependencies: { + materializeSandboxCreatePlan: (input) => + hermesStateVolumeLifecycle.materializeSandboxCreatePlan( + input, + sandboxCreatePlanMaterialization.materializeSandboxCreatePlan, + ), + prepareSandboxBuildPatchConfig: + sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig, + }, + }), + ); + const { + initialSandboxPolicy, + policyTier: resolvedCreatePolicyTier, + messagingProviders, + gpuRoutePlan, + compatibilityPolicyPath, + initialGpuRoute, + sandboxReadyTimeoutSecs, + buildId, + dashboardRemoteBindPrepared, + legacyBuildContext, + launch: { + createArgv, + effectiveDashboardPort, + intendedSandboxStartupCommand, + managedBootstrapIdentity, + managedStartupRootApplyRequest, + prebuild, + sandboxEnv, + sandboxStartupCommand, + }, + } = preparedOnboardLaunch; + const restoreBackupPath = + pendingStateRestore?.manifest?.backupPath ?? pendingStateRestoreBackupPath; + onboardSessionBootstrap.verifyReadOnlyHostMountSources(resolvedCreateIntent.hostMounts); + if (!agentCreateInput.hermesPortableLifecycle) recreateRuntime.advance("creating"); + const managedBootstrap = managedWorkloadOnboard.resolveOnboardManagedBootstrapLaunch({ + runtime: managedWorkloadRuntime, + workload: preparedSandboxWorkload, + stateRoot: getDockerDriverGatewayStateDir(), + bootstrapIdentity: managedBootstrapIdentity, + request: managedStartupRootApplyRequest, + intendedWorkloadArgv: intendedSandboxStartupCommand, + }); + const createdSandboxLifecycle = sandboxRecreateTransaction.createCreatedSandboxLifecycle( + recreateRuntime, + { sandboxName, gatewayName: GATEWAY_NAME }, + getSandboxRecreateObservation, + ); + const hermesPortableAuthority = agentCreateInput.hermesPortableLifecycle + ? (() => { + if (!agent || agent.name !== "hermes" || !portableRuntimeAuthority) { + throw new Error( + "Hermes portable onboarding is missing exact agent or runtime authority.", + ); + } + return { agent, runtimeAuthority: portableRuntimeAuthority }; + })() + : null; + const hermesGpuAuthority = hermesPortableAuthority + ? sandboxGpuCreateFlow.createHermesPortableGpuProofAuthority({ + sandboxName, + gatewayName: GATEWAY_NAME, + sourceEnv: sandboxEnv, + lifecycleGeneration: createdSandboxLifecycle.generation, + runtimeAuthority: hermesPortableAuthority.runtimeAuthority, + runOpenshell, + compactText, + redact, + }) + : null; + const createFlowEnvironment = hermesGpuAuthority?.env ?? sandboxEnv; + const createGpuVerifier = hermesGpuAuthority?.verify ?? verifyDirectSandboxGpu; + const runCreateFlow = ( + attemptCreateArgv: string[], + hermesPortableReadyCapture?: import("../sandbox-gpu-create-flow").HermesPortableReadyCapture, + hermesPortableReadyRunner?: import("../sandbox-gpu-create-flow").HermesPortableReadyRunner, + createWorkingDirectory?: string, + ) => + sandboxGpuCreateFlow.runSandboxGpuCreateFlow( + { + sandboxName, + provider, + sandboxGpuConfig: effectiveSandboxGpuConfig, + gpuRoutePlan, + initialGpuRoute, + compatibilityPolicyPath, + gatewayPort: GATEWAY_PORT, + sandboxReadyTimeoutSecs, + createArgv: attemptCreateArgv, + ...(createWorkingDirectory ? { createWorkingDirectory } : {}), + sandboxEnv: createFlowEnvironment, + sandboxStartupCommand, + lifecycleGeneration: createdSandboxLifecycle.generation, + portableRuntimeAuthority, + prebuild, + restoreBackupPath, + terminalAgent: agentDefs.isTerminalAgent(agent), + managedBootstrap, + ...agentCreateInput, + }, + { + runOpenshell: hermesPortableReadyRunner ?? runOpenshell, + runCaptureOpenshell: hermesPortableReadyCapture ?? runCaptureOpenshell, + sleep: sleepSeconds, + openshellArgv, + verifyDirectSandboxGpu: createGpuVerifier, + }, + ); + + const cleanupBuildContext = + sandboxGpuCreateFlow.createSandboxBuildContextCleanup(legacyBuildContext); + const cleanupInitialCreateSource = sandboxGpuCreateFlow.createSandboxCreateSourceCleanup( + initialSandboxPolicy, + agentCreateInput.hermesPortableLifecycle, + ); + const sandboxRuntimeFields = agentCreateInput.hermesPortableLifecycle + ? sandboxRegistryMetadata.getHermesPortableSandboxRuntimeRegistryFields( + effectiveSandboxGpuConfig, + "0.0.101", + ) + : getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); + const createdSandboxCompletion = createOnboardCreatedSandboxCompletion( + sandboxName, + restoreBackupPath, + pendingStateRestoreBackupPath, + agent, + fromDockerfile, + { customOpenClawImage, isManagedDcodeAgent }, + { provider, model, preferredInferenceApi }, + { createIntent, resolvedCreateIntent }, + sandboxRuntimeFields, + agentCreateInput.portableLifecycle, + { + toolDisclosure: effectiveToolDisclosure, + dcodeAutoApprovalMode: dcodeAutoApprovalPlan.mode, + }, + { webSearchConfig, hermesAuthMethod: normalizeHermesAuthMethod(hermesAuthMethod) }, + { plannedMessagingState, preservedMcpState, hermesToolGateways }, + hermesApiPortReservationScope.effectivePort, + { gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT }, + { initialSandboxPolicy, policyTier: resolvedCreatePolicyTier, dashboardRemoteBindPrepared }, + prebuild.imageRef, + buildId, + effectiveSandboxGpuConfig, + agentCreateInput.dockerDriverGateway, + createGpuVerifier, + runCaptureOpenshell, + chatUiUrl, + hermesDashboardState, + dashboardPortReservationScope.release, + ensureDashboardForward, + getDashboardForwardPort, + hermesDashboardForwarding.resolveStateForPort, + hermesDashboardForwarding.ensureForState, + managedWorkloadRuntime, + preparedSandboxWorkload, + note, + ); + const completeCreatedSandboxRegistration = createOnboardCreatedSandboxRegistration({ + completion: createdSandboxCompletion, + createdLifecycle: createdSandboxLifecycle, + cleanupBuildContext, + manageDashboard, + sandboxGpuEnabled: effectiveSandboxGpuConfig.sandboxGpuEnabled, + }); + + if (hermesPortableAuthority) { + if (!portableRuntimeContext?.environmentScope) { + throw new Error("Hermes portable onboarding is missing runtime environment authority."); + } + if (managedBootstrap || !["none", "native-only"].includes(gpuRoutePlan)) { + throw new Error( + "Hermes portable onboarding cannot use managed bootstrap or Docker GPU compatibility.", + ); + } + if (!inferenceRouteReservationAuthority?.sessionId) { + throw new Error( + "Hermes portable onboarding is missing current inference route reservation authority.", + ); + } + const inferenceRouteReservation = { + sessionId: inferenceRouteReservationAuthority.sessionId, + selection: sandboxRegistration.selection( + sandboxName, + provider, + model, + preferredInferenceApi, + createIntent?.endpointSource ?? null, + ), + }; + await sandboxGpuCreateFlow.runHermesPortableOnboardingFromOnboard< + import("../sandbox-gpu-create-flow").SandboxGpuCreateFlowResult + >({ + sandboxName, + gatewayName: GATEWAY_NAME, + lifecycleGeneration: createdSandboxLifecycle.generation, + portableRuntime: portableRuntimeContext, + createArgv, + createPolicyPath: initialSandboxPolicy.policyPath, + startup: { + agent: hermesPortableAuthority.agent, + sandboxName, + startupArgv: intendedSandboxStartupCommand, + }, + inferenceRouteReservation, + withLifecycleLock: sandboxMutationLock.withMcpLifecycleLock, + childEnv: sandboxEnv, + openshellArgv, + createSandbox: (attemptArgv, readyCapture, readyRunner, buildContextPath) => + runCreateFlow([...attemptArgv], readyCapture, readyRunner, buildContextPath), + readRegistry: () => registry.getSandbox(sandboxName), + registerSandbox: async ( + created, + receipt, + liveIdentityFingerprint, + revalidate, + routeReservation, + ) => { + const registered = await completeCreatedSandboxRegistration( + created, + receipt, + liveIdentityFingerprint, + revalidate, + routeReservation, + ); + if (!registered) { + throw new Error("Hermes portable sandbox registration returned no authority."); + } + return registered; + }, + sourceRoot: ROOT, + buildContextSettings: { + model, + provider, + preferredInferenceApi, + toolDisclosure: effectiveToolDisclosure, + }, + cleanupTemporaryPolicy: cleanupInitialCreateSource, + createPolicySourceBytes: initialSandboxPolicy.sourceBytes, + }); + cleanupBuildContext(); + } else { + const created = await runCreateFlow(createArgv); + cleanupInitialCreateSource(); + await completeCreatedSandboxRegistration(created, null); + } + hermesStateVolumeLifecycle.commit(); + if ("complete" in recreateRuntime) recreateRuntime.complete(); + if (agentCreateInput.hermesPortableLifecycle) return sandboxName; + return completeOrdinaryOnboardSandboxCreation( + { + sandboxName, + sandboxWasLiveDefault, + runtimeFields: sandboxRuntimeFields, + messagingProviders, + liveExists, + }, + { + setDefault: registry.setDefault, + runFile, + scriptsDir: SCRIPTS, + gatewayName: GATEWAY_NAME, + providerExistsInGateway, + armCancelRollback: sandboxCancelRollback.arm, + dockerInfoFormat, + runCapture, + }, + ); + }; +} diff --git a/src/lib/onboard/sandbox-gpu-create-flow-hermes-portable.test.ts b/src/lib/onboard/sandbox-gpu-create-flow-hermes-portable.test.ts new file mode 100644 index 00000000000..861333626c5 --- /dev/null +++ b/src/lib/onboard/sandbox-gpu-create-flow-hermes-portable.test.ts @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + streamSandboxCreate: vi.fn(), + waitForCreatedSandboxReadyWithTrace: vi.fn(), + printReadinessFailure: vi.fn(), + enforceDockerGpuPatchPreserveNetwork: vi.fn(), + verifyGpuSandboxAccessAfterReady: vi.fn(), + createDockerGpuSandboxCreatePatch: vi.fn(), + printSandboxCreateFailureDiagnostics: vi.fn(), + collectDockerGpuPatchDiagnostics: vi.fn(), + queryOpenShellDockerSandboxContainers: vi.fn(), + queryOpenShellDockerSandboxRuntimeSnapshot: vi.fn(), +})); + +vi.mock("../sandbox/create-stream", () => ({ streamSandboxCreate: mocks.streamSandboxCreate })); +vi.mock("./sandbox-readiness-tracing", () => ({ + waitForCreatedSandboxReadyWithTrace: mocks.waitForCreatedSandboxReadyWithTrace, + printReadinessFailure: mocks.printReadinessFailure, +})); +vi.mock("./docker-gpu-local-inference", () => ({ + enforceDockerGpuPatchPreserveNetwork: mocks.enforceDockerGpuPatchPreserveNetwork, + verifyGpuSandboxAccessAfterReady: mocks.verifyGpuSandboxAccessAfterReady, +})); +vi.mock("./docker-gpu-sandbox-create", () => ({ + createDockerGpuSandboxCreatePatch: mocks.createDockerGpuSandboxCreatePatch, +})); +vi.mock("./sandbox-create-failure", () => ({ + printSandboxCreateFailureDiagnostics: mocks.printSandboxCreateFailureDiagnostics, +})); +vi.mock("./docker-gpu-patch", async (importOriginal) => ({ + ...(await importOriginal()), + collectDockerGpuPatchDiagnostics: mocks.collectDockerGpuPatchDiagnostics, +})); +vi.mock("./openshell-docker-sandbox-containers", async (importOriginal) => ({ + ...(await importOriginal()), + queryOpenShellDockerSandboxContainers: mocks.queryOpenShellDockerSandboxContainers, + queryOpenShellDockerSandboxRuntimeSnapshot: mocks.queryOpenShellDockerSandboxRuntimeSnapshot, +})); + +import type { CheckpointPortableRuntimeAuthority } from "../state/onboard-checkpoint-types"; +import { + createGpuFlowDeps as createDeps, + createGpuFlowInput as createInput, + resetGpuFlowMocks, + setupGpuFlowMocks, +} from "./__test-helpers__/sandbox-gpu-create-flow"; +import { + cleanupSandboxCreateSource, + runSandboxGpuCreateFlow, + type SandboxGpuCreateFlowInput, +} from "./sandbox-gpu-create-flow"; +import * as sandboxGpuCreateAttempt from "./sandbox-gpu-create-attempt"; + +const PORTABLE_RUNTIME_AUTHORITY: CheckpointPortableRuntimeAuthority = { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid: 1001, + homeDir: "/home/tester", + configHome: "/home/tester/.config", + runtimeDir: "/run/user/1001", + socketPath: "/run/user/1001/podman/podman.sock", +}; + +beforeEach(() => setupGpuFlowMocks(mocks)); +afterEach(resetGpuFlowMocks); + +describe("Hermes portable sandbox create flow", () => { + it("releases exit cleanup ownership only after successful retirement (#9203)", () => { + const cleanup = vi.fn(() => true); + process.on("exit", cleanup); + try { + expect(cleanupSandboxCreateSource(cleanup)).toBe(true); + expect(process.listeners("exit")).not.toContain(cleanup); + } finally { + process.removeListener("exit", cleanup); + } + }); + + it("preserves exit cleanup ownership when retirement is incomplete (#9203)", () => { + const cleanup = vi.fn(() => false); + process.on("exit", cleanup); + try { + expect(cleanupSandboxCreateSource(cleanup)).toBe(false); + expect(process.listeners("exit")).toContain(cleanup); + } finally { + process.removeListener("exit", cleanup); + } + }); + + it("requires and uses exact source cleanup for Hermes portable custody (#9203)", () => { + const cleanup = vi.fn(() => true); + const exactCleanup = vi.fn(() => true); + process.on("exit", cleanup); + try { + expect(cleanupSandboxCreateSource(cleanup, { exactCleanup, requireExact: true })).toBe(true); + expect(exactCleanup).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + expect(process.listeners("exit")).not.toContain(cleanup); + expect(() => cleanupSandboxCreateSource(cleanup, { requireExact: true })).toThrow( + "has no exact cleanup authority", + ); + } finally { + process.removeListener("exit", cleanup); + } + }); + + it("keeps non-OpenClaw portable creation on the existing runtime patch (#9068)", async () => { + const input = createInput(); + input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; + input.portableLifecycle = false; + input.hermesPortableLifecycle = false; + input.persistStartupCommand = true; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => null); + + await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ route: "native" }); + + expect(mocks.createDockerGpuSandboxCreatePatch).toHaveBeenCalledOnce(); + expect(deps.installPortableDemoLifecycle).toHaveBeenCalledOnce(); + }); + + it("uses the Hermes portable create handoff without Docker or OpenClaw lifecycle mutation (#9203)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hermesPortableLifecycle = true; + input.portableLifecycle = false; + input.lifecycleGeneration = "generation-1"; + input.portableRuntimeAuthority = PORTABLE_RUNTIME_AUTHORITY; + const deps = createDeps(); + deps.installPortableDemoLifecycle = vi.fn(() => null); + + await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ + route: "native", + lifecycleRegistrationFields: { lifecycleGeneration: "generation-1" }, + }); + + expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); + expect(mocks.queryOpenShellDockerSandboxContainers).not.toHaveBeenCalled(); + expect(mocks.queryOpenShellDockerSandboxRuntimeSnapshot).not.toHaveBeenCalled(); + expect(deps.installPortableDemoLifecycle).not.toHaveBeenCalled(); + expect(deps.verifyDirectSandboxGpu).toHaveBeenCalledOnce(); + }); + + it("keeps schema-5 create failure diagnostics out of ambient gateway logs (#9203)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hermesPortableLifecycle = true; + input.lifecycleGeneration = "generation-1"; + input.portableRuntimeAuthority = PORTABLE_RUNTIME_AUTHORITY; + const deps = createDeps(); + const exit = vi.spyOn(process, "exit").mockImplementation(((code: number) => { + throw new Error(`exit ${code}`); + }) as never); + mocks.streamSandboxCreate.mockResolvedValueOnce({ + status: 7, + output: "create rejected", + sawProgress: false, + }); + + await expect(runSandboxGpuCreateFlow(input, deps)).rejects.toThrow("exit 7"); + + expect(exit).toHaveBeenCalledWith(7); + expect(mocks.printSandboxCreateFailureDiagnostics).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("did not complete receipt-owned creation"), + ); + }); + + it("preserves receipt authority instead of suggesting name-only cleanup (#9203)", async () => { + const input = createInput(); + input.gpuRoutePlan = "native-only"; + input.hermesPortableLifecycle = true; + input.lifecycleGeneration = "generation-1"; + input.portableRuntimeAuthority = PORTABLE_RUNTIME_AUTHORITY; + const deps = createDeps(); + vi.spyOn(sandboxGpuCreateAttempt, "executeSandboxGpuCreatePlan").mockResolvedValue({ + ok: false, + route: "native", + stage: "gpu-proof", + error: new Error("GPU proof failed"), + fallbackEligible: false, + }); + vi.spyOn(process, "exit").mockImplementation(((code: number) => { + throw new Error(`exit ${code}`); + }) as never); + + await expect(runSandboxGpuCreateFlow(input, deps)).rejects.toThrow("exit 1"); + + const output = vi.mocked(console.error).mock.calls.flat().join("\n"); + expect(output).toContain("Preserve its lifecycle receipt and resume onboarding"); + expect(output).not.toContain("openshell sandbox delete"); + }); + + it("rejects compatibility and managed bootstrap before Hermes portable create effects (#9203)", async () => { + const compatibility = createInput(); + compatibility.hermesPortableLifecycle = true; + compatibility.lifecycleGeneration = "generation-1"; + compatibility.portableRuntimeAuthority = PORTABLE_RUNTIME_AUTHORITY; + + await expect(runSandboxGpuCreateFlow(compatibility, createDeps())).rejects.toThrow( + "Docker GPU compatibility is unavailable", + ); + + const managed = createInput(); + managed.gpuRoutePlan = "native-only"; + managed.hermesPortableLifecycle = true; + managed.lifecycleGeneration = "generation-1"; + managed.portableRuntimeAuthority = PORTABLE_RUNTIME_AUTHORITY; + const createOnboardRouting = vi.fn(); + const createLifecycle = vi.fn(); + managed.managedBootstrap = { + runtimeProvider: { bootstrap: { createOnboardRouting, createLifecycle } }, + } as unknown as NonNullable; + await expect(runSandboxGpuCreateFlow(managed, createDeps())).rejects.toThrow( + "Hermes portable onboarding cannot use managed-image bootstrap", + ); + + expect(createOnboardRouting).not.toHaveBeenCalled(); + expect(createLifecycle).not.toHaveBeenCalled(); + expect(mocks.streamSandboxCreate).not.toHaveBeenCalled(); + expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index 94a08f8e176..565361601c2 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -215,6 +215,7 @@ describe("resolveAgentCreateInput", () => { { persistStartupCommand: false, portableLifecycle: false, + hermesPortableLifecycle: true, }, ); expect(resolvePortableLifecycleMode(null, env)).toBe(true); @@ -1139,20 +1140,6 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { expect(mocks.createDockerGpuSandboxCreatePatch).not.toHaveBeenCalled(); }); - it("keeps non-OpenClaw portable creation on the existing runtime patch (#9068)", async () => { - const input = createInput(); - input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; - input.portableLifecycle = false; - input.persistStartupCommand = true; - const deps = createDeps(); - deps.installPortableDemoLifecycle = vi.fn(() => null); - - await expect(runSandboxGpuCreateFlow(input, deps)).resolves.toMatchObject({ route: "native" }); - - expect(mocks.createDockerGpuSandboxCreatePatch).toHaveBeenCalledOnce(); - expect(deps.installPortableDemoLifecycle).toHaveBeenCalledOnce(); - }); - it("rejects Docker compatibility before portable sandbox creation (#9068)", async () => { const input = createInput(); input.hostEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }; diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 363d2aff754..17b7e2ff552 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -14,7 +14,25 @@ import type { SelectedDockerGpuRoute } from "./docker-gpu-route"; import { renderCompatibilityFallbackCreateArgs } from "./docker-gpu-route"; import { adaptDockerGpuRouteForPatch } from "./docker-gpu-route-patch-adapter"; import { resolveDockerStartupCommandPatch } from "./docker-startup-command-agent"; +import { + classifyHermesPortableRegistry, + createHermesPortableChildEnvironment, + createHermesPortableContainerDeps, + createHermesPortableOpenShellCapture, + createHermesPortableReadyCapture, + createHermesPortableReadyRunner, + defaultHermesPortableStateDir, + isHermesPortableLifecycleMode, + observeHermesPortableSandbox, + runHermesPortableOnboardingFromOnboard, + runHermesPortableOnboardingTransaction, + shouldManageHermesPortableDashboard, +} from "./experimental/hermes-portable-onboarding"; import { installPortableDemoSandboxLifecycle } from "./experimental/portable-demo-lifecycle"; +import { + buildHermesPortableCommandAuthority, + buildHermesPortableOnboardingCommandAuthority, +} from "./experimental/portable-agent-lifecycle"; import { isPortableExperimentalProfile } from "./experimental/portable-profile"; import { type ManagedBootstrapAdapter, @@ -34,10 +52,64 @@ import type { import * as sandboxGpuCreateAttempt from "./sandbox-gpu-create-attempt"; import { createSandboxGpuCreateAttemptRunner } from "./sandbox-gpu-create-run-attempt"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; +import { + createDirectSandboxGpuVerifier, + type DirectSandboxGpuVerifierDeps, + type VerifyDirectSandboxGpu, +} from "./sandbox-gpu-preflight"; import type { SandboxPrebuildResult } from "./sandbox-prebuild"; import { addTraceEvent } from "./tracing"; export { resolveDockerStartupCommandPatch } from "./docker-startup-command-agent"; +export { + classifyHermesPortableRegistry, + createHermesPortableChildEnvironment, + createHermesPortableContainerDeps, + createHermesPortableOpenShellCapture, + createHermesPortableReadyCapture, + createHermesPortableReadyRunner, + defaultHermesPortableStateDir, + observeHermesPortableSandbox, + runHermesPortableOnboardingFromOnboard, + runHermesPortableOnboardingTransaction, + shouldManageHermesPortableDashboard, + buildHermesPortableCommandAuthority, +}; +export type HermesPortableReadyCapture = ReturnType; +export type HermesPortableReadyRunner = ReturnType; + +/** Release the exit cleanup listener only after its exact create source was retired. */ +export function cleanupSandboxCreateSource( + cleanup: (() => boolean) | undefined, + options: { readonly exactCleanup?: () => boolean; readonly requireExact?: boolean } = {}, +): boolean { + if (options.requireExact && cleanup && !options.exactCleanup) { + throw new Error("Hermes portable temporary policy source has no exact cleanup authority."); + } + const selected = options.exactCleanup ?? cleanup; + if (!selected) return true; + const completed = selected(); + if (completed && cleanup) process.removeListener("exit", cleanup); + return completed; +} + +/** Bind the exact create-source retirement decision without moving its execution point. */ +export function createSandboxCreateSourceCleanup( + source: { readonly cleanup?: () => boolean; readonly cleanupExact?: () => boolean }, + requireExact: boolean, +): () => boolean { + return () => + cleanupSandboxCreateSource(source.cleanup, { exactCleanup: source.cleanupExact, requireExact }); +} + +/** Bind cleanup for the one staged build context owned by this create attempt. */ +export function createSandboxBuildContextCleanup( + context: { readonly cleanupBuildCtx?: () => boolean } | null, +): () => void { + return () => { + if (context?.cleanupBuildCtx?.()) process.removeListener("exit", context.cleanupBuildCtx); + }; +} export function resolvePortableLifecycleMode( agent: AgentDefinition | null, @@ -49,16 +121,14 @@ export function resolvePortableLifecycleMode( /** Resolve the checkpoint-owned authority required by exported portable creation helpers. */ export function resolveExportedPortableRuntimeAuthority( env: NodeJS.ProcessEnv, - loadSession: () => - | { - checkpoint?: { - profile: { kind: "selected"; value: "default" | "portable" }; - runtimeAuthority: - | { kind: "unset" } - | { kind: "selected"; value: CheckpointPortableRuntimeAuthority }; - } | null; - } - | null, + loadSession: () => { + checkpoint?: { + profile: { kind: "selected"; value: "default" | "portable" }; + runtimeAuthority: + | { kind: "unset" } + | { kind: "selected"; value: CheckpointPortableRuntimeAuthority }; + } | null; + } | null, ): CheckpointPortableRuntimeAuthority | null { if (!isPortableExperimentalProfile(env)) return null; const checkpoint = loadSession()?.checkpoint; @@ -78,8 +148,10 @@ export function resolveAgentCreateInput( env: NodeJS.ProcessEnv = process.env, ) { return { + dockerDriverGateway, ...resolveDockerStartupCommandPatch(agent, dockerDriverGateway, env), portableLifecycle: resolvePortableLifecycleMode(agent, env), + hermesPortableLifecycle: isHermesPortableLifecycleMode(agent, env), }; } @@ -129,9 +201,12 @@ export interface SandboxGpuCreateFlowInput { gatewayPort: number; sandboxReadyTimeoutSecs: number; createArgv: string[]; + /** Exact schema-5 build context consumed by the OpenShell create child. */ + createWorkingDirectory?: string; /** Host-side runtime environment used only by the selected lifecycle provider. */ hostEnv?: NodeJS.ProcessEnv; portableLifecycle?: boolean; + hermesPortableLifecycle?: boolean; sandboxEnv: NodeJS.ProcessEnv; sandboxStartupCommand: string[]; lifecycleGeneration?: SandboxEntry["lifecycleGeneration"]; @@ -162,6 +237,10 @@ export interface SandboxGpuCreateFlowDeps { sleep: Sleep; openshellArgv(args: string[]): string[]; verifyDirectSandboxGpu(sandboxName: string): SandboxGpuProofResult; + printCreateFailureDiagnostics?: ( + sandboxName: string, + options: { readonly backupPath?: string | null }, + ) => void; /** Production callers configure the hidden portable lifecycle through the default implementation. */ installPortableDemoLifecycle?: typeof installPortableDemoSandboxLifecycle; /** Production callers omit this factory and use the runtime provider's adapter. */ @@ -178,6 +257,37 @@ export interface SandboxGpuCreateFlowResult { lifecycleRegistrationFields: LifecycleRegistrationFields; } +/** Bind only the schema-5 GPU proof child to its admitted command authorities. */ +export function createHermesPortableGpuProofAuthority(input: { + readonly sandboxName: string; + readonly gatewayName: string; + readonly lifecycleGeneration: string; + readonly sourceEnv: NodeJS.ProcessEnv; + readonly runtimeAuthority: CheckpointPortableRuntimeAuthority; + readonly runOpenshell: DirectSandboxGpuVerifierDeps["runOpenshell"]; + readonly compactText: DirectSandboxGpuVerifierDeps["compactText"]; + readonly redact: DirectSandboxGpuVerifierDeps["redact"]; +}): { readonly env: NodeJS.ProcessEnv; readonly verify: VerifyDirectSandboxGpu } { + const env = createHermesPortableChildEnvironment(input.sourceEnv, input.runtimeAuthority); + return { + env, + verify: createDirectSandboxGpuVerifier({ + runOpenshell: input.runOpenshell, + compactText: input.compactText, + redact: input.redact, + gatewayName: input.gatewayName, + subprocessEnv: env, + resolveOpenShellCommandAuthority: () => + buildHermesPortableOnboardingCommandAuthority( + input.sandboxName, + input.gatewayName, + input.lifecycleGeneration, + input.sourceEnv, + ), + }), + }; +} + /** * SOURCE_OF_TRUTH_REVIEW (ordered native-GPU fallback; #6110) * invalidState: native injection fails and a broader retry starts without exact evidence or cleanup. @@ -194,12 +304,31 @@ export async function runSandboxGpuCreateFlow( input: SandboxGpuCreateFlowInput, deps: SandboxGpuCreateFlowDeps, ): Promise { + const hermesPortableLifecycle = input.hermesPortableLifecycle === true; assertPortableManagedBootstrapNotSelected( input.portableLifecycle === true, input.managedBootstrap != null, ); + if (hermesPortableLifecycle && input.managedBootstrap != null) { + throw new Error( + "Hermes portable onboarding cannot use managed-image bootstrap because it requires Docker lifecycle operations.", + ); + } + if (hermesPortableLifecycle && (!input.lifecycleGeneration || !input.portableRuntimeAuthority)) { + throw new Error( + "Hermes portable onboarding requires checkpoint runtime authority and a lifecycle generation before creation.", + ); + } let registryImageRef: string | null = input.prebuild.imageRef; - const attemptRunner = createSandboxGpuCreateAttemptRunner(input, deps); + const attemptRunner = createSandboxGpuCreateAttemptRunner( + hermesPortableLifecycle ? { ...input, portableLifecycle: true } : input, + hermesPortableLifecycle + ? { + ...deps, + installPortableDemoLifecycle: () => input.lifecycleGeneration!, + } + : deps, + ); const gpuCreateOutcome = await sandboxGpuCreateAttempt .executeSandboxGpuCreatePlan(input.gpuRoutePlan, { runAttempt: attemptRunner.runAttempt, @@ -307,12 +436,16 @@ export async function runSandboxGpuCreateFlow( ` Cleanup could not be proven safe: ${redactFull(gpuCreateOutcome.cleanupRefused)}`, ); } - console.error(` Manual cleanup: openshell sandbox delete "${input.sandboxName}"`); + console.error( + hermesPortableLifecycle + ? ` Hermes portable sandbox '${input.sandboxName}' did not complete receipt-owned creation. Preserve its lifecycle receipt and resume onboarding after correcting the reported failure.` + : ` Manual cleanup: openshell sandbox delete "${input.sandboxName}"`, + ); process.exit(1); } let portableLifecycleGeneration = attemptRunner.state.portableLifecycleGeneration; - if (!input.portableLifecycle && !portableLifecycleGeneration) { + if (!input.portableLifecycle && !input.hermesPortableLifecycle && !portableLifecycleGeneration) { try { portableLifecycleGeneration = (deps.installPortableDemoLifecycle ?? installPortableDemoSandboxLifecycle)( diff --git a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts index 0569def2fcb..b9aba239765 100644 --- a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts +++ b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts @@ -198,6 +198,14 @@ export function createSandboxGpuCreateAttemptRunner( deps: SandboxGpuCreateFlowDeps, ) { const portableLifecycle = input.portableLifecycle === true; + const printCreateFailureDiagnostics = + deps.printCreateFailureDiagnostics ?? + (input.hermesPortableLifecycle + ? (sandboxName: string) => + console.error( + ` Hermes portable sandbox '${sandboxName}' did not complete receipt-owned creation. Preserve its lifecycle receipt and resume onboarding after correcting the reported failure.`, + ) + : printSandboxCreateFailureDiagnostics); if ( portableLifecycle && (input.gpuRoutePlan === "compatibility-only" || @@ -329,6 +337,7 @@ export function createSandboxGpuCreateAttemptRunner( if (!createExecutable) throw new Error("Sandbox create executable is missing."); const streamCreate = () => streamSandboxCreate(createExecutable, createExecutableArgs, input.sandboxEnv, { + ...(input.createWorkingDirectory ? { cwd: input.createWorkingDirectory } : {}), readyCheck: () => { const list = deps.runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); return isSandboxReady(list, input.sandboxName); @@ -495,7 +504,7 @@ export function createSandboxGpuCreateAttemptRunner( }, { classifyCreateFailure: classifySandboxCreateFailure, - printCreateFailureDiagnostics: printSandboxCreateFailureDiagnostics, + printCreateFailureDiagnostics, printRecoveryHints: printSandboxCreateRecoveryHints, warn: (message) => console.warn(message), error: (message) => console.error(message), @@ -514,7 +523,7 @@ export function createSandboxGpuCreateAttemptRunner( console.error( ` Sandbox '${input.sandboxName}' reached Ready, but OpenShell did not return one exact durable sandbox ID before runtime recreation.`, ); - printSandboxCreateFailureDiagnostics(input.sandboxName, { + printCreateFailureDiagnostics(input.sandboxName, { backupPath: input.restoreBackupPath, }); process.exit(createResult.status === 0 ? 1 : createResult.status); @@ -584,7 +593,7 @@ export function createSandboxGpuCreateAttemptRunner( } as const; } await runtimePatch.rollbackManagedStartupAfterCreateFailure(); - printSandboxCreateFailureDiagnostics(input.sandboxName, { + printCreateFailureDiagnostics(input.sandboxName, { backupPath: input.restoreBackupPath, }); if (compatibility) runtimePatch.printReadinessFailureIfEnabled(); diff --git a/src/lib/onboard/sandbox-gpu-direct-proof.test.ts b/src/lib/onboard/sandbox-gpu-direct-proof.test.ts index a6d55681eec..5a7ca8977fa 100644 --- a/src/lib/onboard/sandbox-gpu-direct-proof.test.ts +++ b/src/lib/onboard/sandbox-gpu-direct-proof.test.ts @@ -13,6 +13,40 @@ import { } from "./sandbox-gpu-preflight"; describe("direct sandbox GPU proof", () => { + it("uses the exact gateway and replacement environment for Hermes portable proof (#9203)", () => { + const runOpenshell = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); + const buildProofCommands = vi.fn(() => [ + { id: "nvidia-smi", args: ["sandbox", "exec"], label: "nvidia-smi", optional: true }, + ]); + const env = { HOME: "/home/test", XDG_RUNTIME_DIR: "/run/user/1000" }; + const resolveOpenShellCommandAuthority = vi.fn(() => ({ + env, + executablePath: "/usr/bin/openshell", + })); + const verifier = createDirectSandboxGpuVerifier({ + runOpenshell, + buildDirectSandboxGpuProofCommands: buildProofCommands, + compactText: (value) => value, + redact: (value) => String(value), + detectNvidiaPlatform: () => "linux", + gatewayName: "nemoclaw", + subprocessEnv: env, + resolveOpenShellCommandAuthority, + }); + + expect(verifier("alpha").status).toBe("unverified"); + expect(buildProofCommands).toHaveBeenCalledWith("alpha", "nemoclaw"); + expect(runOpenshell).toHaveBeenCalledWith( + ["sandbox", "exec"], + expect.objectContaining({ + env, + openshellBinary: "/usr/bin/openshell", + replaceEnv: true, + }), + ); + expect(resolveOpenShellCommandAuthority).toHaveBeenCalledOnce(); + }); + it("treats optional direct sandbox GPU proof failures as non-fatal and reports unverified", () => { const runOpenshell = vi.fn(() => ({ status: 1, stdout: "", stderr: "optional proof failed" })); const verifier = createDirectSandboxGpuVerifier({ diff --git a/src/lib/onboard/sandbox-gpu-preflight.ts b/src/lib/onboard/sandbox-gpu-preflight.ts index 65cc1f8479f..d171f8a2291 100644 --- a/src/lib/onboard/sandbox-gpu-preflight.ts +++ b/src/lib/onboard/sandbox-gpu-preflight.ts @@ -161,7 +161,7 @@ export interface DirectSandboxGpuVerifierDeps extends WslDockerDesktopDetectionD args: string[], opts?: Record, ): { status?: number | null; stdout?: unknown; stderr?: unknown }; - buildDirectSandboxGpuProofCommands?: (sandboxName: string) => Array<{ + buildDirectSandboxGpuProofCommands?: (sandboxName: string, gatewayName?: string) => Array<{ id?: string; args: string[]; label: string; @@ -169,6 +169,12 @@ export interface DirectSandboxGpuVerifierDeps extends WslDockerDesktopDetectionD }>; compactText(value: string): string; redact(value: unknown): string; + gatewayName?: string; + subprocessEnv?: NodeJS.ProcessEnv; + resolveOpenShellCommandAuthority?: () => { + readonly env: NodeJS.ProcessEnv; + readonly executablePath: string; + }; // Host firmware platform resolver, used to choose Jetson-specific remediation // when a CUDA proof fails. Defaults to the live `nim.detectNvidiaPlatform()` // so onboarding does not have to thread the platform through. Injected in @@ -236,8 +242,12 @@ export function createDirectSandboxGpuVerifier( // could not run at all). Records the proof that determines "failed" status. let cudaFailure: { label: string; detail: string } | null = null; let explicitNvidiaSmiFailure: { label: string; detail: string } | null = null; - for (const proof of buildProofCommands(sandboxName)) { + for (const proof of buildProofCommands(sandboxName, deps.gatewayName)) { + const commandAuthority = deps.resolveOpenShellCommandAuthority?.(); + const subprocessEnv = commandAuthority?.env ?? deps.subprocessEnv; const result = deps.runOpenshell(proof.args, { + ...(subprocessEnv ? { env: subprocessEnv, replaceEnv: true } : {}), + ...(commandAuthority ? { openshellBinary: commandAuthority.executablePath } : {}), ignoreError: true, suppressOutput: true, timeout: 30_000, @@ -355,6 +365,7 @@ export function createDirectSandboxGpuVerifier( }; } + export function validateSandboxGpuPreflight( config: SandboxGpuConfig, deps: SandboxGpuPreflightDeps = {}, diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index 3a9bc092a24..f59ff6eb39b 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -733,11 +733,47 @@ describe("registerCreatedSandbox", () => { lifecycleLiveIdentityFingerprint: "d".repeat(64), gatewayName: "owner-gateway", }); + expect(entry.hermesApiPort).toBe(8642); expect(registerSandbox).toHaveBeenCalledExactlyOnceWith(entry); expect(entry.agent).toBe("hermes"); expect(classifyPortableLifecycleReceipt).not.toHaveBeenCalled(); }); + it("omits unowned API-port state only for schema-5 Hermes registration", () => { + const agentDefs = requireDist("../agent/defs.js") as typeof import("../agent/defs"); + const entry = registerCreatedSandbox({ + sandboxName: "hermes-portable", + inferenceSelection: { + model: "qwen3-vl:4b", + provider: "ollama-local", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + }, + runtimeFields, + agent: agentDefs.loadAgent("hermes"), + agentVersionKnown: true, + imageTag: null, + appliedPolicies: [], + plannedMessagingState: undefined, + hermesToolGateways: [], + hermesDashboardState: { enabled: false, config: null }, + hermesApiPort: 8642, + hermesPortableLifecycle: true, + dashboardPort: 0, + lifecycleGeneration: "33333333-3333-4333-8333-333333333333", + lifecycleLiveIdentityFingerprint: "e".repeat(64), + gatewayName: "owner-gateway", + gatewayPort: 8080, + registerSandbox: vi.fn(), + }); + + expect(entry.hermesApiPort).toBeUndefined(); + }); + it("passes the built entry to the supplied registry writer", () => { const registerSandbox = vi.fn(); const hostLocalInferenceReceipt = serializedHostLocalInferenceReceipt("docker"); diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index a0a16ca7eb6..b269911f8a7 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -21,6 +21,7 @@ import { cloneSandboxHostLocalInferenceReceipt, requireSandboxHostLocalInferenceProvenance, } from "../state/registry/host-local-inference"; +import type { QualifiedSandboxInferenceRouteReservation } from "../state/registry/route-reservation"; import { cloneSandboxWorkloadReceipt } from "../state/registry/workload"; import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure"; import type { DcodeAutoApprovalMode } from "./dcode-auto-approval"; @@ -87,6 +88,8 @@ export interface CreatedSandboxRegistryEntryInput { hermesDashboardState: HermesDashboardOnboardState; /** Host port this sandbox exposes its OpenAI-compatible API on. */ hermesApiPort?: number | null; + /** True only when schema-5 receipt authority owns this Hermes registration. */ + hermesPortableLifecycle?: boolean; dashboardPort: number; dashboardRemoteBindPrepared?: boolean; lifecycleGeneration?: string; @@ -100,7 +103,11 @@ export interface CreatedSandboxRegistrationInput extends CreatedSandboxRegistryE portableLifecycle?: boolean; environment?: NodeJS.ProcessEnv; classifyPortableLifecycleReceipt?: typeof classifyPortableLifecycleReceipt; - registerSandbox?(entry: SandboxEntry): void; + inferenceRouteReservation?: QualifiedSandboxInferenceRouteReservation; + registerSandbox?( + entry: SandboxEntry, + routeReservation?: QualifiedSandboxInferenceRouteReservation, + ): SandboxEntry | void; runtimeProviders?: RuntimeProviderBundleRegistry; } @@ -284,11 +291,13 @@ export function buildCreatedSandboxRegistryEntry( ...getHermesDashboardRegistryFields(input.hermesDashboardState), hermesApiPort: input.agent?.name === "hermes" - ? (input.hermesApiPort ?? - resolveOnboardHermesApiPort(input.sandboxName, { - // Registration follows a successful create/recreate that applied this environment. - allowRegisteredOverride: true, - })) + ? input.hermesPortableLifecycle === true + ? undefined + : (input.hermesApiPort ?? + resolveOnboardHermesApiPort(input.sandboxName, { + // Registration follows a successful create/recreate that applied this environment. + allowRegisteredOverride: true, + })) : undefined, dashboardPort: input.dashboardPort, dashboardRemoteBindPrepared: input.dashboardRemoteBindPrepared === true, @@ -311,7 +320,7 @@ export function loadServingProfileResumeSession(): { } export function registerCreatedSandbox(input: CreatedSandboxRegistrationInput): SandboxEntry { - const pending = registry.getSandbox(input.sandboxName); + const pending = input.inferenceRouteReservation?.entry ?? registry.getSandbox(input.sandboxName); const pendingHostLocalInferenceReceipt = input.hostLocalInferenceReceipt !== undefined ? input.hostLocalInferenceReceipt @@ -356,6 +365,9 @@ export function registerCreatedSandbox(input: CreatedSandboxRegistrationInput): `Runtime provider '${provider.identity.id}' does not accept the registered workload receipt.`, ); } - (input.registerSandbox ?? registry.registerSandbox)(entry); - return entry; + const writeRegistry = input.registerSandbox ?? registry.registerSandbox; + const registered = input.inferenceRouteReservation + ? writeRegistry(entry, input.inferenceRouteReservation) + : writeRegistry(entry); + return registered ?? entry; } diff --git a/src/lib/onboard/sandbox-registry-metadata.ts b/src/lib/onboard/sandbox-registry-metadata.ts index d6cce185fe2..1942e49c18f 100644 --- a/src/lib/onboard/sandbox-registry-metadata.ts +++ b/src/lib/onboard/sandbox-registry-metadata.ts @@ -39,6 +39,23 @@ export interface SandboxRegistryMetadataHelpers { ): void; } +/** Build schema-5 runtime fields without ambient driver or OpenShell discovery. */ +export function getHermesPortableSandboxRuntimeRegistryFields( + config: SandboxGpuConfig, + openshellVersion: "0.0.101", +): ReturnType { + return { + gpuEnabled: config.sandboxGpuEnabled, + hostGpuDetected: config.hostGpuDetected, + sandboxGpuEnabled: config.sandboxGpuEnabled, + sandboxGpuMode: config.mode, + sandboxGpuDevice: config.sandboxGpuDevice, + ...(config.sandboxGpuProof ? { sandboxGpuProof: config.sandboxGpuProof } : {}), + openshellDriver: "docker", + openshellVersion, + }; +} + export function createSandboxRegistryMetadataHelpers( deps: SandboxRegistryMetadataDeps, ): SandboxRegistryMetadataHelpers { diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index 56ae9be5e7a..5e63f5a9875 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + import type { ServingProfileProvenance } from "../inference/serving/types"; import { PERSONAL_POLICY_TIER_NAME } from "../policy/tiers"; import { redactSensitiveText } from "../security/redact"; @@ -116,10 +118,19 @@ interface PreviousEnvironmentValue { export interface PortableOnboardEnvironmentScope { readonly env: NodeJS.ProcessEnv; + createHermesPortablePodmanSourceEnvironment( + runtimeAuthority: CheckpointPortableRuntimeAuthority, + ): NodeJS.ProcessEnv; installRuntime(input: { containersConf: string; socketPath: string }): void; restore(): void; } +/** Keep one prepared portable runtime authority with the environment scope that installed it. */ +export interface PortableOnboardRuntimeContext { + readonly authority: CheckpointPortableRuntimeAuthority; + readonly environmentScope: PortableOnboardEnvironmentScope | null; +} + export function createDefaultResumeProfileEnvironmentScope( env: NodeJS.ProcessEnv, ): PortableOnboardEnvironmentScope { @@ -129,6 +140,9 @@ export function createDefaultResumeProfileEnvironmentScope( let restored = false; return { env, + createHermesPortablePodmanSourceEnvironment() { + throw new Error("Default onboarding has no portable Podman environment authority."); + }, installRuntime() { throw new Error("Default onboarding resume cannot install portable runtime authority."); }, @@ -247,13 +261,40 @@ export function createPortableOnboardEnvironmentScope( } } + let installedRuntime: { readonly containersConf: string; readonly dockerHost: string } | null = + null; let restored = false; return { env, + createHermesPortablePodmanSourceEnvironment(runtimeAuthority) { + if (restored || !installedRuntime) { + throw new Error("Hermes portable Podman environment authority is not active."); + } + const expectedContainersConf = path.join( + runtimeAuthority.configHome, + "nemoclaw", + "portable", + "containers.conf", + ); + const expectedDockerHost = `unix://${runtimeAuthority.socketPath}`; + if ( + installedRuntime.containersConf !== expectedContainersConf || + installedRuntime.dockerHost !== expectedDockerHost + ) { + throw new Error("Hermes portable Podman environment disagrees with runtime authority."); + } + const source = { ...env }; + if (source.CONTAINERS_CONF === installedRuntime.containersConf) { + delete source.CONTAINERS_CONF; + } + if (source.DOCKER_HOST === installedRuntime.dockerHost) delete source.DOCKER_HOST; + return source; + }, installRuntime({ containersConf, socketPath }) { env.NETAVARK_FW = "iptables"; env.CONTAINERS_CONF = containersConf; env.DOCKER_HOST = `unix://${socketPath}`; + installedRuntime = { containersConf, dockerHost: env.DOCKER_HOST }; }, restore() { if (restored) return; diff --git a/src/lib/onboard/temp-files.test.ts b/src/lib/onboard/temp-files.test.ts index 6643be81160..a1fbe2f3279 100644 --- a/src/lib/onboard/temp-files.test.ts +++ b/src/lib/onboard/temp-files.test.ts @@ -2,16 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanupTempDir, secureTempFile } from "./temp-files"; +import { cleanupTempDir, createExactTempFileCleanup, secureTempFile } from "./temp-files"; const createdParents: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const parent of createdParents.splice(0)) { fs.rmSync(parent, { recursive: true, force: true }); } @@ -70,6 +72,110 @@ describe("onboard temp file helpers", () => { expect(fs.existsSync(parent)).toBe(false); }); + it("removes only the captured private file generation and is idempotent (#9203)", () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + fs.writeFileSync(filePath, "captured", { mode: 0o600 }); + const cleanup = createExactTempFileCleanup(filePath, "nemoclaw-cleanup"); + + expect(cleanup()).toBe(true); + expect(cleanup()).toBe(true); + expect(fs.existsSync(parent)).toBe(false); + }); + + it("preserves a replacement file generation and fails closed (#9203)", () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + const displacedParent = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-displaced-")); + const original = path.join(displacedParent, path.basename(filePath)); + createdParents.push(parent, displacedParent); + fs.writeFileSync(filePath, "captured", { mode: 0o600 }); + const cleanup = createExactTempFileCleanup(filePath, "nemoclaw-cleanup"); + fs.renameSync(filePath, original); + fs.writeFileSync(filePath, "replacement", { mode: 0o600 }); + + expect(cleanup()).toBe(false); + expect(fs.readFileSync(filePath, "utf8")).toBe("replacement"); + expect(fs.readFileSync(original, "utf8")).toBe("captured"); + }); + + it("restores the task directory after post-detach authority drift (#9203)", () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + createdParents.push(parent); + fs.writeFileSync(filePath, "captured", { mode: 0o600 }); + const cleanup = createExactTempFileCleanup(filePath, "nemoclaw-cleanup"); + const rename = fs.renameSync.bind(fs); + let quarantine: string | null = null; + vi.spyOn(fs, "renameSync").mockImplementationOnce((source, target) => { + rename(source, target); + quarantine = path.dirname(String(target)); + fs.writeFileSync(path.join(String(target), path.basename(filePath)), "changed", { + mode: 0o600, + }); + }); + + expect(cleanup()).toBe(false); + expect(fs.readFileSync(filePath, "utf8")).toBe("changed"); + expect(quarantine).not.toBeNull(); + expect(fs.existsSync(quarantine!)).toBe(false); + }); + + it("preserves a replaced task directory and fails closed (#9203)", () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + const originalParent = `${parent}-original`; + createdParents.push(parent, originalParent); + fs.writeFileSync(filePath, "captured", { mode: 0o600 }); + const cleanup = createExactTempFileCleanup(filePath, "nemoclaw-cleanup"); + fs.renameSync(parent, originalParent); + fs.mkdirSync(parent, { mode: 0o700 }); + fs.writeFileSync(filePath, "replacement", { mode: 0o600 }); + + expect(cleanup()).toBe(false); + expect(fs.readFileSync(filePath, "utf8")).toBe("replacement"); + expect(fs.readFileSync(path.join(originalParent, path.basename(filePath)), "utf8")).toBe( + "captured", + ); + }); + + it("fails closed when current-user ownership cannot be established (#9203)", () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + createdParents.push(parent); + fs.writeFileSync(filePath, "captured", { mode: 0o600 }); + vi.spyOn(process, "getuid").mockReturnValue(undefined as never); + + expect(() => createExactTempFileCleanup(filePath, "nemoclaw-cleanup")).toThrow( + "Current-user temporary file authority is unavailable", + ); + }); + + it.skipIf(process.platform === "win32")( + "rejects a FIFO replacement without blocking on open (#9203)", + () => { + const filePath = secureTempFile("nemoclaw-cleanup", ".txt"); + const parent = path.dirname(filePath); + createdParents.push(parent); + fs.writeFileSync(filePath, "captured", { mode: 0o600 }); + const originalLstat = fs.lstatSync; + vi.spyOn(fs, "lstatSync") + .mockImplementationOnce(originalLstat) + .mockImplementationOnce(((target, options) => { + const stat = originalLstat(target, options as never); + expect(path.resolve(String(target))).toBe(path.resolve(filePath)); + fs.unlinkSync(filePath); + execFileSync("mkfifo", [filePath]); + return stat; + }) as typeof fs.lstatSync); + + expect(() => createExactTempFileCleanup(filePath, "nemoclaw-cleanup")).toThrow( + "Exact temporary file must be a regular single-link file", + ); + expect(fs.lstatSync(filePath).isFIFO()).toBe(true); + }, + ); + it("does not remove unrelated temp directories", () => { const parent = fs.mkdtempSync(path.join(os.tmpdir(), "other-prefix-")); createdParents.push(parent); diff --git a/src/lib/onboard/temp-files.ts b/src/lib/onboard/temp-files.ts index f709f5a7036..bbfd678e82a 100644 --- a/src/lib/onboard/temp-files.ts +++ b/src/lib/onboard/temp-files.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import crypto from "node:crypto"; import os from "node:os"; import path from "node:path"; @@ -44,3 +45,169 @@ export function cleanupTempDir(filePath: string, expectedPrefix: string): void { fs.rmSync(parentDir, { recursive: true, force: true }); } } + +type ExactTempFileAuthority = { + readonly bytesSha256: string; + readonly fileDev: bigint; + readonly fileIno: bigint; + readonly parentDev: bigint; + readonly parentIno: bigint; +}; + +function readExactTempFileAuthority(filePath: string): ExactTempFileAuthority { + const tempRoot = path.resolve(os.tmpdir()); + const parentDir = path.resolve(path.dirname(filePath)); + const relativeParent = path.relative(tempRoot, parentDir); + if (relativeParent === "" || relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) { + throw new Error("Exact temporary file authority is outside its task-owned directory"); + } + const uid = process.getuid?.(); + if (uid === undefined) throw new Error("Current-user temporary file authority is unavailable"); + const parent = fs.lstatSync(parentDir, { bigint: true }); + if ( + !parent.isDirectory() || + parent.isSymbolicLink() || + parent.uid !== BigInt(uid) || + (parent.mode & 0o777n) !== 0o700n || + fs.readdirSync(parentDir).some((entry) => entry !== path.basename(filePath)) + ) { + throw new Error( + "Exact temporary file directory must be a non-symlink directory with mode 0700, current-user ownership when available, and only the expected file", + ); + } + const named = fs.lstatSync(filePath, { bigint: true }); + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== 1n || + before.uid !== BigInt(uid) || + (before.mode & 0o777n) !== 0o600n || + named.dev !== before.dev || + named.ino !== before.ino + ) { + throw new Error( + "Exact temporary file must be a regular single-link file with mode 0600 and current-user ownership when available", + ); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const finalParent = fs.lstatSync(parentDir, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + parent.dev !== finalParent.dev || + parent.ino !== finalParent.ino || + parent.uid !== finalParent.uid || + parent.mode !== finalParent.mode + ) { + throw new Error("Exact temporary file authority changed while reading"); + } + return { + bytesSha256: crypto.createHash("sha256").update(bytes).digest("hex"), + fileDev: before.dev, + fileIno: before.ino, + parentDev: parent.dev, + parentIno: parent.ino, + }; + } finally { + fs.closeSync(descriptor); + } +} + +function captureExactTempFileAuthority( + filePath: string, + expectedPrefix: string, +): ExactTempFileAuthority { + const safePrefix = validateTempPrefix(expectedPrefix); + const parentDir = path.resolve(path.dirname(filePath)); + if ( + !path.basename(parentDir).startsWith(`${safePrefix}-`) || + path.basename(filePath) !== `${safePrefix}${path.extname(filePath)}` + ) { + throw new Error("Exact temporary file authority is outside its task-owned directory"); + } + return readExactTempFileAuthority(filePath); +} + +/** Detach and remove only one exact task-created policy file generation. */ +export function createExactTempFileCleanup( + filePath: string, + expectedPrefix: string, +): () => boolean { + const authority = captureExactTempFileAuthority(filePath, expectedPrefix); + const parentDir = path.resolve(path.dirname(filePath)); + const fileName = path.basename(filePath); + let completed = false; + return () => { + if (completed) return true; + try { + const current = captureExactTempFileAuthority(filePath, expectedPrefix); + if ( + current.parentDev !== authority.parentDev || + current.parentIno !== authority.parentIno || + current.fileDev !== authority.fileDev || + current.fileIno !== authority.fileIno || + current.bytesSha256 !== authority.bytesSha256 + ) { + return false; + } + const quarantine = fs.mkdtempSync( + path.join(path.resolve(os.tmpdir()), `${validateTempPrefix(expectedPrefix)}-retired-`), + ); + fs.chmodSync(quarantine, 0o700); + const detachedParent = path.join(quarantine, "source"); + let detached = false; + const restore = (): boolean => { + if (!detached) return true; + if (fs.existsSync(parentDir)) return false; + try { + fs.renameSync(detachedParent, parentDir); + detached = false; + fs.rmdirSync(quarantine); + return true; + } catch { + return false; + } + }; + try { + fs.renameSync(parentDir, detachedParent); + detached = true; + const detachedFile = path.join(detachedParent, fileName); + const detachedAuthority = readExactTempFileAuthority(detachedFile); + const finalNamed = fs.lstatSync(detachedFile, { bigint: true }); + if ( + detachedAuthority.parentDev !== authority.parentDev || + detachedAuthority.parentIno !== authority.parentIno || + detachedAuthority.fileDev !== authority.fileDev || + detachedAuthority.fileIno !== authority.fileIno || + detachedAuthority.bytesSha256 !== authority.bytesSha256 || + finalNamed.dev !== authority.fileDev || + finalNamed.ino !== authority.fileIno + ) { + restore(); + return false; + } + fs.unlinkSync(detachedFile); + fs.rmdirSync(detachedParent); + detached = false; + fs.rmdirSync(quarantine); + completed = true; + return true; + } catch { + restore(); + return false; + } + } catch { + return false; + } + }; +} diff --git a/src/lib/onboard/tool-disclosure-flow.test.ts b/src/lib/onboard/tool-disclosure-flow.test.ts index bfb9780f837..5381c5fe66c 100644 --- a/src/lib/onboard/tool-disclosure-flow.test.ts +++ b/src/lib/onboard/tool-disclosure-flow.test.ts @@ -23,6 +23,7 @@ vi.mock("./dockerfile-tool-disclosure-contract", () => ({ import { applyOnboardToolDisclosureRequest, + prepareHermesPortableToolDisclosure, prepareSandboxToolDisclosure, } from "./tool-disclosure-flow"; @@ -193,4 +194,18 @@ describe("onboard tool-disclosure flow", () => { expect(mocks.updateSession).toHaveBeenCalledOnce(); expect(mocks.removeSandbox).not.toHaveBeenCalled(); }); + + it("resolves schema-5 tool disclosure without reading or writing session state (#9203)", () => { + const result = prepareHermesPortableToolDisclosure("direct"); + + expect(result).toMatchObject({ + existingEntry: null, + liveExists: false, + effectiveToolDisclosure: "direct", + toolDisclosureMigrationNeeded: false, + }); + expect(mocks.loadSession).not.toHaveBeenCalled(); + expect(mocks.updateSession).not.toHaveBeenCalled(); + expect(mocks.removeSandbox).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/onboard/tool-disclosure-flow.ts b/src/lib/onboard/tool-disclosure-flow.ts index ef86896f0e8..f3e6faa5993 100644 --- a/src/lib/onboard/tool-disclosure-flow.ts +++ b/src/lib/onboard/tool-disclosure-flow.ts @@ -78,3 +78,31 @@ export function prepareSandboxToolDisclosure( : null, }; } + +/** Resolve schema-5 tool disclosure without reading live state or writing session state. */ +export function prepareHermesPortableToolDisclosure( + desiredToolDisclosure: ToolDisclosure | null = null, +) { + let mode: ToolDisclosure; + try { + mode = resolveSandboxToolDisclosure({ + requested: desiredToolDisclosure ?? resolveToolDisclosureRequest(null, process.env), + recorded: undefined, + session: undefined, + sandboxExists: false, + recreate: false, + }); + } catch (error) { + throw new Error( + `Hermes portable tool disclosure configuration is invalid: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return { + existingEntry: null, + preservedMcpState: undefined, + liveExists: false, + effectiveToolDisclosure: mode, + toolDisclosureMigrationNeeded: false, + toolDisclosureMigrationNote: null, + }; +} diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index 3783929be02..693646d4521 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -84,6 +84,11 @@ export interface SandboxCreateIntent { readonly rebuildPreservedEnv?: readonly import("../state/preserved-env").PreservedEnvFile[]; } +/** Durable onboarding-session identity that owns the pending inference route. */ +export interface InferenceRouteReservationAuthority { + readonly sessionId: string; +} + export type OnboardOptions = { /** Hidden temporary opt-in for new managed-image runtime activation. */ tempManagedRuntime?: boolean; diff --git a/src/lib/sandbox/create-stream-argv.test.ts b/src/lib/sandbox/create-stream-argv.test.ts index 6b117f40296..95ee4477af3 100644 --- a/src/lib/sandbox/create-stream-argv.test.ts +++ b/src/lib/sandbox/create-stream-argv.test.ts @@ -62,4 +62,23 @@ describe("sandbox-create-stream argv boundary", () => { expect.objectContaining({ env: process.env }), ); }); + + it("uses the exact schema-owned build context without changing argv dispatch (#9203)", async () => { + const child = new FakeChild(); + const spawnImpl = vi.fn(() => child); + const promise = streamSandboxCreate( + "openshell", + ["sandbox", "create", "--from", "/private/context/Dockerfile"], + dockerEnv, + { cwd: "/private/context", spawnImpl, logLine: vi.fn() }, + ); + + child.emit("close", 0); + await expect(promise).resolves.toMatchObject({ status: 0 }); + expect(spawnImpl).toHaveBeenCalledWith( + "openshell", + ["sandbox", "create", "--from", "/private/context/Dockerfile"], + expect.objectContaining({ cwd: "/private/context" }), + ); + }); }); diff --git a/src/lib/sandbox/create-stream.ts b/src/lib/sandbox/create-stream.ts index 4cf4172f4d2..787af28be36 100644 --- a/src/lib/sandbox/create-stream.ts +++ b/src/lib/sandbox/create-stream.ts @@ -23,6 +23,8 @@ export interface StreamSandboxCreateResult { } export interface StreamSandboxCreateOptions { + /** Schema-owned build context. Ordinary create paths keep the repository root. */ + cwd?: string; readyCheck?: (() => boolean) | null; // Optional poll side effect. Must be paired with failureCheck so any // observed side-effect error has an authoritative terminal-state classifier. @@ -114,7 +116,7 @@ export function streamSandboxCreate( const spawnChild = options.spawnImpl ?? spawn; const ownProcessGroup = spawnChild === spawn && process.platform !== "win32"; const child: StreamableChildProcess = spawnChild(spawnCommand, commandArgs, { - cwd: ROOT, + cwd: options.cwd ?? ROOT, env, detached: ownProcessGroup, stdio: ["ignore", "pipe", "pipe"], diff --git a/src/lib/state/portable-uninstall-retirement.test.ts b/src/lib/state/portable-uninstall-retirement.test.ts index 9eaf64d86b0..71d20c4afc0 100644 --- a/src/lib/state/portable-uninstall-retirement.test.ts +++ b/src/lib/state/portable-uninstall-retirement.test.ts @@ -9,6 +9,9 @@ import path from "node:path"; import { afterEach, assert, describe, expect, it, vi } from "vitest"; import { + assertNoHermesPortableHostAuthority, + defaultPortableStateDir, + getHermesPortableHostAuthorityEntryCount, hasPortableRetirementRecord, inspectPortableRetirementRecovery, portableRetirementFingerprint, @@ -85,6 +88,54 @@ afterEach(() => { }); describe("portable uninstall retirement state", () => { + it("shares the guarded portable state root across host admission owners (#9203)", () => { + const homeDir = "/home/nemoclaw-test"; + const stateDir = "/private/nemoclaw-test-state"; + + expect( + defaultPortableStateDir({ + HOME: homeDir, + NEMOCLAW_TEST_STATE_DIR: stateDir, + }), + ).toBe(path.join(homeDir, ".nemoclaw")); + expect( + defaultPortableStateDir({ + HOME: homeDir, + VITEST: "true", + NEMOCLAW_TEST_BASE_HOME: homeDir, + NEMOCLAW_TEST_STATE_DIR: stateDir, + }), + ).toBe(stateDir); + expect(defaultPortableStateDir({ HOME: "" })).toBe(path.join(os.homedir(), ".nemoclaw")); + expect(defaultPortableStateDir({})).toBe(path.join(os.homedir(), ".nemoclaw")); + }); + + it.each([1, 2])( + "fails closed on %i malformed or ambiguous schema-5 authority entries (#9203)", + (entryCount) => { + const test = fixture(); + const authorityRoot = path.join(test.stateDir, "hermes-portable-lifecycle"); + fs.mkdirSync(authorityRoot, { mode: 0o700 }); + Array.from({ length: entryCount }, (_unused, index) => + fs.writeFileSync(path.join(authorityRoot, `ambiguous-${index}`), "not-a-receipt\n", { + mode: 0o600, + }), + ); + + expect(getHermesPortableHostAuthorityEntryCount(test.stateDir)).toBe(entryCount); + expect(() => assertNoHermesPortableHostAuthority(test.stateDir, "list")).toThrow( + "Command 'list' is not supported while an experimental Hermes portable lifecycle receipt exists. No legacy Docker or OpenShell action was attempted.", + ); + }, + ); + + it("preserves ordinary host command admission when schema-5 authority is absent (#9203)", () => { + const test = fixture(); + + expect(getHermesPortableHostAuthorityEntryCount(test.stateDir)).toBe(0); + expect(() => assertNoHermesPortableHostAuthority(test.stateDir, "list")).not.toThrow(); + }); + it("publishes the sole private retry record without raw cleanup authority (#9189)", () => { const test = fixture(); const prepared = prepareFixture(test); @@ -113,33 +164,40 @@ describe("portable uninstall retirement state", () => { Buffer.from("bc"), ), ); - expect(([ + expect( + ( + [ [255, "09adecb5bfc2c496d9e1f4e737b4973699fa3f6f4a9027c0633bda893f7d9cfb"], [256, "a62d83f4aa319e94abbccbb75d1dea277e450e5167be6872d87eb52d2e7a8b30"], [65_535, "ab48f19398e2af46d86be80dea98e8326b67360e6cd8d3a171cc8c1a2b67a1b7"], [65_536, "e868451822f03cecc74591d7785d6799f01d6742a83082d45d9f033c970afdbd"], - ] as const).every(([size, vector]) => - Object.is( - portableRetirementFingerprint( - "a".repeat(64), - "config", - "containers.conf", - Buffer.alloc(size, 1), - ), - vector, - ))).toBe(true); - ([ - ["", "config", "containers.conf", Buffer.from("x")], - [new String("a".repeat(64)), "config", "containers.conf", Buffer.from("x")], - ["A".repeat(64), "config", "containers.conf", Buffer.from("x")], - ["a".repeat(64), "invalid", "containers.conf", Buffer.from("x")], - ["a".repeat(64), "config", "containers.conf\0", Buffer.from("x")], - ["a".repeat(64), "receipt", "../receipt.json", Buffer.from("x")], - ["a".repeat(64), "registry", "sandboxes.json", Buffer.alloc(0)], - ["a".repeat(64), "receipt", `${"b".repeat(64)}.json`, Buffer.alloc(4_097)], - ["a".repeat(64), "config", "containers.conf", Buffer.alloc(65_537)], - ["a".repeat(64), "registry", "sandboxes.json", Buffer.alloc(1_048_577)], - ] as const).forEach((input) => { + ] as const + ).every(([size, vector]) => + Object.is( + portableRetirementFingerprint( + "a".repeat(64), + "config", + "containers.conf", + Buffer.alloc(size, 1), + ), + vector, + ), + ), + ).toBe(true); + ( + [ + ["", "config", "containers.conf", Buffer.from("x")], + [new String("a".repeat(64)), "config", "containers.conf", Buffer.from("x")], + ["A".repeat(64), "config", "containers.conf", Buffer.from("x")], + ["a".repeat(64), "invalid", "containers.conf", Buffer.from("x")], + ["a".repeat(64), "config", "containers.conf\0", Buffer.from("x")], + ["a".repeat(64), "receipt", "../receipt.json", Buffer.from("x")], + ["a".repeat(64), "registry", "sandboxes.json", Buffer.alloc(0)], + ["a".repeat(64), "receipt", `${"b".repeat(64)}.json`, Buffer.alloc(4_097)], + ["a".repeat(64), "config", "containers.conf", Buffer.alloc(65_537)], + ["a".repeat(64), "registry", "sandboxes.json", Buffer.alloc(1_048_577)], + ] as const + ).forEach((input) => { expect(() => portableRetirementFingerprint( ...(input as unknown as Parameters), diff --git a/src/lib/state/portable-uninstall-retirement.ts b/src/lib/state/portable-uninstall-retirement.ts index d0d8122443a..d5da3d6d692 100644 --- a/src/lib/state/portable-uninstall-retirement.ts +++ b/src/lib/state/portable-uninstall-retirement.ts @@ -4,6 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash, randomBytes } from "node:crypto"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { TextDecoder } from "node:util"; import { isErrnoException } from "../core/errno"; @@ -99,6 +100,34 @@ const tails = new Map>(); export const portableHostFencePath = (homeDir: string): string => path.join(homeDir, ".nemoclaw-portable-host.lock"); +/** Resolve the portable state root while admitting only the isolated Vitest override. */ +export function defaultPortableStateDir(env: NodeJS.ProcessEnv): string { + if ( + env.VITEST === "true" && + (env.HOME ?? "") === env.NEMOCLAW_TEST_BASE_HOME && + env.NEMOCLAW_TEST_STATE_DIR && + path.isAbsolute(env.NEMOCLAW_TEST_STATE_DIR) + ) { + return env.NEMOCLAW_TEST_STATE_DIR; + } + return path.join(env.HOME || os.homedir(), ".nemoclaw"); +} + +/** Count bounded schema-5 authority leaves while the caller holds the host fence. */ +export function getHermesPortableHostAuthorityEntryCount(stateDir: string): number { + return readPortableAuthorityDirectory(path.join(stateDir, "hermes-portable-lifecycle"), false) + .entries.length; +} + +/** Reject host-wide legacy work while schema-5 receipt authority exists. */ +export function assertNoHermesPortableHostAuthority(stateDir: string, commandId: string): void { + if (getHermesPortableHostAuthorityEntryCount(stateDir) > 0) { + throw new Error( + `Command '${commandId}' is not supported while an experimental Hermes portable lifecycle receipt exists. No legacy Docker or OpenShell action was attempted.`, + ); + } +} + function releaseFenceReference(owner: FenceOwner): void { owner.references -= 1; if (owner.references === 0) owner.resolveDrained(); @@ -172,6 +201,11 @@ export async function withPortableHostFence( } } +/** Hold the portable host fence for the current process home without a second state owner. */ +export function withCurrentPortableHostFence(operation: () => Promise | T): Promise { + return withPortableHostFence(process.env.HOME || os.homedir(), operation); +} + const root = (homeDir: string): string => path.join(homeDir, ".nemoclaw"); const paths = (homeDir: string) => Object.fromEntries( diff --git a/src/lib/state/registry-route-reservation.test.ts b/src/lib/state/registry-route-reservation.test.ts index 8405e10c3c7..d074b842a12 100644 --- a/src/lib/state/registry-route-reservation.test.ts +++ b/src/lib/state/registry-route-reservation.test.ts @@ -6,6 +6,53 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { serializedHostLocalInferenceReceipt } from "../../../test/helpers/host-local-inference-receipt"; +import type { SandboxInferenceRouteReservationDisposition } from "./registry/route-reservation"; + +function ownedReservation(disposition: SandboxInferenceRouteReservationDisposition) { + expect(disposition.kind).toBe("owned"); + return (disposition as Extract).reservation; +} + +const EXACT_ROUTE_SELECTION = { + provider: "ollama-local", + model: "qwen3-vl:4b", + endpointUrl: "http://127.0.0.1:11434/v1", + endpointSource: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, +} as const; + +const EXACT_ROUTE_AUTHORITY = { + sandboxName: "alpha", + gatewayName: "nemoclaw", + sessionId: "session-owner", + selection: EXACT_ROUTE_SELECTION, +} as const; + +const EXACT_ROUTE_RESERVATION = { + name: EXACT_ROUTE_AUTHORITY.sandboxName, + gatewayName: EXACT_ROUTE_AUTHORITY.gatewayName, + reservationSessionId: EXACT_ROUTE_AUTHORITY.sessionId, + pendingRouteReservation: true as const, + ...EXACT_ROUTE_SELECTION, +}; + +function reserveQualifiedRoute(registry: typeof import("./registry")) { + registry.reserveSandboxInferenceRoute(EXACT_ROUTE_AUTHORITY.sandboxName, { + ...EXACT_ROUTE_SELECTION, + gatewayName: EXACT_ROUTE_AUTHORITY.gatewayName, + reservationSessionId: EXACT_ROUTE_AUTHORITY.sessionId, + }); + return ownedReservation( + registry.classifySandboxInferenceRouteReservation( + EXACT_ROUTE_AUTHORITY, + registry.getSandbox(EXACT_ROUTE_AUTHORITY.sandboxName), + ), + ); +} describe("sandbox inference route reservation", () => { afterEach(() => { @@ -268,6 +315,106 @@ describe("sandbox inference route reservation", () => { await fs.rm(home, { recursive: true, force: true }); } }); + + it("atomically consumes only the exact qualified route reservation (#9203)", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-route-reservation-")); + vi.stubEnv("HOME", home); + vi.resetModules(); + try { + const registry = await import("./registry"); + const qualified = reserveQualifiedRoute(registry); + + const registered = registry.registerSandbox( + { + name: "alpha", + ...EXACT_ROUTE_SELECTION, + agent: "hermes", + openshellDriver: "docker", + gatewayName: "nemoclaw", + }, + qualified, + ); + + expect(registered).toMatchObject({ + name: "alpha", + provider: "ollama-local", + model: "qwen3-vl:4b", + agent: "hermes", + }); + expect(registered.pendingRouteReservation).toBeUndefined(); + expect(registry.getSandbox("alpha")).toEqual(registered); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); + + it("rejects route reservation replacement inside the registry lock (#9203)", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-route-reservation-")); + vi.stubEnv("HOME", home); + vi.resetModules(); + try { + const registry = await import("./registry"); + const qualified = reserveQualifiedRoute(registry); + registry.reserveSandboxInferenceRoute("alpha", { + ...EXACT_ROUTE_SELECTION, + model: "another-model", + gatewayName: "nemoclaw", + reservationSessionId: "another-session", + }); + + expect(() => + registry.registerSandbox( + { + name: "alpha", + ...EXACT_ROUTE_SELECTION, + agent: "hermes", + openshellDriver: "docker", + gatewayName: "nemoclaw", + }, + qualified, + ), + ).toThrow("Cannot register a sandbox after its inference route reservation changed"); + expect(registry.getSandbox("alpha")).toMatchObject({ + pendingRouteReservation: true, + reservationSessionId: "another-session", + model: "another-model", + }); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } + }); +}); + +describe("sandbox inference route reservation qualification (#9203)", () => { + it.each([ + ["missing", null, "missing"], + ["ownerless", { ...EXACT_ROUTE_RESERVATION, reservationSessionId: undefined }, "conflict"], + [ + "foreign-session", + { ...EXACT_ROUTE_RESERVATION, reservationSessionId: "another-session" }, + "conflict", + ], + ["mismatched-sandbox", { ...EXACT_ROUTE_RESERVATION, name: "beta" }, "conflict"], + [ + "mismatched-gateway", + { ...EXACT_ROUTE_RESERVATION, gatewayName: "other-gateway" }, + "conflict", + ], + ["mismatched-route", { ...EXACT_ROUTE_RESERVATION, model: "another-model" }, "conflict"], + ["malformed", { ...EXACT_ROUTE_RESERVATION, gatewayPort: 0 }, "conflict"], + [ + "completed", + { ...EXACT_ROUTE_RESERVATION, createdAt: "2026-08-18T00:00:00.000Z" }, + "conflict", + ], + ["sandbox-authority", { ...EXACT_ROUTE_RESERVATION, agent: "hermes" }, "conflict"], + ])("classifies %s reservation authority", async (_case, entry, expectedKind) => { + const { classifySandboxInferenceRouteReservation } = + await import("./registry/route-reservation"); + expect(classifySandboxInferenceRouteReservation(EXACT_ROUTE_AUTHORITY, entry).kind).toBe( + expectedKind, + ); + }); }); describe("pending reservation ownership (#6562)", () => { diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 6ef6a4f22a4..842a5c11851 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -29,6 +29,22 @@ import { load, save, } from "./registry/persistence"; +import { + isCurrentSandboxInferenceRouteReservation, + sandboxRegistrationMatchesInferenceRouteReservation, + type QualifiedSandboxInferenceRouteReservation, +} from "./registry/route-reservation"; +export { + classifySandboxInferenceRouteReservation, + isCurrentSandboxInferenceRouteReservation, + isPendingReservationForSession, + isRouteOnlySandboxReservation, + normalizeSandboxInferenceRouteSelection, + sandboxRegistrationMatchesInferenceRouteReservation, + type QualifiedSandboxInferenceRouteReservation, + type SandboxInferenceRouteReservationAuthority, + type SandboxInferenceRouteReservationDisposition, +} from "./registry/route-reservation"; import { cloneSandboxWorkloadReceipt } from "./registry/workload"; import { normalizeSandboxMcpState } from "./registry-mcp"; import { @@ -125,9 +141,22 @@ export function getDefault(): string | null { return names.length > 0 ? names[0] || null : null; } -export function registerSandbox(entry: SandboxEntry): void { - withLock(() => { +export function registerSandbox( + entry: SandboxEntry, + routeReservation?: QualifiedSandboxInferenceRouteReservation, +): SandboxEntry { + return withLock(() => { const data = load(); + if ( + routeReservation && + (!isCurrentSandboxInferenceRouteReservation( + routeReservation, + data.sandboxes[entry.name] ?? null, + ) || + !sandboxRegistrationMatchesInferenceRouteReservation(entry, routeReservation)) + ) { + throw new Error("Cannot register a sandbox after its inference route reservation changed"); + } const servingProfileProvenance = parseServingProfileProvenance(entry.servingProfileProvenance); if (entry.servingProfileProvenance !== undefined && !servingProfileProvenance) { throw new Error("Cannot register a sandbox with invalid serving profile provenance"); @@ -175,7 +204,7 @@ export function registerSandbox(entry: SandboxEntry): void { ); } } - data.sandboxes[entry.name] = { + const registered: SandboxEntry = { name: entry.name, createdAt: entry.createdAt || new Date().toISOString(), servingProfileProvenance: servingProfileProvenance ?? undefined, @@ -254,10 +283,12 @@ export function registerSandbox(entry: SandboxEntry): void { gatewayName: entry.gatewayName ?? undefined, gatewayPort: entry.gatewayPort ?? undefined, }; + data.sandboxes[entry.name] = registered; // Registration establishes a new sandbox lifecycle and may not inherit a // deep-off readiness record carried from a previous same-named row. discardOpaqueCuaRuntimeReadiness(data, entry.name); save(reversibleRemoval.claimInitialDefaultInRegistry(data, entry.name)); + return structuredClone(registered); }); } @@ -359,31 +390,6 @@ export function reserveSandboxInferenceRoute( }); } -/** - * True only for an inference route reserved before sandbox registration. - * - * Structural parameter (only the two fields it reads) so display-layer entry - * types that omit the rest of the durable registry shape can reuse this single - * source of truth instead of re-deriving the predicate (#7609). - */ -export function isRouteOnlySandboxReservation(entry: { - pendingRouteReservation?: true; - createdAt?: string; -}): boolean { - return entry.pendingRouteReservation === true && entry.createdAt === undefined; -} - -export function isPendingReservationForSession( - entry: SandboxEntry | null, - sessionId: string | null | undefined, -): boolean { - return ( - entry?.pendingRouteReservation === true && - Boolean(sessionId) && - entry.reservationSessionId === sessionId - ); -} - const HOST_LOCAL_INFERENCE_LIFECYCLE_AUTHORITY_FIELDS = new Set([ "credentialEnv", "endpointSource", diff --git a/src/lib/state/registry/route-reservation.ts b/src/lib/state/registry/route-reservation.ts new file mode 100644 index 00000000000..10bc276eba3 --- /dev/null +++ b/src/lib/state/registry/route-reservation.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isDeepStrictEqual } from "node:util"; + +import { normalizeInferenceSelection, type InferenceSelection } from "../../inference/selection"; +import type { SandboxEntry } from "./types"; + +const ROUTE_RESERVATION_KEYS = new Set([ + "credentialEnv", + "endpointSource", + "endpointUrl", + "gatewayName", + "gatewayPort", + "hostLocalInferenceProvenance", + "hostLocalInferenceReceipt", + "model", + "name", + "openshellDriver", + "pendingRouteReservation", + "preferredInferenceApi", + "provider", + "reservationSessionId", +]); + +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u; + +export interface SandboxInferenceRouteReservationAuthority { + readonly sandboxName: string; + readonly gatewayName: string; + readonly sessionId: string; + readonly selection: InferenceSelection; +} + +export interface QualifiedSandboxInferenceRouteReservation { + readonly authority: SandboxInferenceRouteReservationAuthority; + readonly entry: SandboxEntry; +} + +export type SandboxInferenceRouteReservationDisposition = + | { readonly kind: "missing" } + | { readonly kind: "owned"; readonly reservation: QualifiedSandboxInferenceRouteReservation } + | { readonly kind: "not-reservation" } + | { readonly kind: "conflict"; readonly detail: string }; + +export function normalizeSandboxInferenceRouteSelection(input: InferenceSelection) { + const normalized = normalizeInferenceSelection(input); + return { + provider: normalized.provider, + model: normalized.model, + endpointUrl: normalized.endpointUrl, + endpointSource: normalized.endpointSource, + credentialEnv: normalized.credentialEnv, + preferredInferenceApi: normalized.preferredInferenceApi, + }; +} + +/** + * True only for an inference route reserved before sandbox registration. + * + * Structural parameter (only the two fields it reads) so display-layer entry + * types that omit the rest of the durable registry shape can reuse this single + * source of truth instead of re-deriving the predicate (#7609). + */ +export function isRouteOnlySandboxReservation(entry: { + pendingRouteReservation?: true; + createdAt?: string; +}): boolean { + return entry.pendingRouteReservation === true && entry.createdAt === undefined; +} + +/** Return true only when the pending inference route reservation belongs to the exact onboarding session. */ +export function isPendingReservationForSession( + entry: SandboxEntry | null, + sessionId: string | null | undefined, +): boolean { + return ( + entry?.pendingRouteReservation === true && + Boolean(sessionId) && + entry.reservationSessionId === sessionId + ); +} + +/** Qualify one exact pending route reservation without granting sandbox authority. */ +export function classifySandboxInferenceRouteReservation( + authority: SandboxInferenceRouteReservationAuthority, + entry: SandboxEntry | null, +): SandboxInferenceRouteReservationDisposition { + if (!entry) return { kind: "missing" }; + if (entry.pendingRouteReservation !== true) return { kind: "not-reservation" }; + if (!isRouteOnlySandboxReservation(entry)) { + return { kind: "conflict", detail: "the inference route reservation is already completed" }; + } + if (!isPendingReservationForSession(entry, authority.sessionId)) { + return { + kind: "conflict", + detail: "the inference route reservation belongs to another onboarding session", + }; + } + if (Object.keys(entry).some((key) => !ROUTE_RESERVATION_KEYS.has(key as keyof SandboxEntry))) { + return { kind: "conflict", detail: "the inference route reservation has sandbox authority" }; + } + if (entry.name !== authority.sandboxName || entry.gatewayName !== authority.gatewayName) { + return { + kind: "conflict", + detail: "the inference route reservation has another sandbox or gateway", + }; + } + const expectedSelection = normalizeSandboxInferenceRouteSelection(authority.selection); + if ( + !authority.sessionId || + authority.sessionId.length > 256 || + CONTROL_CHARACTER.test(authority.sessionId) || + !expectedSelection.provider || + !expectedSelection.model || + !isDeepStrictEqual( + normalizeSandboxInferenceRouteSelection(normalizeInferenceSelection(entry)), + expectedSelection, + ) + ) { + return { kind: "conflict", detail: "the inference route reservation has another route" }; + } + if ( + (entry.gatewayPort !== undefined && + (typeof entry.gatewayPort !== "number" || + !Number.isSafeInteger(entry.gatewayPort) || + entry.gatewayPort < 1 || + entry.gatewayPort > 65_535)) || + (entry.openshellDriver !== undefined && + (typeof entry.openshellDriver !== "string" || entry.openshellDriver.length === 0)) || + (entry.hostLocalInferenceReceipt !== undefined && + entry.hostLocalInferenceReceipt !== null && + (typeof entry.hostLocalInferenceReceipt !== "string" || + entry.hostLocalInferenceReceipt.length === 0)) || + (entry.hostLocalInferenceProvenance !== undefined && + (typeof entry.hostLocalInferenceProvenance !== "object" || + entry.hostLocalInferenceProvenance === null || + Array.isArray(entry.hostLocalInferenceProvenance) || + typeof entry.hostLocalInferenceReceipt !== "string")) + ) { + return { kind: "conflict", detail: "the inference route reservation is malformed" }; + } + return { + kind: "owned", + reservation: { + authority: { + ...authority, + selection: { ...authority.selection }, + }, + entry: structuredClone(entry), + }, + }; +} + +/** Requalify the same reservation generation before a protected operation. */ +export function isCurrentSandboxInferenceRouteReservation( + reservation: QualifiedSandboxInferenceRouteReservation, + entry: SandboxEntry | null, +): boolean { + const current = classifySandboxInferenceRouteReservation(reservation.authority, entry); + return ( + current.kind === "owned" && isDeepStrictEqual(current.reservation.entry, reservation.entry) + ); +} + +/** Require the final registration to preserve the route selected by the reservation. */ +export function sandboxRegistrationMatchesInferenceRouteReservation( + entry: SandboxEntry, + reservation: QualifiedSandboxInferenceRouteReservation, +): boolean { + return ( + entry.name === reservation.authority.sandboxName && + entry.gatewayName === reservation.authority.gatewayName && + entry.pendingRouteReservation !== true && + isDeepStrictEqual( + normalizeSandboxInferenceRouteSelection(normalizeInferenceSelection(entry)), + normalizeSandboxInferenceRouteSelection(reservation.authority.selection), + ) + ); +} diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index d47134288b2..7edc9f18739 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -24,8 +24,12 @@ import { } from "./messaging/hooks/status-runner"; import type { MessagingAgentId } from "./messaging/manifest"; import { resolveGatewayName } from "./onboard/gateway-binding"; +import { classifyHermesPortableRegistry } from "./onboard/experimental/hermes-portable-onboarding"; +import { inspectPortableAgentReceiptAuthority } from "./onboard/experimental/hermes-portable-receipt"; +import { defaultPortableDemoStateDir } from "./onboard/experimental/portable-runtime-receipt-readiness"; import { summarizeForDebug } from "./state/onboard-session"; import * as registry from "./state/registry"; +import { getHermesPortableHostAuthorityEntryCount } from "./state/portable-uninstall-retirement"; import { createSystemDeps, parseSshProcesses } from "./state/sandbox-session"; import { getServiceStatuses, showStatus as showServiceStatus } from "./tunnel/services"; @@ -295,6 +299,25 @@ export function buildStatusCommandDeps(rootDir: string): ShowStatusCommandDeps { checkMessagingBridgeHealth(rootDir, sandboxName, channels, agent), findMessagingOverlaps, readGatewayLog: (sandboxName) => readGatewayLog(rootDir, sandboxName), + getHermesPortablePhase: (sandboxName) => { + const authority = inspectPortableAgentReceiptAuthority( + sandboxName, + defaultPortableDemoStateDir(process.env), + ); + if (authority.kind !== "hermes") return null; + const disposition = classifyHermesPortableRegistry( + authority.snapshot.receipt, + registry.getSandbox(sandboxName), + ); + if (disposition.kind !== "matching") { + throw new Error( + "Global status found a Hermes portable receipt that disagrees with its registry row.", + ); + } + return authority.snapshot.receipt.phase; + }, + getHermesPortableHostAuthorityCount: () => + getHermesPortableHostAuthorityEntryCount(defaultPortableDemoStateDir(process.env)), log: console.log, }; } diff --git a/src/lib/tunnel/services-gateway-ownership.test.ts b/src/lib/tunnel/services-gateway-ownership.test.ts index fc6b684f990..42dd5aeee9c 100644 --- a/src/lib/tunnel/services-gateway-ownership.test.ts +++ b/src/lib/tunnel/services-gateway-ownership.test.ts @@ -15,6 +15,9 @@ import * as sandboxGatewayStop from "./sandbox-gateway-stop"; import { stopAll } from "./services"; vi.mock("../adapters/docker", () => ({ + dockerCapture: vi.fn(), + dockerForceRm: vi.fn(), + dockerRunDetached: vi.fn(), dockerSpawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "" })), })); diff --git a/test/helpers/destroy-flow-test-harness.ts b/test/helpers/destroy-flow-test-harness.ts index b9f1e699aa4..80c51f2ac92 100644 --- a/test/helpers/destroy-flow-test-harness.ts +++ b/test/helpers/destroy-flow-test-harness.ts @@ -16,6 +16,7 @@ const requireDist = createRequire( const destroyModulePath = "./destroy.js"; export type DestroyHarness = { + assertHermesPortableCommandUnavailableSpy: MockInstance; cleanupGatewaySpy: MockInstance; captureOpenshellSpy: MockInstance; compareAndSwapSessionSpy: MockInstance; @@ -79,6 +80,7 @@ type DestroyHarnessOptions = { mcpAddState?: "prepared"; mcpServers?: string[]; openshellDriver?: string; + portableCommandError?: string; prepareMcpBridgeError?: string; promptResponses?: string[]; provider?: string; @@ -193,6 +195,15 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr const timerControl = requireDist("../../shields/timer-control.js"); const mcpBridge = requireDist("./mcp-bridge.js"); const dockerRun = requireDist("../../adapters/docker/run.js"); + const portableAgentLifecycle = requireDist( + "../../onboard/experimental/portable-agent-lifecycle.js", + ); + + const assertHermesPortableCommandUnavailableSpy = vi + .spyOn(portableAgentLifecycle, "assertHermesPortableCommandUnavailable") + .mockImplementation(() => { + if (options.portableCommandError) throw new Error(options.portableCommandError); + }); vi.spyOn(resolve, "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); const promptSpy = vi.spyOn(credentialStore, "prompt").mockResolvedValue("yes"); @@ -494,6 +505,7 @@ export function createDestroyHarness(options: DestroyHarnessOptions = {}): Destr logSpy.mockClear(); return { + assertHermesPortableCommandUnavailableSpy, cleanupGatewaySpy, captureOpenshellSpy, compareAndSwapSessionSpy, diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index ee6a82cd161..0b2c467216e 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -53,6 +53,7 @@ import { export { installRebuildFlowTestHooks, originalSandboxName, + portableAgentLifecycle, snapshotEnv, } from "./rebuild-flow-harness"; diff --git a/test/helpers/rebuild-flow-harness.ts b/test/helpers/rebuild-flow-harness.ts index e7f84a49a8e..071594096bc 100644 --- a/test/helpers/rebuild-flow-harness.ts +++ b/test/helpers/rebuild-flow-harness.ts @@ -46,6 +46,9 @@ export const onboardCredentialEnv = requireDist("../../onboard/credential-env.js export const onboardSession = requireDist("../../state/onboard-session.js"); export const openshellRuntime = requireDist("../../adapters/openshell/runtime.js"); export const policies = requireDist("../../policy/index.js"); +export const portableAgentLifecycle = requireDist( + "../../onboard/experimental/portable-agent-lifecycle.js", +); export const processRecovery = requireDist("./process-recovery.js"); export const { rebuildOnboardDependencies } = requireDist("./rebuild-onboard-dependencies.js"); export const rebuildCustomImagePreflight = requireDist("./rebuild-custom-image-preflight.js"); diff --git a/test/install-clone-ref.test.ts b/test/install-clone-ref.test.ts index c52d3df0906..f690d262b37 100644 --- a/test/install-clone-ref.test.ts +++ b/test/install-clone-ref.test.ts @@ -13,7 +13,7 @@ import { INSTALLER_PAYLOAD } from "./helpers/installer-sourced-env"; const CURL_PIPE_INSTALLER = path.join(import.meta.dirname, "..", "install.sh"); describe("installer git checkout", () => { - it("fetches fully-qualified refs into a detached checkout", () => { + it("fetches fully-qualified refs into a detached checkout without group- or other-writable source entries", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-clone-ref-")); const origin = path.join(tmp, "origin"); fs.mkdirSync(origin); @@ -34,7 +34,7 @@ describe("installer git checkout", () => { "bash", [ "-c", - 'source "$INSTALLER_UNDER_TEST"\nclone_nemoclaw_ref refs/heads/topic "$DESTINATION"', + 'umask 0002\nsource "$INSTALLER_UNDER_TEST"\nclone_nemoclaw_ref refs/heads/topic "$DESTINATION"\nprintf "CALLER_UMASK=%s\\n" "$(umask)"', ], { encoding: "utf8", @@ -51,6 +51,15 @@ describe("installer git checkout", () => { expect(result.status, result.stderr).toBe(0); expect(git(["-C", destination, "rev-parse", "HEAD"], tmp).stdout.trim()).toBe(expectedHead); expect(git(["-C", destination, "symbolic-ref", "-q", "HEAD"], tmp).status).not.toBe(0); + expect(result.stdout).toContain("CALLER_UMASK=0002"); + expect([ + fs.lstatSync(destination).mode & 0o22, + fs.lstatSync(path.join(destination, ".git")).mode & 0o22, + fs.lstatSync(path.join(destination, ".git", "HEAD")).mode & 0o22, + fs.lstatSync(path.join(destination, ".git", "config")).mode & 0o22, + fs.lstatSync(path.join(destination, ".git", "objects")).mode & 0o22, + fs.lstatSync(path.join(destination, "README.md")).mode & 0o22, + ]).toEqual([0, 0, 0, 0, 0, 0]); }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/install-portable-profile.test.ts b/test/install-portable-profile.test.ts index 04ec89345ba..b1464d28b30 100644 --- a/test/install-portable-profile.test.ts +++ b/test/install-portable-profile.test.ts @@ -34,6 +34,82 @@ function runPortableOverride(profile = "portable", dockerHost = ""): ReturnType< }); } +function runPortableOnboard( + agent: "hermes" | "openclaw", + options: { + readonly childEnv?: Readonly>; + readonly replaceDockerHost?: boolean; + } = {}, +): { readonly child: Readonly>; readonly stdout: string } { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-onboard-")); + const stubBin = path.join(fixture, "stub-cli"); + const childLog = path.join(fixture, "child.env"); + fs.writeFileSync( + stubBin, + `#!/usr/bin/env bash +{ + printf 'DOCKER_HOST_SET=%s\\n' "\${DOCKER_HOST+x}" + printf 'DOCKER_HOST=%s\\n' "\${DOCKER_HOST-}" + printf 'CONTAINER_HOST=%s\\n' "\${CONTAINER_HOST-}" + printf 'DOCKER_CONTEXT=%s\\n' "\${DOCKER_CONTEXT-}" + printf 'ARGS=%s\\n' "$*" +} > "${childLog}" +`, + { mode: 0o755 }, + ); + + const replaceDockerHost = options.replaceDockerHost + ? 'DOCKER_HOST="tcp://replacement.invalid:2375"; export DOCKER_HOST' + : ":"; + const snippet = ` + set -e + source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 || true + _CLI_BIN="${stubBin}" + _CLI_PATH="${stubBin}" + command_exists() { return 0; } + uname() { printf 'Linux\\n'; } + systemctl() { :; } + podman() { printf '/run/user/4242/podman/podman.sock\\n'; } + info() { :; } + warn() { :; } + error() { printf 'ERROR=%s\\n' "$*" >&2; exit 1; } + show_usage_notice() { :; } + prepare_portable_experimental_runtime_override + printf 'INSTALLER_DOCKER_HOST=%s\\n' "$DOCKER_HOST" + ${replaceDockerHost} + run_onboard + `; + + try { + const result = spawnSync("bash", ["-c", snippet], { + encoding: "utf-8", + env: { + ...process.env, + ACCEPT_THIRD_PARTY_SOFTWARE: "1", + HOME: fixture, + NEMOCLAW_AGENT: agent, + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + NON_INTERACTIVE: "1", + ...options.childEnv, + }, + }); + expect(result.status, result.stderr).toBe(0); + const child = Object.fromEntries( + fs + .readFileSync(childLog, "utf-8") + .trimEnd() + .split("\n") + .map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); + return { child, stdout: result.stdout }; + } finally { + fs.rmSync(fixture, { force: true, recursive: true }); + } +} + describe("installer portable profile runtime override", () => { it("selects the Podman-reported rootless socket before installer preflight", () => { const result = runPortableOverride(); @@ -49,6 +125,44 @@ describe("installer portable profile runtime override", () => { expect(result.stderr).toBe(""); }); + it("unsets only its exact Podman DOCKER_HOST selector for the portable Hermes onboarding child", () => { + const result = runPortableOnboard("hermes", { + childEnv: { + CONTAINER_HOST: "ssh://remote.invalid/run/podman.sock", + DOCKER_CONTEXT: "remote-context", + }, + }); + + expect(result.stdout).toContain( + "INSTALLER_DOCKER_HOST=unix:///run/user/4242/podman/podman.sock", + ); + expect(result.child).toMatchObject({ + ARGS: expect.stringContaining("onboard --experimental-profile portable"), + CONTAINER_HOST: "ssh://remote.invalid/run/podman.sock", + DOCKER_CONTEXT: "remote-context", + DOCKER_HOST: "", + DOCKER_HOST_SET: "", + }); + }); + + it("keeps a replaced DOCKER_HOST for strict Hermes rejection", () => { + const result = runPortableOnboard("hermes", { replaceDockerHost: true }); + + expect(result.child).toMatchObject({ + DOCKER_HOST: "tcp://replacement.invalid:2375", + DOCKER_HOST_SET: "x", + }); + }); + + it("preserves the portable OpenClaw Docker CLI selector", () => { + const result = runPortableOnboard("openclaw"); + + expect(result.child).toMatchObject({ + DOCKER_HOST: "unix:///run/user/4242/podman/podman.sock", + DOCKER_HOST_SET: "x", + }); + }); + it("rejects an unknown experimental profile before install effects (#9007)", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-invalid-profile-")); const processTemp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-invalid-profile-tmp-")); diff --git a/test/onboard-mcp-observability-redirect.test.ts b/test/onboard-mcp-observability-redirect.test.ts index 68ede295835..57d5a047fce 100644 --- a/test/onboard-mcp-observability-redirect.test.ts +++ b/test/onboard-mcp-observability-redirect.test.ts @@ -68,6 +68,7 @@ const { createSandbox } = require(${onboardPath}); createSandbox( null, "model", "provider", "openai-completions", "alpha", null, null, null, { name: "langchain-deepagents-code", policyAdditionsPath: ${dcodePolicyPath} }, null, null, null, [], null, + null, { recreate: true, toolDisclosure: "progressive", diff --git a/test/onboard-pre-destructive-intent.test.ts b/test/onboard-pre-destructive-intent.test.ts index 351c1bd757b..babe846d955 100644 --- a/test/onboard-pre-destructive-intent.test.ts +++ b/test/onboard-pre-destructive-intent.test.ts @@ -92,6 +92,7 @@ const resolved = { null, [], null, + null, { resolved, recreate: true, diff --git a/test/onboard-prepared-build-context.test.ts b/test/onboard-prepared-build-context.test.ts index 9718bf71255..1b7112c2fbd 100644 --- a/test/onboard-prepared-build-context.test.ts +++ b/test/onboard-prepared-build-context.test.ts @@ -227,6 +227,7 @@ const { createSandbox } = require(${onboardPath}); [], null, null, + null, preparedBuildContext, ); } catch (error) { diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index 156ecc06577..fe6fa33ecfc 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -32,6 +32,7 @@ delete require.cache[requireDist.resolve(connectModulePath)]; export type ConnectHarness = { applyVmDnsMonkeypatchSpy: MockInstance; captureOpenshellSpy: MockInstance; + captureResolvedOpenshellSpy: MockInstance; checkAndRecoverSpy: MockInstance; connectSandbox: ConnectSandbox; ensureOllamaAuthProxySpy: MockInstance; @@ -50,6 +51,7 @@ export type ConnectHarness = { probeOllamaAuthProxyHealthSpy: MockInstance; readSandboxConfigSpy: MockInstance; recoverPortableDemoLifecycleSpy: MockInstance; + inspectPortableReceiptDispositionSpy: MockInstance; registryEntries: SandboxEntry[]; resolveAgentConfigSpy: MockInstance; restoreSandboxStartupState: RestoreSandboxStartupState; @@ -90,6 +92,15 @@ export type ConnectHarnessOptions = { mcpReconciliationReason?: string; }; portableRecoveryResult?: { kind: "not-installed" | "already-running" | "recovered" }; + portableReceiptDisposition?: + | { kind: "absent" } + | { kind: "openclaw" } + | { + kind: "hermes"; + phase: "pending" | "configuring" | "active"; + gatewayName?: string; + lifecycleGeneration?: string; + }; dockerRuntime?: { health?: string; paused?: boolean; @@ -169,13 +180,89 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne const sandboxSession = requireDist("../../src/lib/state/sandbox-session.js"); const vmDnsMonkeypatch = requireDist("../../src/lib/actions/sandbox/vm-dns-monkeypatch.js"); const launchReadiness = requireDist("../../src/lib/actions/sandbox/launch-readiness.js"); + const portableAgentLifecycle = requireDist( + "../../src/lib/onboard/experimental/portable-agent-lifecycle.js", + ); + const lifecycleLock = requireDist("../../src/lib/state/mcp-lifecycle-lock.js"); + const lifecycleLockAcquisition = requireDist( + "../../src/lib/state/mcp-lifecycle-lock-acquisition.js", + ); + + vi.spyOn(lifecycleLock, "withMcpLifecycleLock").mockImplementation((async ( + _sandboxName: string, + operation: () => Promise, + ) => operation()) as never); + vi.spyOn(lifecycleLockAcquisition, "withMcpLifecycleLock").mockImplementation((async ( + _sandboxName: string, + operation: () => Promise, + ) => operation()) as never); + vi.spyOn(gatewayState, "withConnectSandboxLifecycleLock").mockImplementation((async ( + _sandboxName: string, + operation: () => Promise, + ) => operation()) as never); + vi.spyOn(gatewayState, "buildHermesPortableCommandEnvironment").mockReturnValue({ + HOME: "/home/test", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + }); + vi.spyOn(gatewayState, "buildHermesPortableCommandAuthority").mockReturnValue({ + env: { + HOME: "/home/test", + XDG_CONFIG_HOME: "/home/test/.config", + XDG_RUNTIME_DIR: "/run/user/1000", + }, + executablePath: "/usr/bin/openshell", + }); + vi.spyOn(gatewayState, "assertHermesPortableLifecycleForConnect").mockImplementation( + () => undefined, + ); + const requestedPortableDisposition = options.portableReceiptDisposition ?? { kind: "absent" }; + const portableDisposition = + requestedPortableDisposition.kind === "hermes" + ? { + ...requestedPortableDisposition, + gatewayName: + requestedPortableDisposition.gatewayName ?? + options.registryEntry?.gatewayName ?? + "nemoclaw", + lifecycleGeneration: + requestedPortableDisposition.lifecycleGeneration ?? + options.registryEntry?.lifecycleGeneration ?? + "generation-1", + liveIdentityFingerprint: "f".repeat(64), + } + : requestedPortableDisposition; + const inspectPortableReceiptDispositionSpy = vi + .spyOn(portableAgentLifecycle, "inspectPortableAgentReceiptDisposition") + .mockReturnValue(portableDisposition); + let registryEntries: SandboxEntry[] = []; + const qualifyPortableAgentLifecycleAuthority = + portableAgentLifecycle.qualifyPortableAgentLifecycleAuthority; + const requireHermesPortableActiveLifecycleAuthority = + portableAgentLifecycle.requireHermesPortableActiveLifecycleAuthority; + const portableAuthorityDeps = () => ({ + inspectReceiptDisposition: (sandboxName: string) => + portableAgentLifecycle.inspectPortableAgentReceiptDisposition(sandboxName), + readRegistry: (sandboxName: string) => + registryEntries.find((candidate) => candidate.name === sandboxName) ?? null, + }); + vi.spyOn(gatewayState, "qualifyPortableAgentLifecycleAuthority").mockImplementation((( + sandboxName: string, + ) => qualifyPortableAgentLifecycleAuthority(sandboxName, portableAuthorityDeps())) as never); + vi.spyOn(gatewayState, "requireHermesPortableActiveLifecycleAuthority").mockImplementation((( + sandboxName: string, + expected: unknown, + ) => + requireHermesPortableActiveLifecycleAuthority( + sandboxName, + expected, + portableAuthorityDeps(), + )) as never); const sandboxExec = requireDist("../../src/lib/actions/sandbox/exec.js"); - const runSandboxExecChildSpy = vi - .spyOn(sandboxExec, "runSandboxExecChild") - .mockResolvedValue({ - status: spawnStatusFromOptions(options), - signal: options.spawnSignal ?? null, - }); + const runSandboxExecChildSpy = vi.spyOn(sandboxExec, "runSandboxExecChild").mockResolvedValue({ + status: spawnStatusFromOptions(options), + signal: options.spawnSignal ?? null, + }); const inspectLaunchReadinessSpy = vi .spyOn(launchReadiness, "inspectLaunchReadiness") @@ -220,35 +307,43 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne }); const inferenceProbeResponses = [...(options.inferenceProbeResponses ?? [])]; const listOutputs = [...(options.listOutputs ?? [])]; + const captureOpenshellImplementation = (args: unknown) => { + const argv = Array.isArray(args) ? args : []; + if (argv[0] === "sandbox" && argv[1] === "list") { + return { + status: 0, + output: + listOutputs.shift() ?? + options.listOutput ?? + `${options.registryEntry?.name ?? "alpha"} Ready`, + }; + } + if (argv[0] === "inference" && argv[1] === "get") { + return { + status: 0, + output: + options.inferenceGetOutput ?? + (options.agentName === "hermes" + ? "Gateway inference:\n Provider: ollama-local\n Model: qwen3-vl:4b\n" + : "Provider: unknown\nModel: unknown\n"), + }; + } + if ( + argv[0] === "sandbox" && + argv[1] === "exec" && + argv.join(" ").includes("inference.local/v1/models") + ) { + const response = inferenceProbeResponses.shift() ?? "OK 200"; + return typeof response === "string" ? { status: 0, output: response } : response; + } + return { status: 0, output: "" }; + }; const captureOpenshellSpy = vi .spyOn(runtime, "captureOpenshell") - .mockImplementation((args: unknown) => { - const argv = Array.isArray(args) ? args : []; - if (argv[0] === "sandbox" && argv[1] === "list") { - return { - status: 0, - output: - listOutputs.shift() ?? - options.listOutput ?? - `${options.registryEntry?.name ?? "alpha"} Ready`, - }; - } - if (argv[0] === "inference" && argv[1] === "get") { - return { - status: 0, - output: options.inferenceGetOutput ?? "Provider: unknown\nModel: unknown\n", - }; - } - if ( - argv[0] === "sandbox" && - argv[1] === "exec" && - argv.join(" ").includes("inference.local/v1/models") - ) { - const response = inferenceProbeResponses.shift() ?? "OK 200"; - return typeof response === "string" ? { status: 0, output: response } : response; - } - return { status: 0, output: "" }; - }); + .mockImplementation(captureOpenshellImplementation); + const captureResolvedOpenshellSpy = vi + .spyOn(runtime, "captureResolvedOpenshell") + .mockImplementation(captureOpenshellImplementation); const runOpenshellSpy = vi.spyOn(runtime, "runOpenshell").mockReturnValue({ status: 0 }); const withGatewayRouteMutationLockSpy = vi .spyOn(gatewayRouteMutationLock, "withGatewayRouteMutationLock") @@ -301,13 +396,25 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne const primaryRegistryEntry: SandboxEntry = { name: "alpha", agent: options.agentName ?? "openclaw", - provider: null, - model: null, + provider: options.agentName === "hermes" ? "ollama-local" : null, + model: options.agentName === "hermes" ? "qwen3-vl:4b" : null, + lifecycleLiveIdentityFingerprint: + portableDisposition.kind === "hermes" + ? portableDisposition.liveIdentityFingerprint + : undefined, gpuEnabled: false, policies: [], + ...(portableDisposition.kind === "hermes" + ? { + openshellDriver: "docker", + gatewayName: portableDisposition.gatewayName, + lifecycleGeneration: portableDisposition.lifecycleGeneration, + lifecycleLiveIdentityFingerprint: portableDisposition.liveIdentityFingerprint, + } + : {}), ...options.registryEntry, }; - const registryEntries: SandboxEntry[] = options.registryEntries + registryEntries = options.registryEntries ? options.registryEntries.map((candidate) => candidate.name === primaryRegistryEntry.name ? { ...primaryRegistryEntry, ...candidate } @@ -367,6 +474,7 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne return { applyVmDnsMonkeypatchSpy, captureOpenshellSpy, + captureResolvedOpenshellSpy, checkAndRecoverSpy, connectSandbox: requireDist(connectModulePath).connectSandbox, ensureOllamaAuthProxySpy, @@ -384,6 +492,7 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne probeOllamaAuthProxyHealthSpy, readSandboxConfigSpy, recoverPortableDemoLifecycleSpy, + inspectPortableReceiptDispositionSpy, registryEntries, resolveAgentConfigSpy, restoreSandboxStartupState: requireDist(connectModulePath).restoreSandboxStartupState, diff --git a/test/support/status-flow-test-harness.ts b/test/support/status-flow-test-harness.ts index b1af7a64fb7..ddea93db1d4 100644 --- a/test/support/status-flow-test-harness.ts +++ b/test/support/status-flow-test-harness.ts @@ -15,7 +15,15 @@ import type { ProviderHealthStatus } from "../../src/lib/inference/health"; import type { BaselineExclusionRuntimeStatus } from "../../src/lib/policy/baseline-exclusion"; import type { BaselineExclusionTransition, SandboxHostMount } from "../../src/lib/state/registry"; -type ShowSandboxStatus = typeof import("../../src/lib/actions/sandbox/status")["showSandboxStatus"]; +type ShowSandboxStatus = + (typeof import("../../src/lib/actions/sandbox/status"))["showSandboxStatus"]; +type GetSandboxStatusReport = + (typeof import("../../src/lib/actions/sandbox/status"))["getSandboxStatusReport"]; +type PortableAgentReceiptDisposition = ReturnType< + (typeof import("../../src/lib/onboard/experimental/portable-agent-lifecycle"))["inspectPortableAgentReceiptDisposition"] +>; +type WithMcpLifecycleLock = + (typeof import("../../src/lib/state/mcp-lifecycle-lock-acquisition"))["withMcpLifecycleLock"]; const requireDist = createRequire(import.meta.url); const statusModulePath = "../../src/lib/actions/sandbox/status.js"; @@ -30,10 +38,13 @@ export type StatusFlowHarness = { collectSandboxStatusSnapshotSpy: MockInstance; getActiveSandboxSessionsSpy: MockInstance; getSandboxDockerRuntimeSpy: MockInstance; + getSandboxStatusReport: GetSandboxStatusReport; + qualifyPortableAgentLifecycleAuthoritySpy: MockInstance; isSandboxGatewayRunningForStatusSpy: MockInstance; logSpy: MockInstance; removeSandboxSpy: MockInstance; showSandboxStatus: ShowSandboxStatus; + withMcpLifecycleLockSpy: MockInstance; }; const baseSandboxEntry = { @@ -53,6 +64,9 @@ const baseSandboxEntry = { }, openshellDriver: "docker", openshellVersion: "0.1.2", + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: "fingerprint-1", dashboardPort: 18789, agentVersion: "0.1.0", }; @@ -63,6 +77,12 @@ export type StatusFlowHarnessOptions = { routeDrift?: SandboxStatusRouteDrift | null; inferenceHealth?: ProviderHealthStatus | null; servingProcessHealth?: ServingProcessHealth | null; + portableDisposition?: + | PortableAgentReceiptDisposition + | Error + | (() => PortableAgentReceiptDisposition | Error); + registryEntry?: "present" | "missing"; + withMcpLifecycleLock?: WithMcpLifecycleLock; baselineExclusionStatus?: BaselineExclusionRuntimeStatus; lookup?: SandboxGatewayState; lookupState?: "present" | "missing"; @@ -113,6 +133,10 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): const statusProcessRecovery = requireDist( "../../src/lib/actions/sandbox/status/process-recovery.js", ); + const portableAgentLifecycle = requireDist( + "../../src/lib/onboard/experimental/portable-agent-lifecycle.js", + ); + const lifecycleLock = requireDist("../../src/lib/state/mcp-lifecycle-lock-acquisition.js"); const resolve = requireDist("../../src/lib/adapters/openshell/resolve.js"); const agentRuntime = requireDist("../../src/lib/agent/runtime.js"); const nim = requireDist("../../src/lib/inference/nim.js"); @@ -142,8 +166,32 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): const sandboxEntry = options.sandboxEntry === null ? null : { ...baseSandboxEntry, ...options.sandboxEntry }; + const qualifyPortableAgentLifecycleAuthority = + portableAgentLifecycle.qualifyPortableAgentLifecycleAuthority; + const qualifyPortableAgentLifecycleAuthoritySpy = vi + .spyOn(portableAgentLifecycle, "qualifyPortableAgentLifecycleAuthority") + .mockImplementation(((sandboxName: string) => { + const disposition = + typeof options.portableDisposition === "function" + ? options.portableDisposition() + : options.portableDisposition; + if (disposition instanceof Error) throw disposition; + return qualifyPortableAgentLifecycleAuthority(sandboxName, { + inspectReceiptDisposition: () => disposition ?? { kind: "absent" }, + readRegistry: () => (options.registryEntry === "missing" ? null : sandboxEntry), + }); + }) as never); + + const withMcpLifecycleLockSpy = vi + .spyOn(lifecycleLock, "withMcpLifecycleLock") + .mockImplementation( + (options.withMcpLifecycleLock ?? + (async (_sandboxName: string, operation: () => unknown) => await operation())) as never, + ); - vi.spyOn(registry, "getSandbox").mockReturnValue(sandboxEntry); + vi.spyOn(registry, "getSandbox").mockReturnValue( + options.registryEntry === "missing" ? null : sandboxEntry, + ); const removeSandboxSpy = vi.spyOn(registry, "removeSandbox").mockImplementation(() => undefined); vi.spyOn(statusPreflight, "getSandboxStatusPreflight").mockResolvedValue( options.preflight ?? { @@ -253,14 +301,19 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): logSpy.mockClear(); + const statusModule = requireDist(statusModulePath); + return { checkAgentVersionSpy, collectSandboxStatusSnapshotSpy, getActiveSandboxSessionsSpy, getSandboxDockerRuntimeSpy, + getSandboxStatusReport: statusModule.getSandboxStatusReport, + qualifyPortableAgentLifecycleAuthoritySpy, isSandboxGatewayRunningForStatusSpy, logSpy, removeSandboxSpy, - showSandboxStatus: requireDist(statusModulePath).showSandboxStatus, + showSandboxStatus: statusModule.showSandboxStatus, + withMcpLifecycleLockSpy, } satisfies StatusFlowHarness; }