diff --git a/.github/workflows/podman-cpu-proof.yaml b/.github/workflows/podman-cpu-proof.yaml index 8dae2099e67..03800f6b361 100644 --- a/.github/workflows/podman-cpu-proof.yaml +++ b/.github/workflows/podman-cpu-proof.yaml @@ -3,7 +3,7 @@ name: Runtime / Podman CPU Proof -run-name: "Podman CPU proof PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }}" +run-name: "Podman CPU proof PR #${{ github.event.pull_request.number }} commit ${{ github.event.pull_request.head.sha }}" on: pull_request: @@ -15,12 +15,16 @@ on: - "src/lib/onboard/docker-driver-gateway-*.ts" - "src/lib/onboard/managed-bootstrap/podman-*.ts" - "src/lib/onboard/experimental/portable-demo-lifecycle.ts" + - "src/lib/onboard/runtime-provider/container-state-mutation.ts" + - "src/lib/onboard/runtime-provider/docker-state-mutation.ts" - "src/lib/onboard/runtime-provider/podman*.ts" - "scripts/install-openshell.sh" - "test/e2e/live/podman-cpu-lifecycle-artifacts.ts" - "test/e2e/live/podman-cpu-lifecycle-helpers.ts" - "test/e2e/live/podman-cpu-lifecycle-policy.yaml" - "test/e2e/live/podman-cpu-lifecycle.test.ts" + - "test/e2e/registry/native-runtime-qualification.ts" + - "test/e2e/support/native-runtime-qualification.test.ts" - "src/lib/onboard/experimental/portable-demo-lifecycle.test.ts" - "test/e2e/support/podman-cpu-proof-workflow.test.ts" @@ -40,6 +44,7 @@ jobs: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/podman-cpu-proof E2E_DEFAULT_ENABLED: "0" E2E_JOB: "1" + E2E_SOURCE_REVISION: ${{ github.event.pull_request.head.sha }} E2E_TARGET_ID: podman-cpu-lifecycle NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.101" @@ -179,6 +184,28 @@ jobs: dockerSocketPresent: false, dockerCandidate: $dockerCandidate }' >"$E2E_ARTIFACT_DIR/docker-absence-boundary.json" + source_revision="$(git rev-parse HEAD)" + test "$source_revision" = "$E2E_SOURCE_REVISION" + jq -n \ + --arg sourceRevision "$source_revision" \ + '{ + schemaVersion: 1, + claim: "candidate-execution-prerequisites", + candidateId: "podman-cpu-lifecycle", + providerId: "podman", + sourceRevision: $sourceRevision, + executionPath: "runtime-provider-bundle", + architecture: "amd64", + acceleration: "cpu", + agents: ["openclaw", "hermes", "langchain-deepagents-code"], + socketFree: true, + dockerUnavailable: { + service: true, + socket: true, + daemon: true, + invocationGuard: true + } + }' >"$E2E_ARTIFACT_DIR/candidate-execution-prerequisites.json" - name: Start exact rootless Podman API socket shell: bash diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 4ea0ec9e725..8c14cc0b504 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -223,7 +223,7 @@ }, { "file": "test/e2e/support/podman-cpu-proof-workflow.test.ts", - "test": "runs as a credential-free exact-head PR workflow", + "test": "runs as a credential-free PR workflow bound to the commit under review", "category": "security" }, { diff --git a/scripts/runtime-state-mutation-control.py b/scripts/runtime-state-mutation-control.py index 55e91139bd7..4e011b99950 100755 --- a/scripts/runtime-state-mutation-control.py +++ b/scripts/runtime-state-mutation-control.py @@ -12,7 +12,7 @@ only the original process fence. PID 1 remains stopped until release completes. This blocks OpenShell SSH and -exec admission while a root Docker exec can continue the transaction. The +exec admission while a root provider exec can continue the transaction. The helper also stops ``nemoclaw-start`` and terminates every other process that uses the ``sandbox`` or ``gateway`` account. Activation resumes only the exact entrypoint, proves a fresh Hermes gateway, and freezes the resulting process @@ -51,7 +51,6 @@ SCHEMA_VERSION = 1 PLAN_SCHEMA_VERSION = 2 -SUPPORTED_PROVIDER_ID = "docker" SUPPORTED_STATE_ROOT = "/sandbox/.hermes" SUPPORTED_WRITER_ACCOUNTS = ("gateway", "sandbox") @@ -113,6 +112,7 @@ HEX_64 = re.compile(r"[0-9a-f]{64}\Z") SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +PROVIDER_ID = re.compile(r"[a-z][a-z0-9-]{0,62}\Z") RUNTIME_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/=+\-]{0,511}\Z") MOUNT_NAMESPACE = re.compile(r"mnt:\[[1-9][0-9]*\]\Z") PID_NAMESPACE = re.compile(r"pid:\[[1-9][0-9]*\]\Z") @@ -603,10 +603,10 @@ def _sha256(payload: bytes) -> str: ) PROVIDER_HANDLE = re.compile( - r"docker-state-mutation-v1:([0-9a-f]{64}):([0-9a-f]{64})\Z" + r"([a-z][a-z0-9-]{0,62})-state-mutation-v1:([0-9a-f]{64}):([0-9a-f]{64})\Z" ) ACTIVATION_PROVIDER_HANDLE = re.compile( - r"docker-state-mutation-activation-v1:([0-9a-f]{64}):([0-9a-f]{64})\Z" + r"([a-z][a-z0-9-]{0,62})-state-mutation-activation-v1:([0-9a-f]{64}):([0-9a-f]{64})\Z" ) @@ -689,9 +689,7 @@ def _parse_request(action: Action, raw: bytes) -> Request: ): _fail("envelope-version") transaction_id = _hex_digest(envelope["transactionId"], "transaction-id") - provider_id = _bounded_string(envelope["providerId"], SAFE_NAME, "provider-id") - if provider_id != SUPPORTED_PROVIDER_ID: - _fail("provider-unsupported") + provider_id = _bounded_string(envelope["providerId"], PROVIDER_ID, "provider-id") sandbox_name = _bounded_string(envelope["sandboxName"], SAFE_NAME, "sandbox-name") lifecycle_generation = _bounded_string( envelope["lifecycleGeneration"], RUNTIME_ID, "lifecycle-generation" @@ -728,6 +726,12 @@ def _parse_request(action: Action, raw: bytes) -> Request: if action != "recover" else None ) + if provider_handle is not None: + provider_match = PROVIDER_HANDLE.fullmatch(provider_handle) + if provider_match is None or not secrets.compare_digest( + provider_match.group(1), provider_id + ): + _fail("provider-handle") activation_provider_handle = ( _bounded_string( envelope["activationProviderHandle"], @@ -737,6 +741,14 @@ def _parse_request(action: Action, raw: bytes) -> Request: if action == "release" else None ) + if activation_provider_handle is not None: + activation_match = ACTIVATION_PROVIDER_HANDLE.fullmatch( + activation_provider_handle + ) + if activation_match is None or not secrets.compare_digest( + activation_match.group(1), provider_id + ): + _fail("activation-provider-handle") completed = ( _hex_digest(envelope["completedLedgerSha256"], "completed-ledger-digest") if action == "release" @@ -1139,9 +1151,7 @@ def _validate_marker(value: object) -> dict[str, object]: or marker["phase"] not in PHASES ): _fail("marker-schema") - provider_id = _bounded_string(marker["providerId"], SAFE_NAME, "marker-schema") - if provider_id != SUPPORTED_PROVIDER_ID: - _fail("marker-schema") + provider_id = _bounded_string(marker["providerId"], PROVIDER_ID, "marker-schema") transaction_id = _hex_digest(marker["transactionId"], "marker-schema") sandbox_name = _bounded_string(marker["sandboxName"], SAFE_NAME, "marker-schema") lifecycle_generation = _bounded_string( @@ -1258,7 +1268,7 @@ def _base_receipt( def _provider_handle(marker: dict[str, object]) -> str: return ( - f"docker-state-mutation-v1:{marker['transactionId']}:" + f"{marker['providerId']}-state-mutation-v1:{marker['transactionId']}:" f"{_sha256(_json_bytes(_base_receipt(marker, 'fenced')))}" ) @@ -1280,7 +1290,7 @@ def _activation_provider_handle(marker: dict[str, object]) -> str: "fenceProviderHandle": _provider_handle(marker), } return ( - f"docker-state-mutation-activation-v1:{marker['transactionId']}:" + f"{marker['providerId']}-state-mutation-activation-v1:{marker['transactionId']}:" f"{_sha256(_json_bytes(payload))}" ) diff --git a/scripts/runtime_state_mutation_hermes_publisher.py b/scripts/runtime_state_mutation_hermes_publisher.py index 1bf45514d40..bc0a3237dcf 100755 --- a/scripts/runtime_state_mutation_hermes_publisher.py +++ b/scripts/runtime_state_mutation_hermes_publisher.py @@ -12,7 +12,7 @@ The publisher keeps a root-only journal beside the controller marker. The journal binds the provider nonce and complete canonical plan before the Hermes -guard begins, so a lost Docker exec response can resume only the same +guard begins, so a lost provider exec response can resume only the same transaction. A successful retry independently verifies the top-level config/hash projection and every selector in the installed state-lock plan. """ @@ -283,7 +283,12 @@ def _normalize_marker(marker: object, posture: str) -> dict[str, object]: nonce = _hex(marker.get("nonce"), "publisher-marker-invalid") plan_sha256 = _hex(marker.get("planSha256"), "publisher-marker-invalid") projection_sha256 = _hex(marker.get("projectionSha256"), "publisher-marker-invalid") - if marker.get("providerId") != "docker" or marker.get("stateRoot") != HERMES_DIR: + provider_id = marker.get("providerId") + if ( + not isinstance(provider_id, str) + or re.fullmatch(r"[a-z][a-z0-9-]{0,62}", provider_id) is None + or marker.get("stateRoot") != HERMES_DIR + ): _fail("publisher-marker-invalid") target = marker.get("target") rollback = marker.get("rollback") @@ -385,6 +390,7 @@ def _normalize_marker(marker: object, posture: str) -> dict[str, object]: return { "binding": binding, "bindingSha256": hashlib.sha256(_canonical(binding)).hexdigest(), + "providerId": provider_id, "transactionId": transaction_id, "nonce": nonce, "planSha256": plan_sha256, diff --git a/src/lib/adapters/container-engine.ts b/src/lib/adapters/container-engine.ts index bec1a22ba2d..1e61fcdab06 100644 --- a/src/lib/adapters/container-engine.ts +++ b/src/lib/adapters/container-engine.ts @@ -10,6 +10,7 @@ export type ContainerEngineOperationScope = | "gateway-inspection" | "managed-bootstrap" | "sandbox-lifecycle" + | "state-mutation" | "workload-cleanup"; export interface ContainerEngineCommandResult { diff --git a/src/lib/adapters/podman/index.test.ts b/src/lib/adapters/podman/index.test.ts index acc7a8a9437..50673c3039e 100644 --- a/src/lib/adapters/podman/index.test.ts +++ b/src/lib/adapters/podman/index.test.ts @@ -144,6 +144,35 @@ describe("Podman container engine command adapter", () => { expect(capture).toHaveBeenCalledTimes(1); }); + it("pins socket and executable authority for state-mutation retries", () => { + const assertSocketAuthority = vi.fn(); + const capture = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); + const readFile = vi.fn(() => PODMAN_BYTES); + const engine = createPodmanContainerEngine({ + operation: "state-mutation", + socketAuthority: AUTHORITY, + executable: "/usr/bin/podman", + executableAuthorityDeps: executableAuthorityDeps(PODMAN_BYTES, { readFile }), + assertAuthority: assertSocketAuthority, + capture, + }); + + engine.assertAuthority(); + engine.capture(["container", "inspect", "a".repeat(64)]); + + expect(engine.operation).toBe("state-mutation"); + expect(readFile).toHaveBeenCalledTimes(2); + expect(assertSocketAuthority).toHaveBeenCalledTimes(3); + expect(capture).toHaveBeenCalledExactlyOnceWith( + "/usr/bin/podman", + ["--url", "unix:///run/user/1000/podman/podman.sock", "container", "inspect", "a".repeat(64)], + 15_000, + ); + expect(() => engine.captureHost(["info"])).toThrow( + "Podman state-mutation forbids ambient host command capture", + ); + }); + it("shares only socket authority across real operation-scoped engines", () => { const common = { socketAuthority: AUTHORITY, diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts index f323b5be2a2..cded0c9baea 100644 --- a/src/lib/adapters/podman/index.ts +++ b/src/lib/adapters/podman/index.ts @@ -26,7 +26,11 @@ import { const EXECUTABLE_CONTENT_REVALIDATION_COMMAND_INTERVAL = 64; export interface PodmanContainerEngineOptions { - readonly operation: "host-doctor" | "host-local-inference" | "sandbox-lifecycle"; + readonly operation: + | "host-doctor" + | "host-local-inference" + | "sandbox-lifecycle" + | "state-mutation"; readonly socketAuthority: PodmanSocketAuthority; readonly executable?: string; readonly capture?: ContainerEngineCommandCapture; @@ -42,6 +46,11 @@ export interface PodmanContainerEngine extends ContainerEngine { readonly endpointAuthorityId: string; } +/** Podman engine whose exact socket and executable authority can be revalidated on demand. */ +export interface PodmanBoundContainerEngine extends PodmanContainerEngine { + readonly assertAuthority: () => void; +} + export function localPodmanEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const local = { ...env }; delete local.CONTAINER_CONNECTION; @@ -97,17 +106,27 @@ function podmanAuthorityId( */ export function createPodmanContainerEngine( options: PodmanContainerEngineOptions, -): PodmanContainerEngine { +): PodmanBoundContainerEngine { const assertAuthority = options.assertAuthority ?? assertPodmanSocketAuthority; const executable = options.executable ?? "podman"; - const executableAuthority = - options.operation === "host-local-inference" - ? capturePodmanExecutableAuthority(executable, options.executableAuthorityDeps) - : undefined; + const protectsRuntimeMutation = + options.operation === "host-local-inference" || options.operation === "state-mutation"; + const executableAuthority = protectsRuntimeMutation + ? capturePodmanExecutableAuthority(executable, options.executableAuthorityDeps) + : undefined; let executableCommandCount = 0; let hasExecutableAuthorityFailure = false; let executableAuthorityFailure: unknown; const endpointAuthorityId = podmanAuthorityId(options.socketAuthority); + const assertBoundAuthority = (rehashExecutable: boolean): void => { + assertAuthority(options.socketAuthority, options.authorityDeps); + if (!executableAuthority) return; + if (rehashExecutable) { + assertPodmanExecutableAuthority(executableAuthority, options.executableAuthorityDeps); + } else { + assertPodmanExecutableMetadataAuthority(executableAuthority, options.executableAuthorityDeps); + } + }; const engine = createContainerEngineCommand({ operation: options.operation, engineId: "podman", @@ -131,8 +150,7 @@ export function createPodmanContainerEngine( try { const shouldRehash = phase === "before" && - executableCommandCount + 1 === - EXECUTABLE_CONTENT_REVALIDATION_COMMAND_INTERVAL; + executableCommandCount + 1 === EXECUTABLE_CONTENT_REVALIDATION_COMMAND_INTERVAL; if (shouldRehash) { assertPodmanExecutableAuthority(executableAuthority, options.executableAuthorityDeps); } else { @@ -157,12 +175,17 @@ export function createPodmanContainerEngine( } if (failure !== undefined) throw failure; }, - }) as PodmanContainerEngine; - if (options.operation !== "host-local-inference") return engine; - return Object.freeze({ + }); + const boundEngine = { ...engine, + endpointAuthorityId, + assertAuthority: () => assertBoundAuthority(true), + }; + if (!protectsRuntimeMutation) return Object.freeze(boundEngine); + return Object.freeze({ + ...boundEngine, captureHost: () => { - throw new Error("Podman host-local inference forbids ambient host command capture."); + throw new Error(`Podman ${options.operation} forbids ambient host command capture.`); }, }); } diff --git a/src/lib/onboard/runtime-provider/container-state-mutation.ts b/src/lib/onboard/runtime-provider/container-state-mutation.ts new file mode 100644 index 00000000000..9a8bbfc4f9f --- /dev/null +++ b/src/lib/onboard/runtime-provider/container-state-mutation.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Keep the generalized implementation in the established Docker module so the +// shipped Docker surface and its import path remain stable. Candidate providers +// consume only this provider-neutral facade. +export { + createContainerStateMutationOwner, + createContainerStateMutationSurface, + type ContainerStateMutationAuthority, + type ContainerStateMutationOwner, + type ContainerStateMutationOwnerOptions, + type ContainerStateMutationSurfaceOptions, +} from "./docker-state-mutation"; diff --git a/src/lib/onboard/runtime-provider/contract.ts b/src/lib/onboard/runtime-provider/contract.ts index 64c1e097166..2b59b2fb939 100644 --- a/src/lib/onboard/runtime-provider/contract.ts +++ b/src/lib/onboard/runtime-provider/contract.ts @@ -39,6 +39,7 @@ export type RuntimeProviderContainerEngineOperation = | "gateway-inspection" | "host-local-inference" | "sandbox-lifecycle" + | "state-mutation" | "workload-cleanup"; export interface RuntimeProviderIdentity { diff --git a/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts b/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts index 37d70fcc924..487a6e7cfd4 100644 --- a/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts +++ b/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts @@ -36,7 +36,7 @@ function ownerThatStopsAfterPrepare(runtime: ReturnType) { lifecycleGeneration: runtime.lifecycleGeneration, lifecycleLiveIdentityFingerprint: SANDBOX_FINGERPRINT, runtimeId: RUNTIME_ID, - authority: runtime.authority, + authority: runtime.authority as ReturnType, engineAuthorityStore: runtime.engineAuthorityStore, lifecycleStore: { ...runtime.lifecycleStore, diff --git a/src/lib/onboard/runtime-provider/docker-state-mutation.ts b/src/lib/onboard/runtime-provider/docker-state-mutation.ts index 6042fcbc9e7..a288e337b95 100644 --- a/src/lib/onboard/runtime-provider/docker-state-mutation.ts +++ b/src/lib/onboard/runtime-provider/docker-state-mutation.ts @@ -4,7 +4,11 @@ import { createHash, randomBytes } from "node:crypto"; import path from "node:path"; -import type { ContainerEngineCommandCapture } from "../../adapters/container-engine"; +import type { + ContainerEngine, + ContainerEngineCommandCapture, + ContainerEngineOperationScope, +} from "../../adapters/container-engine"; import { resolveShieldsStateDir, withShieldsTransitionLock } from "../../shields/transition-lock"; import type { RuntimeProviderPreparedStateMutationPlan, @@ -18,7 +22,6 @@ import { RUNTIME_PROVIDER_STATE_MUTATION_CONTRACT_VERSION } from "./contract"; import { createDockerOperationAuthority, type DockerOperationAuthority, - dockerOperationBindingSha256, } from "./docker-operation-authority"; import { createFilePersistedEngineAuthorityStore, @@ -43,7 +46,7 @@ import { } from "./persisted-engine-lifecycle"; import { prepareRuntimeProviderStateMutationPlan } from "./state-mutation"; -const PROVIDER_ID = "docker"; +const DOCKER_PROVIDER_ID = "docker"; const SUPPORTED_STATE_ROOT = "/sandbox/.hermes"; const HELPER_PYTHON_PATH = "/opt/hermes/.venv/bin/python3"; const HELPER_PATH = "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py"; @@ -59,14 +62,12 @@ const INSPECT_FORMAT = '[{{json .Id}},{{json .State.Running}},{{json .State.Status}},{{json .State.Paused}},{{json .State.Restarting}},{{json .State.Dead}},{{json .State.Pid}},{{json (index .Config.Labels "openshell.ai/managed-by")}},{{json (index .Config.Labels "openshell.ai/sandbox-name")}},{{json (index .Config.Labels "openshell.ai/sandbox-id")}},{{json .HostConfig.PidMode}},{{json .HostConfig.Privileged}},{{json .Mounts}}]'; const SHA256 = /^[a-f0-9]{64}$/u; const CONTAINER_ID = /^[a-f0-9]{64}$/u; +const PROVIDER_ID = /^[a-z][a-z0-9-]{0,62}$/u; const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; const LIFECYCLE_GENERATION = /^[A-Za-z0-9][A-Za-z0-9._:/=+-]{0,511}$/u; const MOUNT_NAMESPACE = /^mnt:\[[1-9][0-9]*\]$/u; const POSITIVE_DECIMAL = /^[1-9][0-9]*$/u; const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; -const PROVIDER_HANDLE = /^docker-state-mutation-v1:([a-f0-9]{64}):([a-f0-9]{64})$/u; -const ACTIVATION_PROVIDER_HANDLE = - /^docker-state-mutation-activation-v1:([a-f0-9]{64}):([a-f0-9]{64})$/u; type HelperAction = | "acquire" @@ -105,6 +106,7 @@ interface DockerMountIdentity { } interface DockerRuntimeObservation { + readonly providerDisplayName: string; readonly runtimeId: string; readonly runtimePid: number; readonly pidMode: ""; @@ -123,7 +125,7 @@ interface DockerStateMutationHelperReceipt { readonly schemaVersion: 1; readonly phase: HelperPhase; readonly transactionId: string; - readonly providerId: typeof PROVIDER_ID; + readonly providerId: string; readonly sandboxName: string; readonly lifecycleGeneration: string; readonly engineBindingSha256: string; @@ -147,18 +149,33 @@ interface DockerStateMutationHelperReceipt { readonly activationProviderHandle?: string; } -export interface DockerStateMutationOwnerOptions { +export interface ContainerStateMutationAuthority { + readonly assertAuthority: () => void; + readonly engine: ContainerEngine; +} + +export interface ContainerStateMutationOwnerOptions { + readonly providerId: string; + readonly providerDisplayName: string; + readonly engineOperation: ContainerEngineOperationScope; readonly sandboxName: string; readonly lifecycleGeneration: string; /** SHA-256 of the raw OpenShell sandbox ID recorded with this generation. */ readonly lifecycleLiveIdentityFingerprint?: string; - /** Full immutable Docker container ID. Names and short IDs are not accepted. */ + /** Full immutable container ID. Names and short IDs are not accepted. */ readonly runtimeId: string; - readonly authority: DockerOperationAuthority; + readonly authority: ContainerStateMutationAuthority; readonly engineAuthorityStore: PersistedEngineAuthorityStore; readonly lifecycleStore: PersistedEngineLifecycleStore; } +export type DockerStateMutationOwnerOptions = Omit< + ContainerStateMutationOwnerOptions, + "providerId" | "providerDisplayName" | "engineOperation" | "authority" +> & { + readonly authority: DockerOperationAuthority; +}; + export interface DockerStateMutationSurfaceOptions { /** Test seam for the fixed Docker executable; production uses the real capture adapter. */ readonly capture?: ContainerEngineCommandCapture; @@ -171,7 +188,22 @@ export interface DockerStateMutationSurfaceOptions { ) => T; } -function resolveDockerStateMutationStateDir(environment: NodeJS.ProcessEnv): string { +export interface ContainerStateMutationSurfaceOptions { + readonly providerId: string; + readonly providerDisplayName: string; + readonly engineOperation: ContainerEngineOperationScope; + readonly createAuthority: ( + input: RuntimeProviderStateMutationContext, + ) => ContainerStateMutationAuthority; + readonly resolveStateDir?: (environment: NodeJS.ProcessEnv) => string; + readonly withDirectSandboxExecutionExclusion?: ( + sandboxName: string, + operation: string, + fn: () => T, + ) => T; +} + +function resolveContainerStateMutationStateDir(environment: NodeJS.ProcessEnv): string { if ( environment.VITEST === "true" && (environment.HOME ?? "") === environment.NEMOCLAW_TEST_BASE_HOME && @@ -183,7 +215,7 @@ function resolveDockerStateMutationStateDir(environment: NodeJS.ProcessEnv): str return resolveShieldsStateDir(environment.HOME?.trim() || undefined); } -export interface DockerStateMutationOwner { +export interface ContainerStateMutationOwner { acquire( input: RuntimeProviderStateMutationContext & { readonly plan: RuntimeProviderPreparedStateMutationPlan; @@ -215,8 +247,33 @@ export interface DockerStateMutationOwner { ): void; } +export type DockerStateMutationOwner = ContainerStateMutationOwner; + function fail(message: string): never { - throw new Error(`Docker state mutation failed: ${message}`); + throw new Error(`Runtime provider state mutation failed: ${message}`); +} + +function providerHandlePattern(providerId: string): RegExp { + return new RegExp(`^${providerId}-state-mutation-v1:([a-f0-9]{64}):([a-f0-9]{64})$`, "u"); +} + +function activationProviderHandlePattern(providerId: string): RegExp { + return new RegExp( + `^${providerId}-state-mutation-activation-v1:([a-f0-9]{64}):([a-f0-9]{64})$`, + "u", + ); +} + +function operationBindingSha256(engine: ContainerEngine): string { + return createHash("sha256") + .update( + JSON.stringify({ + operation: engine.operation, + engineId: engine.engineId, + authorityId: engine.authorityId, + }), + ) + .digest("hex"); } function record(value: unknown, label: string): Record { @@ -288,7 +345,9 @@ function canonicalAbsolutePath(value: unknown, label: string): string { function canonicalStateRoot(value: unknown): string { const stateRoot = canonicalAbsolutePath(value, "state root"); if (!stateRoot.startsWith("/sandbox/")) fail("state root is outside /sandbox"); - if (stateRoot !== SUPPORTED_STATE_ROOT) fail("state root is unsupported by the Docker helper"); + if (stateRoot !== SUPPORTED_STATE_ROOT) { + fail("state root is unsupported by the runtime-provider helper"); + } return stateRoot; } @@ -298,23 +357,23 @@ function exactOptionalText(value: unknown, label: string): string | null { } function parseMount(value: unknown, index: number): DockerMountIdentity { - const source = record(value, `Docker mount ${String(index)}`); - const type = exactText(source.Type, `Docker mount ${String(index)} type`, 128); - const mountSource = exactText(source.Source, `Docker mount ${String(index)} source`); + const source = record(value, `container mount ${String(index)}`); + const type = exactText(source.Type, `container mount ${String(index)} type`, 128); + const mountSource = exactText(source.Source, `container mount ${String(index)} source`); const destination = canonicalAbsolutePath( source.Destination, - `Docker mount ${String(index)} destination`, + `container mount ${String(index)} destination`, ); - const name = exactOptionalText(source.Name, `Docker mount ${String(index)} name`); - const driver = exactOptionalText(source.Driver, `Docker mount ${String(index)} driver`); - const mode = exactText(source.Mode, `Docker mount ${String(index)} mode`, 1024); + const name = exactOptionalText(source.Name, `container mount ${String(index)} name`); + const driver = exactOptionalText(source.Driver, `container mount ${String(index)} driver`); + const mode = exactText(source.Mode, `container mount ${String(index)} mode`, 1024); const propagation = exactText( source.Propagation, - `Docker mount ${String(index)} propagation`, + `container mount ${String(index)} propagation`, 1024, ); if (type.length === 0 || mountSource.length === 0 || typeof source.RW !== "boolean") { - fail(`Docker mount ${String(index)} is malformed`); + fail(`container mount ${String(index)} is malformed`); } return Object.freeze({ type, @@ -359,13 +418,17 @@ function bindStateRoot( const owners = observation.mounts.filter((mount) => isPathAtOrBelow(stateRoot, mount.destination), ); - if (owners.length === 0) fail("state root has no durable Docker mount"); + if (owners.length === 0) { + fail(`state root has no durable ${observation.providerDisplayName} mount`); + } const deepestLength = Math.max(...owners.map((mount) => mount.destination.length)); const deepest = owners.filter((mount) => mount.destination.length === deepestLength); if (deepest.length !== 1 || !["bind", "volume"].includes(deepest[0]?.type ?? "")) { - fail("state root Docker mount is ambiguous or not durable"); + fail(`state root ${observation.providerDisplayName} mount is ambiguous or not durable`); + } + if (!deepest[0]?.readWrite) { + fail(`state root ${observation.providerDisplayName} mount is not writable`); } - if (!deepest[0]?.readWrite) fail("state root Docker mount is not writable"); const related = observation.mounts.filter( (mount) => isPathAtOrBelow(stateRoot, mount.destination) || @@ -378,22 +441,23 @@ function parseInspection( output: string, expectedRuntimeId: string, expectedSandboxName: string, + providerDisplayName: string, ): DockerRuntimeObservation { if ( output.length === 0 || Buffer.byteLength(output, "utf8") > MAX_INSPECTION_BYTES || output.includes("\0") ) { - fail("Docker container inspection output is empty or too large"); + fail(`${providerDisplayName} container inspection output is empty or too large`); } let parsed: unknown; try { parsed = JSON.parse(output); } catch { - fail("Docker container inspection returned unreadable JSON"); + fail(`${providerDisplayName} container inspection returned unreadable JSON`); } if (!Array.isArray(parsed) || parsed.length !== 13) { - fail("Docker container inspection schema is unsupported"); + fail(`${providerDisplayName} container inspection schema is unsupported`); } const [ runtimeIdInput, @@ -410,8 +474,12 @@ function parseInspection( privileged, mountsInput, ] = parsed; - const runtimeId = boundedString(runtimeIdInput, CONTAINER_ID, "Docker container identity"); - if (runtimeId !== expectedRuntimeId) fail("Docker container identity changed"); + const runtimeId = boundedString( + runtimeIdInput, + CONTAINER_ID, + `${providerDisplayName} container identity`, + ); + if (runtimeId !== expectedRuntimeId) fail(`${providerDisplayName} container identity changed`); if ( running !== true || status !== "running" || @@ -421,18 +489,18 @@ function parseInspection( !Number.isSafeInteger(runtimePid) || (runtimePid as number) <= 0 ) { - fail("Docker container is not one stable running runtime"); + fail(`${providerDisplayName} container is not one stable running runtime`); } if (managedBy !== "openshell" || sandboxName !== expectedSandboxName) { - fail("Docker container does not belong to the exact OpenShell sandbox"); + fail(`${providerDisplayName} container does not belong to the exact OpenShell sandbox`); } if (pidMode !== "" || privileged !== false) { - fail("Docker container does not have one private unprivileged PID namespace"); + fail(`${providerDisplayName} container does not have one private unprivileged PID namespace`); } const sandboxId = exactText(sandboxIdInput, "OpenShell sandbox identity", 512); if (sandboxId.length === 0) fail("OpenShell sandbox identity is missing"); if (!Array.isArray(mountsInput) || mountsInput.length > MAX_MOUNTS) { - fail("Docker container mounts are malformed"); + fail(`${providerDisplayName} container mounts are malformed`); } const mounts = mountsInput .map(parseMount) @@ -444,7 +512,7 @@ function parseInspection( : left.source.localeCompare(right.source), ); if (new Set(mounts.map((mount) => mount.destination)).size !== mounts.length) { - fail("Docker container has ambiguous mount destinations"); + fail(`${providerDisplayName} container has ambiguous mount destinations`); } if ( mounts.some( @@ -454,10 +522,11 @@ function parseInspection( mount.destination.startsWith("/proc/"), ) ) { - fail("Docker container overlays the trusted private procfs"); + fail(`${providerDisplayName} container overlays the trusted private procfs`); } const sandboxIdentitySha256 = sha256(sandboxId); return Object.freeze({ + providerDisplayName, runtimeId, runtimePid: runtimePid as number, pidMode: "", @@ -476,7 +545,10 @@ function exactPosture( return value; } -function parseHelperReceipt(output: string): DockerStateMutationHelperReceipt { +function parseHelperReceipt( + output: string, + expectedProviderId: string, +): DockerStateMutationHelperReceipt { if ( output.length === 0 || !output.endsWith("\n") || @@ -553,7 +625,7 @@ function parseHelperReceipt(output: string): DockerStateMutationHelperReceipt { healthSha256: boundedString(receipt.healthSha256, SHA256, "helper health digest"), activationProviderHandle: boundedString( receipt.activationProviderHandle, - ACTIVATION_PROVIDER_HANDLE, + activationProviderHandlePattern(expectedProviderId), "helper activation provider handle", ), } @@ -562,11 +634,7 @@ function parseHelperReceipt(output: string): DockerStateMutationHelperReceipt { schemaVersion: 1, phase: receipt.phase, transactionId: boundedString(receipt.transactionId, SHA256, "helper transaction identity"), - providerId: boundedString( - receipt.providerId, - /^docker$/u, - "helper provider identity", - ) as typeof PROVIDER_ID, + providerId: boundedString(receipt.providerId, SAFE_NAME, "helper provider identity"), sandboxName: boundedString(receipt.sandboxName, SAFE_NAME, "helper sandbox name"), lifecycleGeneration: boundedString( receipt.lifecycleGeneration, @@ -618,6 +686,9 @@ function parseHelperReceipt(output: string): DockerStateMutationHelperReceipt { rollback: exactPosture(receipt.rollback, "helper rollback posture"), ...activation, }); + if (normalized.providerId !== expectedProviderId) { + fail("root helper receipt changed the provider identity"); + } if (output !== `${fullReceiptTransport(normalized)}\n`) { fail("root helper receipt is not canonical"); } @@ -669,7 +740,7 @@ function receiptWithPhase( } function providerHandle(receipt: DockerStateMutationHelperReceipt): string { - return `docker-state-mutation-v1:${receipt.transactionId}:${sha256(receiptTransport(receipt))}`; + return `${receipt.providerId}-state-mutation-v1:${receipt.transactionId}:${sha256(receiptTransport(receipt))}`; } function activationProviderHandleFor( @@ -701,7 +772,7 @@ function activationProviderHandleFor( ["fenceProviderHandle", fenceProviderHandle], ]), ); - return `docker-state-mutation-activation-v1:${transactionId}:${digest}`; + return `${evidence.providerId}-state-mutation-activation-v1:${transactionId}:${digest}`; } function expectedActivationProviderHandle( @@ -778,7 +849,7 @@ function normalizeActivationProof( ); const handle = typeof proof.providerHandle === "string" - ? proof.providerHandle.match(ACTIVATION_PROVIDER_HANDLE) + ? proof.providerHandle.match(activationProviderHandlePattern(fence.providerId)) : null; if ( proof.schemaVersion !== 1 || @@ -794,7 +865,7 @@ function normalizeActivationProof( } const normalized = Object.freeze({ schemaVersion: 1, - providerId: PROVIDER_ID, + providerId: fence.providerId, sandboxName: fence.sandboxName, lifecycleGeneration: fence.lifecycleGeneration, runtimeId: fence.runtimeId, @@ -812,7 +883,7 @@ function normalizeActivationProof( healthSha256: boundedString(proof.healthSha256, SHA256, "activation health digest"), providerHandle: boundedString( proof.providerHandle, - ACTIVATION_PROVIDER_HANDLE, + activationProviderHandlePattern(fence.providerId), "activation provider handle", ), }); @@ -826,7 +897,7 @@ function normalizeActivationProof( } function runtimeStateSha256( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, observation: DockerRuntimeObservation, stateRoot: DockerStateRootBinding, @@ -834,7 +905,7 @@ function runtimeStateSha256( return sha256( canonicalRecord([ ["schemaVersion", 1], - ["providerId", PROVIDER_ID], + ["providerId", options.providerId], ["sandboxName", options.sandboxName], ["lifecycleGeneration", options.lifecycleGeneration], ["engineBindingSha256", bindingSha256], @@ -873,13 +944,13 @@ function transactionId( } function requireContext( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, input: RuntimeProviderStateMutationContext, ): void { if ( input.sandboxName !== options.sandboxName || input.sandbox.name !== options.sandboxName || - input.sandbox.openshellDriver !== PROVIDER_ID + input.sandbox.openshellDriver !== options.providerId ) { fail("sandbox provider identity changed"); } @@ -892,25 +963,32 @@ function requireContext( } function requireRegistryLiveIdentity( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, observation: DockerRuntimeObservation, ): void { const expected = options.lifecycleLiveIdentityFingerprint; if (expected === undefined) return; boundedString(expected, SHA256, "sandbox live identity fingerprint"); if (observation.sandboxIdentitySha256 !== expected) { - fail("Docker sandbox identity does not match the registered live identity"); + fail( + `${options.providerDisplayName} sandbox identity does not match the registered live identity`, + ); } } function requireCurrentEngineAuthority( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, ): void { options.authority.assertAuthority(); - const persisted = options.engineAuthorityStore.load("sandbox-lifecycle"); - if (!persisted) fail("persisted sandbox-lifecycle engine authority is missing"); - requirePersistedEngineAuthority(persisted, PROVIDER_ID, options.authority.engine, bindingSha256); + const persisted = options.engineAuthorityStore.load(options.engineOperation); + if (!persisted) fail(`persisted ${options.engineOperation} engine authority is missing`); + requirePersistedEngineAuthority( + persisted, + options.providerId, + options.authority.engine, + bindingSha256, + ); } function inspectCommand(runtimeId: string) { @@ -954,7 +1032,7 @@ function requireCommandSuccess( } function inspectDirect( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, ): DockerRuntimeObservation { requireCurrentEngineAuthority(options, bindingSha256); @@ -964,9 +1042,10 @@ function inspectDirect( ); requireCurrentEngineAuthority(options, bindingSha256); const observation = parseInspection( - requireCommandSuccess(result, "Docker container inspection"), + requireCommandSuccess(result, `${options.providerDisplayName} container inspection`), options.runtimeId, options.sandboxName, + options.providerDisplayName, ); requireRegistryLiveIdentity(options, observation); return observation; @@ -974,13 +1053,14 @@ function inspectDirect( function inspectAuthorized( scope: AuthorizedPersistedEngineLifecycle, - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, ): DockerRuntimeObservation { const result = scope.captureExact("target", inspectCommand, INSPECT_TIMEOUT_MS); const observation = parseInspection( - requireCommandSuccess(result, "Docker container inspection"), + requireCommandSuccess(result, `${options.providerDisplayName} container inspection`), options.runtimeId, options.sandboxName, + options.providerDisplayName, ); requireRegistryLiveIdentity(options, observation); return observation; @@ -998,7 +1078,9 @@ function sameObservation( expected.sandboxIdentitySha256 !== actual.sandboxIdentitySha256 || expected.containerMountsSha256 !== actual.containerMountsSha256 ) { - fail("Docker runtime changed while the state mutation fence was established"); + fail( + `${expected.providerDisplayName} runtime changed while the state mutation fence was established`, + ); } } @@ -1012,6 +1094,7 @@ function helperInput(fields: readonly (readonly [string, unknown])[]): Buffer { function invokeHelperAuthorized( scope: AuthorizedPersistedEngineLifecycle, + providerId: string, action: HelperAction, input: Buffer, ): DockerStateMutationHelperReceipt { @@ -1021,11 +1104,11 @@ function invokeHelperAuthorized( helperTimeoutMs(action), input, ); - return parseHelperReceipt(requireCommandSuccess(result, `root helper ${action}`)); + return parseHelperReceipt(requireCommandSuccess(result, `root helper ${action}`), providerId); } function lifecycleInput( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, record: Pick< PersistedEngineLifecycleRecord, @@ -1038,7 +1121,7 @@ function lifecycleInput( sandboxName: record.sandboxName, resources: record.resources, runtimeStateSha256: record.runtimeStateSha256, - providerId: PROVIDER_ID, + providerId: options.providerId, bindingSha256, engine: options.authority.engine, engineAuthorityStore: options.engineAuthorityStore, @@ -1063,7 +1146,7 @@ function normalizePreparedPlan( fail("prepared state mutation plan changed after validation"); } if (normalized.plan.intent !== "protection-transition") { - fail("Docker adapter does not implement restore publication"); + fail("container state-mutation adapter does not implement restore publication"); } if (normalized.plan.target === normalized.plan.rollback) { fail("protection transition target must differ from its rollback posture"); @@ -1094,7 +1177,7 @@ function preparedPlanFromPersistedIntent( } function acquireRequest( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, observation: DockerRuntimeObservation, stateRoot: DockerStateRootBinding, @@ -1106,7 +1189,7 @@ function acquireRequest( ["schemaVersion", 1], ["action", "acquire"], ["transactionId", exactTransactionId], - ["providerId", PROVIDER_ID], + ["providerId", options.providerId], ["sandboxName", options.sandboxName], ["lifecycleGeneration", options.lifecycleGeneration], ["engineBindingSha256", bindingSha256], @@ -1127,7 +1210,7 @@ function acquireRequest( function statusRequest( action: Exclude, - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, observation: DockerRuntimeObservation, exactTransactionId: string, @@ -1139,7 +1222,7 @@ function statusRequest( ["schemaVersion", 1], ["action", action], ["transactionId", exactTransactionId], - ["providerId", PROVIDER_ID], + ["providerId", options.providerId], ["sandboxName", options.sandboxName], ["lifecycleGeneration", options.lifecycleGeneration], ["engineBindingSha256", bindingSha256], @@ -1161,14 +1244,14 @@ function statusRequest( function validateReceipt( receipt: DockerStateMutationHelperReceipt, - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, observation: DockerRuntimeObservation, record: PersistedEngineLifecycleRecord, ): DockerStateRootBinding { const stateRoot = bindStateRoot(observation, receipt.stateRoot); if ( - receipt.providerId !== PROVIDER_ID || + receipt.providerId !== options.providerId || receipt.sandboxName !== options.sandboxName || receipt.lifecycleGeneration !== options.lifecycleGeneration || receipt.engineBindingSha256 !== bindingSha256 || @@ -1179,7 +1262,9 @@ function validateReceipt( receipt.stateRootMountsSha256 !== stateRoot.stateRootMountsSha256 || receipt.target === receipt.rollback ) { - fail("root helper receipt does not match the exact Docker runtime binding"); + fail( + `root helper receipt does not match the exact ${options.providerDisplayName} runtime binding`, + ); } const expectedRuntimeState = runtimeStateSha256(options, bindingSha256, observation, stateRoot); const expectedTransactionId = transactionId( @@ -1236,7 +1321,7 @@ function fenceFromReceipt( function normalizeFence( fence: RuntimeProviderStateMutationFence, - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, ): RuntimeProviderStateMutationFence { const source = record(fence, "state mutation fence"); @@ -1267,7 +1352,9 @@ function normalizeFence( "state mutation fence", ); const handle = - typeof fence.providerHandle === "string" ? fence.providerHandle.match(PROVIDER_HANDLE) : null; + typeof fence.providerHandle === "string" + ? fence.providerHandle.match(providerHandlePattern(options.providerId)) + : null; if ( fence.schemaVersion !== 1 || fence.intent !== "protection-transition" || @@ -1275,7 +1362,7 @@ function normalizeFence( fence.phase !== "published" && fence.phase !== "rolled-back" && fence.phase !== "activation-proven") || - fence.providerId !== PROVIDER_ID || + fence.providerId !== options.providerId || fence.sandboxName !== options.sandboxName || fence.lifecycleGeneration !== options.lifecycleGeneration || fence.runtimeId !== options.runtimeId || @@ -1284,7 +1371,7 @@ function normalizeFence( fence.target === fence.rollback || !handle ) { - fail("state mutation fence does not match the bound Docker runtime"); + fail(`state mutation fence does not match the bound ${options.providerDisplayName} runtime`); } return Object.freeze({ ...fence, @@ -1345,7 +1432,7 @@ function requireFenceReceipt( } function unfinishedRecord( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, ): PersistedEngineLifecycleRecord | null { const matches = options.lifecycleStore .listUnfinished() @@ -1357,7 +1444,9 @@ function unfinishedRecord( if (!match) return null; const targetRuntime = match.resources.find((resource) => resource.role === "target")?.runtimeId; if (targetRuntime !== options.runtimeId) { - fail("durable state mutation target does not match the exact labeled Docker runtime"); + fail( + `durable state mutation target does not match the exact labeled ${options.providerDisplayName} runtime`, + ); } return match; } @@ -1384,7 +1473,7 @@ function validateAcquireReceipt( function acquireAuthorizedReceipt( scope: AuthorizedPersistedEngineLifecycle, - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, plan: ReturnType, nonce: string, @@ -1402,7 +1491,9 @@ function acquireAuthorizedReceipt( stateRoot, ); if (observedRuntimeStateSha256 !== expectedRuntimeStateSha256) { - fail("Docker runtime changed before the state mutation fence was established"); + fail( + `${options.providerDisplayName} runtime changed before the state mutation fence was established`, + ); } if ( transactionId( @@ -1418,6 +1509,7 @@ function acquireAuthorizedReceipt( } const receipt = invokeHelperAuthorized( scope, + options.providerId, "acquire", acquireRequest(options, bindingSha256, observation, stateRoot, plan, nonce, exactTransactionId), ); @@ -1429,7 +1521,7 @@ function acquireAuthorizedReceipt( } function queryEstablishedReceipt( - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, execution: PersistedEngineLifecycleExecutionInput, action: "assert" | "publish" | "recover" | "rollback" | "activate", @@ -1453,7 +1545,9 @@ function queryEstablishedReceipt( currentRecord.runtimeStateSha256 !== runtimeStateSha256(options, bindingSha256, before, stateRoot) ) { - fail("Docker runtime changed after the state mutation fence was established"); + fail( + `${options.providerDisplayName} runtime changed after the state mutation fence was established`, + ); } } guard(); @@ -1470,7 +1564,10 @@ function queryEstablishedReceipt( ), ); guard(); - const receipt = parseHelperReceipt(requireCommandSuccess(result, `root helper ${action}`)); + const receipt = parseHelperReceipt( + requireCommandSuccess(result, `root helper ${action}`), + options.providerId, + ); validateReceipt(receipt, options, bindingSha256, before, currentRecord); if (expectedFence) requireFenceReceipt(expectedFence, receipt); const after = inspectDirect(options, bindingSha256); @@ -1497,7 +1594,7 @@ function executionRecord( function requireRecordMatchesFence( record: PersistedEngineLifecycleRecord, fence: RuntimeProviderStateMutationFence, - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, ): void { const targetRuntime = record.resources.find((resource) => resource.role === "target")?.runtimeId; if ( @@ -1534,7 +1631,7 @@ function sameActivationProof( function releaseAuthorizedFence( scope: AuthorizedPersistedEngineLifecycle, - options: DockerStateMutationOwnerOptions, + options: ContainerStateMutationOwnerOptions, bindingSha256: string, fence: RuntimeProviderStateMutationFence, proof: RuntimeProviderStateMutationActivationProof, @@ -1543,6 +1640,7 @@ function releaseAuthorizedFence( const before = inspectAuthorized(scope, options); const receipt = invokeHelperAuthorized( scope, + options.providerId, "release", statusRequest( "release", @@ -1563,13 +1661,15 @@ function releaseAuthorizedFence( } /** - * Own Docker's durable state-mutation fence without accepting a shell command, - * helper path, runtime alias, or caller-authored provider receipt. + * Own one container provider's durable state-mutation fence without accepting + * a shell command, helper path, runtime alias, or caller-authored receipt. */ -export function createDockerStateMutationOwner( - optionsInput: DockerStateMutationOwnerOptions, -): DockerStateMutationOwner { +export function createContainerStateMutationOwner( + optionsInput: ContainerStateMutationOwnerOptions, +): ContainerStateMutationOwner { const options = Object.freeze({ ...optionsInput }); + boundedString(options.providerId, PROVIDER_ID, "provider identity"); + boundedString(options.providerDisplayName, SAFE_NAME, "provider display name"); boundedString(options.sandboxName, SAFE_NAME, "sandbox name"); boundedString(options.lifecycleGeneration, LIFECYCLE_GENERATION, "lifecycle generation"); if (options.lifecycleLiveIdentityFingerprint !== undefined) { @@ -1579,16 +1679,18 @@ export function createDockerStateMutationOwner( "sandbox live identity fingerprint", ); } - boundedString(options.runtimeId, CONTAINER_ID, "Docker runtime identity"); + boundedString(options.runtimeId, CONTAINER_ID, `${options.providerDisplayName} runtime identity`); if ( - options.authority.engine.operation !== "sandbox-lifecycle" || - options.authority.engine.engineId !== PROVIDER_ID + options.authority.engine.operation !== options.engineOperation || + options.authority.engine.engineId !== options.providerId ) { - fail("Docker authority does not own sandbox-lifecycle operations"); + fail( + `${options.providerDisplayName} authority does not own ${options.engineOperation} operations`, + ); } - const bindingSha256 = dockerOperationBindingSha256(options.authority.engine); + const bindingSha256 = operationBindingSha256(options.authority.engine); - const owner: DockerStateMutationOwner = { + const owner: ContainerStateMutationOwner = { acquire(input) { requireContext(options, input); if ( @@ -1604,7 +1706,7 @@ export function createDockerStateMutationOwner( const plan = normalizePreparedPlan(input.plan); options.authority.assertAuthority(); options.engineAuthorityStore.record( - createPersistedEngineAuthority(PROVIDER_ID, options.authority.engine, bindingSha256), + createPersistedEngineAuthority(options.providerId, options.authority.engine, bindingSha256), ); const before = inspectDirect(options, bindingSha256); const stateRoot = bindStateRoot(before, plan.plan.stateRoot); @@ -1774,6 +1876,7 @@ export function createDockerStateMutationOwner( const before = inspectAuthorized(scope, options); const receipt = invokeHelperAuthorized( scope, + options.providerId, "activate", statusRequest( "activate", @@ -1822,6 +1925,7 @@ export function createDockerStateMutationOwner( const before = inspectAuthorized(scope, options); const recovered = invokeHelperAuthorized( scope, + options.providerId, "recover", statusRequest("recover", options, bindingSha256, before, record.transactionId), ); @@ -1872,6 +1976,17 @@ export function createDockerStateMutationOwner( return Object.freeze(owner); } +export function createDockerStateMutationOwner( + options: DockerStateMutationOwnerOptions, +): DockerStateMutationOwner { + return createContainerStateMutationOwner({ + ...options, + providerId: DOCKER_PROVIDER_ID, + providerDisplayName: "Docker", + engineOperation: "sandbox-lifecycle", + }); +} + function lifecycleGeneration(input: RuntimeProviderStateMutationContext): string { return boundedString( input.sandbox.lifecycleGeneration, @@ -1880,11 +1995,14 @@ function lifecycleGeneration(input: RuntimeProviderStateMutationContext): string ); } -function requireSurfaceContext(input: RuntimeProviderStateMutationContext): void { +function requireSurfaceContext( + input: RuntimeProviderStateMutationContext, + providerId: string, +): void { const sandboxName = boundedString(input.sandboxName, SAFE_NAME, "sandbox name"); if ( input.sandbox.name !== sandboxName || - input.sandbox.openshellDriver !== PROVIDER_ID || + input.sandbox.openshellDriver !== providerId || typeof input.environment !== "object" || input.environment === null ) { @@ -1894,8 +2012,9 @@ function requireSurfaceContext(input: RuntimeProviderStateMutationContext): void } function resolveExactLabeledRuntimeId( - authority: DockerOperationAuthority, + authority: ContainerStateMutationAuthority, sandboxName: string, + providerDisplayName: string, ): string { const result = authority.engine.capture( [ @@ -1911,59 +2030,63 @@ function resolveExactLabeledRuntimeId( ], INSPECT_TIMEOUT_MS, ); - const output = requireCommandSuccess(result, "Docker labeled runtime resolution"); + const output = requireCommandSuccess(result, `${providerDisplayName} labeled runtime resolution`); if (Buffer.byteLength(output, "utf8") > 4096 || output.includes("\0") || output.includes("\r")) { - fail("Docker labeled runtime resolution returned malformed output"); + fail(`${providerDisplayName} labeled runtime resolution returned malformed output`); } const ids = output .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); if (ids.length !== 1 || !CONTAINER_ID.test(ids[0] as string)) { - fail("Docker labeled runtime resolution requires one exact full container identity"); + fail( + `${providerDisplayName} labeled runtime resolution requires one exact full container identity`, + ); } authority.assertAuthority(); return ids[0] as string; } function requireExistingSurfaceAuthority( - authority: DockerOperationAuthority, + authority: ContainerStateMutationAuthority, engineAuthorityStore: PersistedEngineAuthorityStore, + options: ContainerStateMutationSurfaceOptions, ): void { authority.assertAuthority(); - const persisted = engineAuthorityStore.load("sandbox-lifecycle"); - if (!persisted) fail("persisted sandbox-lifecycle engine authority is missing"); + const persisted = engineAuthorityStore.load(options.engineOperation); + if (!persisted) fail(`persisted ${options.engineOperation} engine authority is missing`); requirePersistedEngineAuthority( persisted, - PROVIDER_ID, + options.providerId, authority.engine, - dockerOperationBindingSha256(authority.engine), + operationBindingSha256(authority.engine), ); } function createSurfaceOwner( input: RuntimeProviderStateMutationContext, - options: DockerStateMutationSurfaceOptions, + options: ContainerStateMutationSurfaceOptions, phase: "acquire" | "existing", -): DockerStateMutationOwner { - requireSurfaceContext(input); +): ContainerStateMutationOwner { + requireSurfaceContext(input, options.providerId); - // The operation-qualified endpoint is established from this invocation's - // environment before any mutable-name lookup occurs. - const authority = createDockerOperationAuthority( - "sandbox-lifecycle", - input.environment, - options.capture, - ); - const stateDir = (options.resolveStateDir ?? resolveDockerStateMutationStateDir)( + const authority = options.createAuthority(input); + const stateDir = (options.resolveStateDir ?? resolveContainerStateMutationStateDir)( input.environment, ); const engineAuthorityStore = createFilePersistedEngineAuthorityStore(stateDir); if (phase === "existing") { - requireExistingSurfaceAuthority(authority, engineAuthorityStore); + requireExistingSurfaceAuthority(authority, engineAuthorityStore, options); } - const runtimeId = resolveExactLabeledRuntimeId(authority, input.sandboxName); - return createDockerStateMutationOwner({ + const runtimeId = resolveExactLabeledRuntimeId( + authority, + input.sandboxName, + options.providerDisplayName, + ); + return createContainerStateMutationOwner({ + providerId: options.providerId, + providerDisplayName: options.providerDisplayName, + engineOperation: options.engineOperation, sandboxName: input.sandboxName, lifecycleGeneration: lifecycleGeneration(input), ...(input.sandbox.lifecycleLiveIdentityFingerprint === undefined @@ -1980,10 +2103,10 @@ function createSurfaceOwner( function recoverSurface( input: RuntimeProviderStateMutationContext, - options: DockerStateMutationSurfaceOptions, + options: ContainerStateMutationSurfaceOptions, ): RuntimeProviderStateMutationFence | null { - requireSurfaceContext(input); - const stateDir = (options.resolveStateDir ?? resolveDockerStateMutationStateDir)( + requireSurfaceContext(input, options.providerId); + const stateDir = (options.resolveStateDir ?? resolveContainerStateMutationStateDir)( input.environment, ); const lifecycleStore = createFilePersistedEngineLifecycleStore(stateDir); @@ -1999,20 +2122,20 @@ function releaseSurface( fence: RuntimeProviderStateMutationFence, proof: RuntimeProviderStateMutationActivationProof, completedLedgerSha256: string, - options: DockerStateMutationSurfaceOptions, + options: ContainerStateMutationSurfaceOptions, ): void { - requireSurfaceContext(input); + requireSurfaceContext(input, options.providerId); const source = record(fence, "state mutation fence"); const transactionId = boundedString(source.transactionId, SHA256, "fence transaction identity"); const resultSha256 = boundedString(completedLedgerSha256, SHA256, "completed ledger digest"); if ( - source.providerId !== PROVIDER_ID || + source.providerId !== options.providerId || source.sandboxName !== input.sandboxName || source.lifecycleGeneration !== input.sandbox.lifecycleGeneration ) { fail("state mutation fence does not match the sandbox release context"); } - const stateDir = (options.resolveStateDir ?? resolveDockerStateMutationStateDir)( + const stateDir = (options.resolveStateDir ?? resolveContainerStateMutationStateDir)( input.environment, ); const lifecycleStore = createFilePersistedEngineLifecycleStore(stateDir); @@ -2020,10 +2143,12 @@ function releaseSurface( createSurfaceOwner(input, options, "existing").release(input, fence, proof, resultSha256); } -/** Production Docker provider surface for one exact, durable runtime fence. */ -export function createDockerStateMutationSurface( - options: DockerStateMutationSurfaceOptions = {}, +/** One exact, durable container-provider runtime fence. */ +export function createContainerStateMutationSurface( + options: ContainerStateMutationSurfaceOptions, ): Extract { + boundedString(options.providerId, PROVIDER_ID, "provider identity"); + boundedString(options.providerDisplayName, SAFE_NAME, "provider display name"); const withDirectSandboxExecutionExclusion = options.withDirectSandboxExecutionExclusion ?? withShieldsTransitionLock; const acquireSurface = ( @@ -2031,8 +2156,8 @@ export function createDockerStateMutationSurface( readonly plan: RuntimeProviderPreparedStateMutationPlan; }, ): RuntimeProviderStateMutationFence => { - requireSurfaceContext(input); - const stateDir = (options.resolveStateDir ?? resolveDockerStateMutationStateDir)( + requireSurfaceContext(input, options.providerId); + const stateDir = (options.resolveStateDir ?? resolveContainerStateMutationStateDir)( input.environment, ); const lifecycleStore = createFilePersistedEngineLifecycleStore(stateDir); @@ -2043,13 +2168,13 @@ export function createDockerStateMutationSurface( return createSurfaceOwner(input, options, "acquire").acquire(input); }; const surface: Extract = { - providerId: PROVIDER_ID, + providerId: options.providerId, supported: true, contractVersion: RUNTIME_PROVIDER_STATE_MUTATION_CONTRACT_VERSION, acquire: (input) => withDirectSandboxExecutionExclusion( input.sandboxName, - "Docker runtime-provider state mutation acquire", + `${options.providerDisplayName} runtime-provider state mutation acquire`, () => acquireSurface(input), ), assertFenced: (input, fence) => @@ -2064,9 +2189,28 @@ export function createDockerStateMutationSurface( recover: (input) => withDirectSandboxExecutionExclusion( input.sandboxName, - "Docker runtime-provider state mutation recovery", + `${options.providerDisplayName} runtime-provider state mutation recovery`, () => recoverSurface(input, options), ), }; return Object.freeze(surface); } + +/** Production Docker provider surface for one exact, durable runtime fence. */ +export function createDockerStateMutationSurface( + options: DockerStateMutationSurfaceOptions = {}, +): Extract { + return createContainerStateMutationSurface({ + providerId: DOCKER_PROVIDER_ID, + providerDisplayName: "Docker", + engineOperation: "sandbox-lifecycle", + createAuthority: (input) => + createDockerOperationAuthority("sandbox-lifecycle", input.environment, options.capture), + ...(options.resolveStateDir ? { resolveStateDir: options.resolveStateDir } : {}), + ...(options.withDirectSandboxExecutionExclusion + ? { + withDirectSandboxExecutionExclusion: options.withDirectSandboxExecutionExclusion, + } + : {}), + }); +} diff --git a/src/lib/onboard/runtime-provider/persisted-engine-authority.ts b/src/lib/onboard/runtime-provider/persisted-engine-authority.ts index e4300d84d45..aa9d0c9519a 100644 --- a/src/lib/onboard/runtime-provider/persisted-engine-authority.ts +++ b/src/lib/onboard/runtime-provider/persisted-engine-authority.ts @@ -26,6 +26,7 @@ const OPERATIONS = new Set([ "gateway-inspection", "managed-bootstrap", "sandbox-lifecycle", + "state-mutation", "workload-cleanup", ]); diff --git a/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts b/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts index 4edb9c57d8b..6497f61c7ec 100644 --- a/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts +++ b/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts @@ -68,8 +68,7 @@ export interface PersistedEngineStateMutationIntentInput { readonly nonce: string; } -export interface PersistedEngineStateMutationIntent - extends PersistedEngineStateMutationIntentInput { +export interface PersistedEngineStateMutationIntent extends PersistedEngineStateMutationIntentInput { readonly schemaVersion: typeof PERSISTED_ENGINE_STATE_MUTATION_INTENT_SCHEMA_VERSION; readonly transactionId: string; } @@ -348,8 +347,11 @@ export function normalizePersistedEngineLifecycleRecord( fail("completion result digest does not match the phase"); } const engineAuthority = normalizePersistedEngineAuthority(record.engineAuthority); - if (engineAuthority.operation !== "sandbox-lifecycle") { - fail("lifecycle authority must use the sandbox-lifecycle engine scope"); + if ( + engineAuthority.operation !== "sandbox-lifecycle" && + !(action === "state-mutation" && engineAuthority.operation === "state-mutation") + ) { + fail("lifecycle authority does not match the lifecycle action"); } return Object.freeze({ schemaVersion: PERSISTED_ENGINE_LIFECYCLE_SCHEMA_VERSION, @@ -1821,12 +1823,14 @@ function requireCurrentEngineAuthority( engine: ContainerEngine, bindingSha256: string, ): PersistedEngineAuthority { - if (engine.operation !== "sandbox-lifecycle") { - throw new Error("Persisted lifecycle requires a sandbox-lifecycle container engine."); + if (engine.operation !== "sandbox-lifecycle" && engine.operation !== "state-mutation") { + throw new Error( + "Persisted lifecycle requires a sandbox-lifecycle or state-mutation container engine.", + ); } - const current = engineAuthorityStore.load("sandbox-lifecycle"); + const current = engineAuthorityStore.load(engine.operation); if (!current) { - throw new Error("Persisted sandbox-lifecycle engine authority is missing."); + throw new Error(`Persisted ${engine.operation} engine authority is missing.`); } requirePersistedEngineAuthority(current, providerId, engine, bindingSha256); if ( @@ -1841,8 +1845,16 @@ function requireCurrentEngineAuthority( function expectedRecord( input: PreparePersistedEngineLifecycleInput, ): PersistedEngineLifecycleRecord { - const authority = input.engineAuthorityStore.load("sandbox-lifecycle"); - if (!authority) throw new Error("Persisted sandbox-lifecycle engine authority is missing."); + if ( + input.engine.operation !== "sandbox-lifecycle" && + !(input.action === "state-mutation" && input.engine.operation === "state-mutation") + ) { + throw new Error("Persisted lifecycle engine operation does not match its action."); + } + const authority = input.engineAuthorityStore.load(input.engine.operation); + if (!authority) { + throw new Error(`Persisted ${input.engine.operation} engine authority is missing.`); + } requirePersistedEngineAuthority(authority, input.providerId, input.engine, input.bindingSha256); return normalizePersistedEngineLifecycleRecord({ schemaVersion: PERSISTED_ENGINE_LIFECYCLE_SCHEMA_VERSION, diff --git a/src/lib/onboard/runtime-provider/podman-state-mutation.test.ts b/src/lib/onboard/runtime-provider/podman-state-mutation.test.ts new file mode 100644 index 00000000000..42b4401b4bc --- /dev/null +++ b/src/lib/onboard/runtime-provider/podman-state-mutation.test.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + cleanupDockerStateMutationRoots, + createPodmanStateMutationHarness as harness, + dockerStateMutationPlan as plan, +} from "../../../../test/helpers/docker-state-mutation-harness"; +import type { PodmanBoundContainerEngine, PodmanContainerEngine } from "../../adapters/podman"; +import { loadAgent } from "../../agent/defs"; +import { + runHermesRuntimeProviderStateMutation, + type HermesRuntimeStateMutationConfigTarget, +} from "../../shields/hermes-runtime-state-mutation"; +import { createPodmanRuntimeProviderBundle } from "./podman"; +import { createRuntimeProviderBundleRegistry } from "./registry"; + +function companionEngine( + operation: "host-doctor" | "sandbox-lifecycle", + stateMutation: PodmanContainerEngine, +): PodmanContainerEngine { + return { + operation, + engineId: "podman", + displayName: "Podman", + authorityId: `${stateMutation.endpointAuthorityId}:${operation}`, + endpointAuthorityId: stateMutation.endpointAuthorityId, + capture: vi.fn(() => ({ status: 0, stdout: "", stderr: "" })), + captureHost: vi.fn(() => ({ status: 0, stdout: "", stderr: "" })), + }; +} + +function hermesConfigTarget(): HermesRuntimeStateMutationConfigTarget { + const agent = loadAgent("hermes"); + return { + agentName: agent.name, + configPath: path.posix.join(agent.configPaths.dir, agent.configPaths.configFile), + configDir: agent.configPaths.dir, + configFile: agent.configPaths.configFile, + format: agent.configPaths.format, + sensitiveFiles: [ + path.posix.join(agent.configPaths.dir, ".config-hash"), + ...agent.configPaths.shieldsFiles.map((entry) => + path.posix.join(agent.configPaths.dir, entry), + ), + ], + stateLockPlan: agent.stateLockPlan, + stateLockPlanInImage: agent.stateLockPlanInImage, + }; +} + +afterEach(() => cleanupDockerStateMutationRoots()); + +describe("Podman runtime-provider state mutation", () => { + it("holds one exact Podman fence through rollback, activation, and durable release", () => { + const runtime = harness(); + const fence = runtime.owner.acquire({ ...runtime.context, plan: plan() }); + + expect(fence).toMatchObject({ + providerId: "podman", + phase: "fenced", + providerHandle: expect.stringMatching(/^podman-state-mutation-v1:/u), + }); + expect(runtime.engineAuthorityStore.load("state-mutation")).toMatchObject({ + providerId: "podman", + operation: "state-mutation", + engineId: "podman", + }); + + runtime.owner.rollback(runtime.context, fence); + const proof = runtime.owner.activate(runtime.context, fence); + expect(proof).toMatchObject({ + providerId: "podman", + providerHandle: expect.stringMatching(/^podman-state-mutation-activation-v1:/u), + }); + runtime.owner.release(runtime.context, fence, proof, "e".repeat(64)); + + expect(runtime.lifecycleStore.listUnfinished()).toEqual([]); + expect(runtime.helperActions).toEqual([ + "acquire", + "rollback", + "activate", + "activate", + "release", + ]); + expect( + runtime.capture.mock.calls.every(([, args]) => + (args as readonly string[]) + .slice(0, 2) + .every((value, index) => + index === 0 ? value === "--url" : value === "unix:///run/user/1000/podman/podman.sock", + ), + ), + ).toBe(true); + }); + + it("replays one lost acquire from durable intent under the same Podman authority", () => { + const runtime = harness({ loseAcquireResponseOnce: true }); + + expect(() => runtime.owner.acquire({ ...runtime.context, plan: plan() })).toThrow( + "root helper acquire did not complete successfully", + ); + expect(runtime.lifecycleStore.listUnfinished()).toMatchObject([ + { + action: "state-mutation", + phase: "prepared", + engineAuthority: { providerId: "podman", operation: "state-mutation" }, + }, + ]); + + const recovered = runtime.owner.recover(runtime.context); + + expect(recovered).toMatchObject({ + providerId: "podman", + phase: "fenced", + providerHandle: expect.stringMatching(/^podman-state-mutation-v1:/u), + }); + expect(runtime.acquireRequests[1]).toBe(runtime.acquireRequests[0]); + expect(runtime.helperActions).toEqual(["acquire", "acquire"]); + }); + + it("rejects runtime identity drift before retrying the retained fence", () => { + const runtime = harness(); + const fence = runtime.owner.acquire({ ...runtime.context, plan: plan() }); + runtime.state.mountSource = "/var/lib/openshell/replaced/hermes"; + + expect(() => runtime.owner.assertFenced(runtime.context, fence)).toThrow( + "runtime changed after the state mutation fence was established", + ); + expect(runtime.helperActions).toEqual(["acquire"]); + }); + + it("runs the named Hermes consumer only through an injected Podman bundle", () => { + const runtime = harness(); + const stateMutation = runtime.authority.engine as PodmanBoundContainerEngine; + const bundle = createPodmanRuntimeProviderBundle({ + engines: { + hostDoctor: companionEngine("host-doctor", stateMutation), + sandboxLifecycle: companionEngine("sandbox-lifecycle", stateMutation), + stateMutation, + }, + stateMutation: { resolveStateDir: () => runtime.root }, + }); + const providers = createRuntimeProviderBundleRegistry([["podman", bundle]]); + const sandbox = { ...runtime.context.sandbox, agent: "hermes" }; + + const result = runHermesRuntimeProviderStateMutation({ + environment: runtime.context.environment, + sandbox, + sandboxName: sandbox.name, + configTarget: hermesConfigTarget(), + target: "locked", + rollback: "mutable", + providers, + }); + + expect(result).toMatchObject({ + fence: { + providerId: "podman", + providerHandle: expect.stringMatching(/^podman-state-mutation-v1:/u), + }, + proof: { + providerId: "podman", + providerHandle: expect.stringMatching(/^podman-state-mutation-activation-v1:/u), + }, + }); + expect(runtime.helperActions).toEqual([ + "acquire", + "assert", + "publish", + "assert", + "activate", + "activate", + "release", + ]); + }); +}); diff --git a/src/lib/onboard/runtime-provider/podman-state-mutation.ts b/src/lib/onboard/runtime-provider/podman-state-mutation.ts new file mode 100644 index 00000000000..60f6709cdc0 --- /dev/null +++ b/src/lib/onboard/runtime-provider/podman-state-mutation.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PodmanBoundContainerEngine } from "../../adapters/podman"; +import type { RuntimeProviderStateMutationSurface } from "./contract"; +import { + createContainerStateMutationSurface, + type ContainerStateMutationSurfaceOptions, +} from "./container-state-mutation"; + +export interface PodmanStateMutationSurfaceOptions { + readonly engine: PodmanBoundContainerEngine; + readonly resolveStateDir?: ContainerStateMutationSurfaceOptions["resolveStateDir"]; + readonly withDirectSandboxExecutionExclusion?: ContainerStateMutationSurfaceOptions["withDirectSandboxExecutionExclusion"]; +} + +/** Candidate-only Podman facet bound to one qualified socket and executable. */ +export function createPodmanStateMutationSurface( + options: PodmanStateMutationSurfaceOptions, +): Extract { + if (options.engine.engineId !== "podman" || options.engine.operation !== "state-mutation") { + throw new Error("Podman state mutation requires a 'state-mutation' Podman engine."); + } + const surfaceOptions: ContainerStateMutationSurfaceOptions = { + providerId: "podman", + providerDisplayName: "Podman", + engineOperation: "state-mutation", + createAuthority: () => ({ + assertAuthority: options.engine.assertAuthority, + engine: options.engine, + }), + ...(options.resolveStateDir ? { resolveStateDir: options.resolveStateDir } : {}), + ...(options.withDirectSandboxExecutionExclusion + ? { + withDirectSandboxExecutionExclusion: options.withDirectSandboxExecutionExclusion, + } + : {}), + }; + return createContainerStateMutationSurface(surfaceOptions); +} diff --git a/src/lib/onboard/runtime-provider/podman.test.ts b/src/lib/onboard/runtime-provider/podman.test.ts index ca4126c1047..d840a050ac7 100644 --- a/src/lib/onboard/runtime-provider/podman.test.ts +++ b/src/lib/onboard/runtime-provider/podman.test.ts @@ -7,6 +7,7 @@ import { startSandbox } from "../../actions/sandbox/start"; import { stopSandbox } from "../../actions/sandbox/stop"; import { createPodmanContainerEngine, + type PodmanBoundContainerEngine, type PodmanContainerEngine, type PodmanExecutableAuthorityDeps, type PodmanExecutableStat, @@ -96,6 +97,11 @@ function realOperationEngines(socketAuthority: PodmanSocketAuthority = REAL_SOCK ...common, operation: "sandbox-lifecycle", }), + stateMutation: createPodmanContainerEngine({ + ...common, + operation: "state-mutation", + executableAuthorityDeps: podmanExecutableAuthorityDeps(), + }), }; } @@ -215,43 +221,44 @@ function providerHarness(agent: (typeof AGENTS)[number]) { } describe("dormant Podman runtime provider", () => { - it.each( - AGENTS, - )("runs basic CPU start and stop for %s through an injected bundle", async (agent) => { - const runtime = providerHarness(agent); - const verifyGateway = vi.fn(async () => undefined); - const restoreStartupState = vi.fn(() => SUCCESSFUL_RECOVERY); - const stopSandboxChannels = vi.fn(); + it.each(AGENTS)( + "runs basic CPU start and stop for %s through an injected bundle", + async (agent) => { + const runtime = providerHarness(agent); + const verifyGateway = vi.fn(async () => undefined); + const restoreStartupState = vi.fn(() => SUCCESSFUL_RECOVERY); + const stopSandboxChannels = vi.fn(); - await expect( - startSandbox(runtime.sandboxName, { - getSandbox: () => runtime.entry, - runtimeProviders: runtime.providers, - restoreStartupState, - verifyGateway, - log: vi.fn(), - }), - ).resolves.toEqual({ exitCode: 0 }); - expect( - stopSandbox(runtime.sandboxName, { - getSandbox: () => runtime.entry, - runtimeProviders: runtime.providers, - stopSandboxChannels, - teardownSandboxDashboardForward: vi.fn(), - log: vi.fn(), - }), - ).toEqual({ exitCode: 0 }); + await expect( + startSandbox(runtime.sandboxName, { + getSandbox: () => runtime.entry, + runtimeProviders: runtime.providers, + restoreStartupState, + verifyGateway, + log: vi.fn(), + }), + ).resolves.toEqual({ exitCode: 0 }); + expect( + stopSandbox(runtime.sandboxName, { + getSandbox: () => runtime.entry, + runtimeProviders: runtime.providers, + stopSandboxChannels, + teardownSandboxDashboardForward: vi.fn(), + log: vi.fn(), + }), + ).toEqual({ exitCode: 0 }); - expect(restoreStartupState).toHaveBeenCalledExactlyOnceWith(runtime.sandboxName); - expect(verifyGateway).toHaveBeenCalledExactlyOnceWith(runtime.sandboxName); - expect(stopSandboxChannels).toHaveBeenCalledWith( - runtime.sandboxName, - expect.objectContaining({ channelStopTransport: "openshell" }), - ); - expect( - JSON.stringify((runtime.lifecycle.capture as ReturnType).mock.calls), - ).not.toContain("docker"); - }); + expect(restoreStartupState).toHaveBeenCalledExactlyOnceWith(runtime.sandboxName); + expect(verifyGateway).toHaveBeenCalledExactlyOnceWith(runtime.sandboxName); + expect(stopSandboxChannels).toHaveBeenCalledWith( + runtime.sandboxName, + expect.objectContaining({ channelStopTransport: "openshell" }), + ); + expect( + JSON.stringify((runtime.lifecycle.capture as ReturnType).mock.calls), + ).not.toContain("docker"); + }, + ); it("reports a failed gateway probe after the exact Podman container starts", async () => { const runtime = providerHarness("openclaw"); @@ -364,14 +371,34 @@ describe("dormant Podman runtime provider", () => { expect(engines.sandboxLifecycle.endpointAuthorityId).toBe( engines.hostLocalInference.endpointAuthorityId, ); + expect(engines.stateMutation.endpointAuthorityId).toBe( + engines.hostLocalInference.endpointAuthorityId, + ); expect(engines.hostLocalInference.authorityId).not.toBe(engines.hostDoctor.authorityId); + expect(engines.stateMutation.authorityId).not.toBe(engines.hostDoctor.authorityId); expect(bundle).toMatchObject({ capabilities: { hostLocalInference: true }, hostLocalInference: { providerId: "podman", supported: true, }, + stateMutation: { + providerId: "podman", + supported: true, + }, + containerEngine: { + providerId: "podman", + supported: true, + identities: expect.arrayContaining([ + { + operation: "state-mutation", + engineId: "podman", + displayName: "Podman", + }, + ]), + }, }); + expect(CURRENT_RUNTIME_PROVIDER_BUNDLES).not.toHaveProperty("podman"); }); it("rejects real operation engines when one socket endpoint drifts", () => { @@ -395,6 +422,48 @@ describe("dormant Podman runtime provider", () => { ).toThrow("same endpoint authority"); }); + it("rejects a state-mutation engine with another operation scope", () => { + const { hostLocalInference: _hostLocalInference, ...engines } = realOperationEngines(); + + expect(() => + createPodmanRuntimeProviderBundle({ + engines: { + ...engines, + stateMutation: engines.sandboxLifecycle as PodmanBoundContainerEngine, + }, + }), + ).toThrow("'state-mutation' Podman engine"); + }); + + it("rejects a state-mutation engine bound to another endpoint authority", () => { + const { hostLocalInference: _hostLocalInference, ...engines } = realOperationEngines(); + const driftedStateMutation = realOperationEngines({ + ...REAL_SOCKET_AUTHORITY, + inode: "9002", + }).stateMutation; + + expect(() => + createPodmanRuntimeProviderBundle({ + engines: { ...engines, stateMutation: driftedStateMutation }, + }), + ).toThrow("same endpoint authority"); + }); + + it("rejects state-mutation options without a state-mutation engine", () => { + const { + hostLocalInference: _hostLocalInference, + stateMutation: _stateMutation, + ...engines + } = realOperationEngines(); + + expect(() => + createPodmanRuntimeProviderBundle({ + engines, + stateMutation: {}, + }), + ).toThrow("state-mutation engine with its options"); + }); + it("rejects a mismatched engine scope before bundle registration", () => { const doctor = hostDoctorEngine(); expect(() => diff --git a/src/lib/onboard/runtime-provider/podman.ts b/src/lib/onboard/runtime-provider/podman.ts index 3e61e1febfe..f3c51dba8d9 100644 --- a/src/lib/onboard/runtime-provider/podman.ts +++ b/src/lib/onboard/runtime-provider/podman.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { PodmanContainerEngine } from "../../adapters/podman"; +import type { PodmanBoundContainerEngine, PodmanContainerEngine } from "../../adapters/podman"; import { RUNTIME_PROVIDER_BUNDLE_CONTRACT_VERSION, type RuntimeProviderBundle, @@ -22,11 +22,14 @@ import { type PodmanHostPreflightOptions, qualifyPodmanHost, } from "./podman-preflight"; +import { createPodmanStateMutationSurface } from "./podman-state-mutation"; +import type { PodmanStateMutationSurfaceOptions } from "./podman-state-mutation"; export interface PodmanRuntimeProviderEngines { readonly hostDoctor: PodmanContainerEngine; readonly hostLocalInference?: PodmanContainerEngine; readonly sandboxLifecycle: PodmanContainerEngine; + readonly stateMutation?: PodmanBoundContainerEngine; } export interface PodmanHostLocalInferenceOptions { @@ -40,6 +43,7 @@ export interface PodmanRuntimeProviderOptions { readonly engines: PodmanRuntimeProviderEngines; readonly hostLocalInference?: PodmanHostLocalInferenceOptions; readonly preflight?: PodmanHostPreflightOptions; + readonly stateMutation?: Omit; } const DORMANT_WORKLOAD_PROFILE = { @@ -55,7 +59,7 @@ function unsupported(providerId: string, reason: string) { function requireEngine( engine: PodmanContainerEngine, - operation: "host-doctor" | "host-local-inference" | "sandbox-lifecycle", + operation: "host-doctor" | "host-local-inference" | "sandbox-lifecycle" | "state-mutation", ): void { if (engine.engineId !== "podman" || engine.operation !== operation) { throw new Error(`Podman provider requires a '${operation}' Podman engine.`); @@ -88,8 +92,14 @@ export function createPodmanRuntimeProviderBundle( options: PodmanRuntimeProviderOptions, ): RuntimeProviderBundle { const providerId = "podman"; - const { hostDoctor, hostLocalInference: inferenceEngine, sandboxLifecycle } = options.engines; + const { + hostDoctor, + hostLocalInference: inferenceEngine, + sandboxLifecycle, + stateMutation: stateMutationEngine, + } = options.engines; const inferenceOptions = options.hostLocalInference; + const stateMutationOptions = options.stateMutation; requireEngine(hostDoctor, "host-doctor"); requireEngine(sandboxLifecycle, "sandbox-lifecycle"); const providerEndpointAuthority = hostDoctor.endpointAuthorityId; @@ -107,6 +117,15 @@ export function createPodmanRuntimeProviderBundle( throw new Error("Podman provider engines must bind the same endpoint authority."); } } + if (stateMutationEngine !== undefined) { + requireEngine(stateMutationEngine, "state-mutation"); + if (stateMutationEngine.endpointAuthorityId !== providerEndpointAuthority) { + throw new Error("Podman provider engines must bind the same endpoint authority."); + } + } + if (stateMutationEngine === undefined && stateMutationOptions !== undefined) { + throw new Error("Podman provider requires its state-mutation engine with its options."); + } const preflight = options.preflight ?? {}; const deferred = "This operation is intentionally deferred to a later Podman slice."; @@ -177,7 +196,16 @@ export function createPodmanRuntimeProviderBundle( supported: true, operations: ["start", "stop"], }, - stateMutation: unsupported(providerId, deferred), + stateMutation: + stateMutationEngine === undefined + ? unsupported( + providerId, + "Podman state mutation remains disabled without injected candidate authority.", + ) + : createPodmanStateMutationSurface({ + engine: stateMutationEngine, + ...(stateMutationOptions ?? {}), + }), bootstrap: unsupported(providerId, deferred), snapshot: unsupported(providerId, deferred), recovery: unsupported(providerId, deferred), @@ -205,6 +233,15 @@ export function createPodmanRuntimeProviderBundle( engineId: sandboxLifecycle.engineId, displayName: sandboxLifecycle.displayName, }, + ...(stateMutationEngine + ? [ + { + operation: "state-mutation" as const, + engineId: stateMutationEngine.engineId, + displayName: stateMutationEngine.displayName, + }, + ] + : []), ], }, }; diff --git a/src/lib/onboard/runtime-provider/registry.ts b/src/lib/onboard/runtime-provider/registry.ts index 8f03c58fbb4..bfbe8f4d8ed 100644 --- a/src/lib/onboard/runtime-provider/registry.ts +++ b/src/lib/onboard/runtime-provider/registry.ts @@ -79,6 +79,7 @@ const CONTAINER_ENGINE_OPERATIONS = new Set([ diff --git a/src/lib/shields/hermes-runtime-state-mutation.ts b/src/lib/shields/hermes-runtime-state-mutation.ts index edb5feb1001..acb7522f14e 100644 --- a/src/lib/shields/hermes-runtime-state-mutation.ts +++ b/src/lib/shields/hermes-runtime-state-mutation.ts @@ -312,7 +312,7 @@ export function supportsHermesRuntimeProviderStateMutation( return provider.identity.id === "docker" && provider.stateMutation.supported === true; } -/** Execute one complete Hermes protection transition under the Docker provider fence. */ +/** Execute one complete Hermes protection transition under its provider fence. */ export function runHermesRuntimeProviderStateMutation( input: HermesRuntimeStateMutationInput, ): HermesRuntimeStateMutationResult | null { @@ -322,7 +322,7 @@ export function runHermesRuntimeProviderStateMutation( input.providers ?? currentRuntimeProviderBundles(), ); const surface = provider.stateMutation; - if (provider.identity.id !== "docker" || surface.supported !== true) { + if (surface.supported !== true || surface.providerId !== provider.identity.id) { throw new Error( `Runtime provider '${provider.identity.id}' does not support Hermes state mutation.`, ); diff --git a/test/e2e/live/podman-cpu-lifecycle.test.ts b/test/e2e/live/podman-cpu-lifecycle.test.ts index 65db0df75bc..82ec8e14329 100644 --- a/test/e2e/live/podman-cpu-lifecycle.test.ts +++ b/test/e2e/live/podman-cpu-lifecycle.test.ts @@ -29,6 +29,10 @@ import { createPodmanRuntimeProviderBundle } from "../../../src/lib/onboard/runt import type { SandboxEntry } from "../../../src/lib/state/registry/types"; import { expect, test } from "../fixtures/e2e-test.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; +import { + consumeNativeRuntimeCandidateEvidence, + type NativeRuntimeCandidateEvidence, +} from "../registry/native-runtime-qualification.ts"; import { ARTIFACT_DIR, cleanupPodmanLifecycle, @@ -59,17 +63,27 @@ const GATEWAY_PORT = 18_080; const SUPERVISOR_IMAGE = "ghcr.io/nvidia/openshell/supervisor@sha256:b58be5e40c788977ffa0e8305a8cad9c656efdf1a3fe182582a00ca870bb0edb"; const E2E_PHASES = [ + "consume exact candidate prerequisites", "pin the exact rootless Podman endpoint", "qualify the Podman 5 host contract", "prove cold activation and warm API readiness", "start the pinned OpenShell Podman gateway", "activate registered-agent identities through the pinned OpenShell CLI", "exercise exact-container stop and start", - "verify production portable ownership and final at-rest state", + "record successful final at-rest state", ] as const; type SupportedLifecycle = Extract; +function candidateAuthority() { + const expectedSourceRevision = process.env.E2E_SOURCE_REVISION ?? ""; + expect(expectedSourceRevision).toMatch(/^[a-f0-9]{40}$/u); + const evidence = JSON.parse( + fs.readFileSync(path.join(ARTIFACT_DIR, "candidate-execution-prerequisites.json"), "utf8"), + ) as NativeRuntimeCandidateEvidence; + return consumeNativeRuntimeCandidateEvidence(evidence, expectedSourceRevision); +} + function engines(): { hostDoctor: PodmanContainerEngine; sandboxLifecycle: PodmanContainerEngine; @@ -90,64 +104,74 @@ function supportedLifecycle(bundle: RuntimeProviderBundle): SupportedLifecycle { return bundle.lifecycle as SupportedLifecycle; } -test("activates pinned OpenShell sandboxes and preserves registered-agent Podman CPU identity", { - meta: { e2ePhases: E2E_PHASES }, - timeout: 360_000, -}, async ({ progress, shellProbe }) => { - progress.phase("pin the exact rootless Podman endpoint"); - expect(process.platform).toBe("linux"); - expect(process.getuid?.()).not.toBe(0); - expect(ARTIFACT_DIR).not.toBe(""); - let runtimeEngines = engines(); - const bundle = createPodmanRuntimeProviderBundle({ engines: runtimeEngines }); +test( + "activates pinned OpenShell sandboxes and preserves registered-agent Podman CPU identity", + { + meta: { e2ePhases: E2E_PHASES }, + timeout: 360_000, + }, + async ({ progress, shellProbe }) => { + progress.phase("consume exact candidate prerequisites"); + expect(candidateAuthority()).toMatchObject({ + candidateId: "podman-cpu-lifecycle", + providerId: "podman", + executionPath: "runtime-provider-bundle", + }); + + progress.phase("pin the exact rootless Podman endpoint"); + expect(process.platform).toBe("linux"); + expect(process.getuid?.()).not.toBe(0); + expect(ARTIFACT_DIR).not.toBe(""); + let runtimeEngines = engines(); + const bundle = createPodmanRuntimeProviderBundle({ engines: runtimeEngines }); - progress.phase("qualify the Podman 5 host contract"); - const doctor = bundle.preflightDoctor.inspectHost(); - expect(doctor).toMatchObject({ - group: "Host", - label: "Podman runtime", - status: "ok", - }); - expect(doctor.detail).toContain("rootless server 5."); - expect(bundle.identity.id).toBe("podman"); - expect(bundle.workload.profile.support).toBeNull(); - expect(bundle.capabilities.hostLocalInference).toBe(false); + progress.phase("qualify the Podman 5 host contract"); + const doctor = bundle.preflightDoctor.inspectHost(); + expect(doctor).toMatchObject({ + group: "Host", + label: "Podman runtime", + status: "ok", + }); + expect(doctor.detail).toContain("rootless server 5."); + expect(bundle.identity.id).toBe("podman"); + expect(bundle.workload.profile.support).toBeNull(); + expect(bundle.capabilities.hostLocalInference).toBe(false); - const openshellBin = executableOnPath("openshell"); - const gatewayBin = executableOnPath("openshell-gateway"); - const sandboxBin = executableOnPath("openshell-sandbox"); - for (const component of [openshellBin, gatewayBin, sandboxBin]) { - expect( - await runCommand(shellProbe, component, ["--version"], { - artifactName: `podman-lifecycle-version-${path.basename(component)}`, - }), - ).toContain(OPENSHELL_VERSION); - } + const openshellBin = executableOnPath("openshell"); + const gatewayBin = executableOnPath("openshell-gateway"); + const sandboxBin = executableOnPath("openshell-sandbox"); + for (const component of [openshellBin, gatewayBin, sandboxBin]) { + expect( + await runCommand(shellProbe, component, ["--version"], { + artifactName: `podman-lifecycle-version-${path.basename(component)}`, + }), + ).toContain(OPENSHELL_VERSION); + } - const uid = process.getuid?.() ?? -1; - expect(uid, "Rootless portable lifecycle evidence requires a non-root Linux UID").toBeGreaterThan( - 0, - ); - const runtimeAuthority = { - schemaVersion: 1, - kind: "podman", - ownership: "current-user", - uid, - homeDir: os.homedir(), - configHome: path.join(os.homedir(), ".config"), - runtimeDir: path.join("/run/user", String(uid)), - socketPath: SOCKET_PATH, - } as const; + const uid = process.getuid?.() ?? -1; + expect(uid, "Rootless portable lifecycle evidence requires a non-root Linux UID").toBeGreaterThan( + 0, + ); + const runtimeAuthority = { + schemaVersion: 1, + kind: "podman", + ownership: "current-user", + uid, + homeDir: os.homedir(), + configHome: path.join(os.homedir(), ".config"), + runtimeDir: path.join("/run/user", String(uid)), + socketPath: SOCKET_PATH, + } as const; - progress.phase("prove cold activation and warm API readiness"); - const proofServicePid = process.env.E2E_PODMAN_SERVICE_PID ?? ""; - expect(proofServicePid).toMatch(/^[1-9][0-9]*$/u); - await runCommand( - shellProbe, - "bash", - [ - "-ceu", - ` + progress.phase("prove cold activation and warm API readiness"); + const proofServicePid = process.env.E2E_PODMAN_SERVICE_PID ?? ""; + expect(proofServicePid).toMatch(/^[1-9][0-9]*$/u); + await runCommand( + shellProbe, + "bash", + [ + "-ceu", + ` pid="$1" kill "$pid" for _attempt in $(seq 1 100); do @@ -159,113 +183,141 @@ done printf 'Podman proof service %s did not stop\n' "$pid" >&2 exit 1 `, - "podman-proof-service-stop", - proofServicePid, - ], - { artifactName: "podman-lifecycle-stop-proof-service", timeoutMs: 60_000 }, - ); - expect(fs.existsSync(`/proc/${proofServicePid}`)).toBe(false); - fs.rmSync(SOCKET_PATH, { force: true }); - await runCommand( - shellProbe, - "systemctl", - ["--user", "stop", "podman.service", "podman.socket"], - { artifactName: "podman-lifecycle-stop-user-units", timeoutMs: 10_000 }, - ); - for (const unit of ["podman.service", "podman.socket"]) { - expect( - await runCommand(shellProbe, "systemctl", ["--user", "is-active", unit], { - allowFailure: true, - artifactName: `podman-lifecycle-cold-${unit}`, - timeoutMs: 10_000, - }), - ).not.toBe("active"); - } - const coldReadiness = inspectPortablePodmanReadiness(runtimeAuthority); - expect(coldReadiness).toMatchObject({ ok: true, timing: { mode: "cold" } }); - const warmReadiness = inspectPortablePodmanReadiness(runtimeAuthority); - expect(warmReadiness).toMatchObject({ ok: true, timing: { mode: "warm" } }); - runtimeEngines = engines(); - - const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-podman-openshell-")); - const stateDir = path.join(root, "gateway-state"); - const cliEnv: NodeJS.ProcessEnv = { - ...process.env, - OPENSHELL_GATEWAY: GATEWAY_NAME, - XDG_CONFIG_HOME: path.join(root, "cli-config"), - }; - const createdSandboxes: string[] = []; - let gateway: ChildProcess | null = null; - let completed = false; - const previousPortableProfile = process.env.NEMOCLAW_EXPERIMENTAL_PROFILE; - - try { - progress.phase("start the pinned OpenShell Podman gateway"); - process.env.NEMOCLAW_EXPERIMENTAL_PROFILE = "portable"; - const gatewayEnv = buildDockerDriverGatewayEnv({ - platform: "linux", - gatewayPort: GATEWAY_PORT, - stateDir, - podmanSocketPath: SOCKET_PATH, - getDockerSupervisorImage: () => SUPERVISOR_IMAGE, - resolveSandboxBin: () => sandboxBin, - }); - const tls = ensureDockerDriverGatewayLocalTlsBundle({ gatewayBin, stateDir }); - cliEnv.OPENSHELL_LOCAL_TLS_DIR = tls.localTlsDir; - gateway = await startPinnedGateway(gatewayBin, gatewayEnv, progress); + "podman-proof-service-stop", + proofServicePid, + ], + { artifactName: "podman-lifecycle-stop-proof-service", timeoutMs: 60_000 }, + ); + expect(fs.existsSync(`/proc/${proofServicePid}`)).toBe(false); + fs.rmSync(SOCKET_PATH, { force: true }); await runCommand( shellProbe, - openshellBin, - [ - "gateway", - "add", - `https://127.0.0.1:${String(GATEWAY_PORT)}`, - "--local", - "--name", - GATEWAY_NAME, - ], - { artifactName: "podman-lifecycle-add-gateway", env: cliEnv }, + "systemctl", + ["--user", "stop", "podman.service", "podman.socket"], + { artifactName: "podman-lifecycle-stop-user-units", timeoutMs: 10_000 }, ); - const gatewayInfo = await waitForHealthyGateway(shellProbe, openshellBin, cliEnv, gateway); - expect(gatewayInfo).toMatchObject({ status: "healthy", version: OPENSHELL_VERSION }); - expect(gatewayInfo.compute_drivers).toContainEqual(expect.objectContaining({ name: "podman" })); + for (const unit of ["podman.service", "podman.socket"]) { + expect( + await runCommand(shellProbe, "systemctl", ["--user", "is-active", unit], { + allowFailure: true, + artifactName: `podman-lifecycle-cold-${unit}`, + timeoutMs: 10_000, + }), + ).not.toBe("active"); + } + const coldReadiness = inspectPortablePodmanReadiness(runtimeAuthority); + expect(coldReadiness).toMatchObject({ ok: true, timing: { mode: "cold" } }); + const warmReadiness = inspectPortablePodmanReadiness(runtimeAuthority); + expect(warmReadiness).toMatchObject({ ok: true, timing: { mode: "warm" } }); + runtimeEngines = engines(); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-podman-openshell-")); + const stateDir = path.join(root, "gateway-state"); + const cliEnv: NodeJS.ProcessEnv = { + ...process.env, + OPENSHELL_GATEWAY: GATEWAY_NAME, + XDG_CONFIG_HOME: path.join(root, "cli-config"), + }; + const createdSandboxes: string[] = []; + let gateway: ChildProcess | null = null; + let completed = false; + const previousPortableProfile = process.env.NEMOCLAW_EXPERIMENTAL_PROFILE; - progress.phase("activate registered-agent identities through the pinned OpenShell CLI"); - for (const { agent, sandboxName } of AGENTS) { - // Record the exact proof-owned name before creation so cleanup also - // covers a sandbox that reaches OpenShell's Error phase. - createdSandboxes.push(sandboxName); + try { + progress.phase("start the pinned OpenShell Podman gateway"); + process.env.NEMOCLAW_EXPERIMENTAL_PROFILE = "portable"; + const gatewayEnv = buildDockerDriverGatewayEnv({ + platform: "linux", + gatewayPort: GATEWAY_PORT, + stateDir, + podmanSocketPath: SOCKET_PATH, + getDockerSupervisorImage: () => SUPERVISOR_IMAGE, + resolveSandboxBin: () => sandboxBin, + }); + const tls = ensureDockerDriverGatewayLocalTlsBundle({ gatewayBin, stateDir }); + cliEnv.OPENSHELL_LOCAL_TLS_DIR = tls.localTlsDir; + gateway = await startPinnedGateway(gatewayBin, gatewayEnv, progress); await runCommand( shellProbe, openshellBin, [ - "sandbox", - "create", - "-g", - GATEWAY_NAME, + "gateway", + "add", + `https://127.0.0.1:${String(GATEWAY_PORT)}`, + "--local", "--name", - sandboxName, - "--from", - BASE_IMAGE, - "--policy", - ACTIVATION_POLICY, - "--label", - `nemoclaw.agent=${agent}`, - "--no-tty", - "--", - "/bin/sh", - "-lc", - // OpenShell keeps sandboxes by default after the initial command - // exits. Let this command finish so `sandbox create` can return; - // a foreground keepalive would hold the CLI session indefinitely. - `printf '%s\\n' '${agent}' >/tmp/nemoclaw-agent-proof`, + GATEWAY_NAME, ], - { - artifactName: `podman-lifecycle-create-${agent}`, - env: cliEnv, - timeoutMs: 240_000, - }, + { artifactName: "podman-lifecycle-add-gateway", env: cliEnv }, ); + const gatewayInfo = await waitForHealthyGateway(shellProbe, openshellBin, cliEnv, gateway); + expect(gatewayInfo).toMatchObject({ status: "healthy", version: OPENSHELL_VERSION }); + expect(gatewayInfo.compute_drivers).toContainEqual( + expect.objectContaining({ name: "podman" }), + ); + + progress.phase("activate registered-agent identities through the pinned OpenShell CLI"); + for (const { agent, sandboxName } of AGENTS) { + // Record the exact proof-owned name before creation so cleanup also + // covers a sandbox that reaches OpenShell's Error phase. + createdSandboxes.push(sandboxName); + await runCommand( + shellProbe, + openshellBin, + [ + "sandbox", + "create", + "-g", + GATEWAY_NAME, + "--name", + sandboxName, + "--from", + BASE_IMAGE, + "--policy", + ACTIVATION_POLICY, + "--label", + `nemoclaw.agent=${agent}`, + "--no-tty", + "--", + "/bin/sh", + "-lc", + // OpenShell keeps sandboxes by default after the initial command + // exits. Let this command finish so `sandbox create` can return; + // a foreground keepalive would hold the CLI session indefinitely. + `printf '%s\\n' '${agent}' >/tmp/nemoclaw-agent-proof`, + ], + { + artifactName: `podman-lifecycle-create-${agent}`, + env: cliEnv, + timeoutMs: 240_000, + }, + ); + expect( + await runCommand( + shellProbe, + openshellBin, + [ + "sandbox", + "exec", + "--name", + sandboxName, + "-g", + GATEWAY_NAME, + "--", + "cat", + "/tmp/nemoclaw-agent-proof", + ], + { + artifactName: `podman-lifecycle-agent-proof-${agent}`, + env: cliEnv, + timeoutMs: 10_000, + }, + ), + ).toBe(agent); + const activated = inspectContainer(runtimeEngines.sandboxLifecycle, sandboxName); + expect(activated.State).toMatchObject({ Paused: false, Running: true, Status: "running" }); + } + expect( await runCommand( shellProbe, @@ -274,147 +326,123 @@ exit 1 "sandbox", "exec", "--name", - sandboxName, + AGENTS[0].sandboxName, "-g", GATEWAY_NAME, "--", - "cat", - "/tmp/nemoclaw-agent-proof", + "/bin/sh", + "-lc", + "command -v ip", ], { - artifactName: `podman-lifecycle-agent-proof-${agent}`, + artifactName: "podman-lifecycle-v085-ip-prerequisite", env: cliEnv, timeoutMs: 10_000, }, ), - ).toBe(agent); - const activated = inspectContainer(runtimeEngines.sandboxLifecycle, sandboxName); - expect(activated.State).toMatchObject({ Paused: false, Running: true, Status: "running" }); - } + ).toMatch(/^\/(?:usr\/)?s?bin\/ip$/u); - expect( - await runCommand( - shellProbe, - openshellBin, + const openclawSandbox = AGENTS[0].sandboxName; + const portableStateDir = path.join(root, "portable-lifecycle"); + const readinessLogs: string[] = []; + const registryGeneration = installPortableDemoSandboxLifecycle( + openclawSandbox, [ - "sandbox", - "exec", - "--name", - AGENTS[0].sandboxName, - "-g", - GATEWAY_NAME, - "--", - "/bin/sh", - "-lc", - "command -v ip", + "env", + "CHAT_UI_URL=http://127.0.0.1:18789", + "NEMOCLAW_DASHBOARD_PORT=18789", + "OPENCLAW_HOME=/sandbox", + "OPENCLAW_STATE_DIR=/sandbox/.openclaw", + "OPENCLAW_WORKSPACE_DIR=/sandbox/.openclaw/workspace", + `NEMOCLAW_SANDBOX_NAME=${openclawSandbox}`, + "/usr/local/bin/nemoclaw-start", ], + { ...process.env, NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, { - artifactName: "podman-lifecycle-v085-ip-prerequisite", - env: cliEnv, - timeoutMs: 10_000, + platform: "linux", + log: (message) => readinessLogs.push(message), + runtimeAuthority, + stateDir: portableStateDir, }, - ), - ).toMatch(/^\/(?:usr\/)?s?bin\/ip$/u); - - const openclawSandbox = AGENTS[0].sandboxName; - const portableStateDir = path.join(root, "portable-lifecycle"); - const readinessLogs: string[] = []; - installPortableDemoSandboxLifecycle( - openclawSandbox, - [ - "env", - "CHAT_UI_URL=http://127.0.0.1:18789", - "NEMOCLAW_DASHBOARD_PORT=18789", - "OPENCLAW_HOME=/sandbox", - "OPENCLAW_STATE_DIR=/sandbox/.openclaw", - "OPENCLAW_WORKSPACE_DIR=/sandbox/.openclaw/workspace", - `NEMOCLAW_SANDBOX_NAME=${openclawSandbox}`, - "/usr/local/bin/nemoclaw-start", - ], - { ...process.env, NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, - { - platform: "linux", - log: (message) => readinessLogs.push(message), - stateDir: portableStateDir, - runtimeAuthority, - }, - ); - expect(readinessLogs).toContainEqual(expect.stringContaining("readiness: warm")); - runtimeEngines = engines(); - const portableReceipt = JSON.parse( - fs.readFileSync( - portableDemoLifecycleInternals.receiptPath(openclawSandbox, portableStateDir), - "utf-8", - ), - ) as { - containerId: string; - runtimeAuthority: { socketPath: string }; - sandboxName: string; - schemaVersion: number; - }; - expect(portableReceipt).toMatchObject({ - containerId: exactContainerId(runtimeEngines.sandboxLifecycle, openclawSandbox), - sandboxName: openclawSandbox, - runtimeAuthority: { socketPath: SOCKET_PATH }, - schemaVersion: 4, - }); - - progress.phase("exercise exact-container stop and start"); - for (const { agent, sandboxName } of AGENTS) { - const agentEngines = engines(); - const agentBundle = createPodmanRuntimeProviderBundle({ engines: agentEngines }); - const lifecycle = supportedLifecycle(agentBundle); - const sandbox: SandboxEntry = { agent, name: sandboxName, openshellDriver: "podman" }; - const input: RuntimeProviderLifecycleInput = { - environment: process.env, - log: vi.fn(), - sandbox, - sandboxName, + ); + expect(readinessLogs).toContainEqual(expect.stringContaining("readiness: warm")); + expect(registryGeneration).toMatch(/^[a-f0-9]{64}$/u); + runtimeEngines = engines(); + const portableReceipt = JSON.parse( + fs.readFileSync( + portableDemoLifecycleInternals.receiptPath(openclawSandbox, portableStateDir), + "utf-8", + ), + ) as { + containerId: string; + runtimeAuthority: { socketPath: string }; + sandboxName: string; + schemaVersion: number; }; - const beforeStop = vi.fn(); - const initial = inspectContainer(agentEngines.sandboxLifecycle, sandboxName); + expect(portableReceipt).toMatchObject({ + containerId: exactContainerId(runtimeEngines.sandboxLifecycle, openclawSandbox), + sandboxName: openclawSandbox, + runtimeAuthority: { socketPath: SOCKET_PATH }, + schemaVersion: 4, + }); - expect(lifecycle.stop(input, { beforeStop })).toEqual({ exitCode: 0, state: "stopped" }); - expect(beforeStop).toHaveBeenCalledExactlyOnceWith(); - const stopped = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); - expect(stopped.State).toMatchObject({ Paused: false, Running: false, Status: "exited" }); + progress.phase("exercise exact-container stop and start"); + for (const { agent, sandboxName } of AGENTS) { + const agentEngines = engines(); + const agentBundle = createPodmanRuntimeProviderBundle({ engines: agentEngines }); + const lifecycle = supportedLifecycle(agentBundle); + const sandbox: SandboxEntry = { agent, name: sandboxName, openshellDriver: "podman" }; + const input: RuntimeProviderLifecycleInput = { + environment: process.env, + log: vi.fn(), + sandbox, + sandboxName, + }; + const beforeStop = vi.fn(); + const initial = inspectContainer(agentEngines.sandboxLifecycle, sandboxName); - expect(agentBundle.preflightDoctor.preflightLifecycle("start", input)).toBeNull(); - expect(lifecycle.start(input)).toEqual({ exitCode: 0 }); - await lifecycle.verifyStarted( - input, - vi.fn(async () => undefined), - ); - const running = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); - expect(running.State).toMatchObject({ Paused: false, Running: true, Status: "running" }); + expect(lifecycle.stop(input, { beforeStop })).toEqual({ exitCode: 0, state: "stopped" }); + expect(beforeStop).toHaveBeenCalledExactlyOnceWith(); + const stopped = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); + expect(stopped.State).toMatchObject({ Paused: false, Running: false, Status: "exited" }); - expect(lifecycle.stop(input, { beforeStop: vi.fn() })).toEqual({ - exitCode: 0, - state: "stopped", - }); - expect(lifecycle.start(input)).toEqual({ exitCode: 0 }); - const restarted = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); - expect(restarted.State).toMatchObject({ Paused: false, Running: true, Status: "running" }); - expect(lifecycle.stop(input, { beforeStop: vi.fn() })).toEqual({ - exitCode: 0, - state: "stopped", + expect(agentBundle.preflightDoctor.preflightLifecycle("start", input)).toBeNull(); + expect(lifecycle.start(input)).toEqual({ exitCode: 0 }); + await lifecycle.verifyStarted( + input, + vi.fn(async () => undefined), + ); + const running = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); + expect(running.State).toMatchObject({ Paused: false, Running: true, Status: "running" }); + + expect(lifecycle.stop(input, { beforeStop: vi.fn() })).toEqual({ + exitCode: 0, + state: "stopped", + }); + expect(lifecycle.start(input)).toEqual({ exitCode: 0 }); + const restarted = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); + expect(restarted.State).toMatchObject({ Paused: false, Running: true, Status: "running" }); + expect(lifecycle.stop(input, { beforeStop: vi.fn() })).toEqual({ + exitCode: 0, + state: "stopped", + }); + const final = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); + expect(final.State).toMatchObject({ Paused: false, Running: false, Status: "exited" }); + } + progress.phase("record successful final at-rest state"); + completed = true; + } finally { + await cleanupPodmanLifecycle({ + cliEnv, + completed, + createdSandboxes, + engine: runtimeEngines.sandboxLifecycle, + gateway, + openshellBin, + previousPortableProfile, + root, + shellProbe, }); - const final = inspectContainer(agentEngines.sandboxLifecycle, sandboxName, initial.Id); - expect(final.State).toMatchObject({ Paused: false, Running: false, Status: "exited" }); } - progress.phase("verify production portable ownership and final at-rest state"); - completed = true; - } finally { - await cleanupPodmanLifecycle({ - cliEnv, - completed, - createdSandboxes, - engine: runtimeEngines.sandboxLifecycle, - gateway, - openshellBin, - previousPortableProfile, - root, - shellProbe, - }); - } -}); + }, +); diff --git a/test/e2e/registry/native-runtime-qualification.ts b/test/e2e/registry/native-runtime-qualification.ts new file mode 100644 index 00000000000..aa248baed0e --- /dev/null +++ b/test/e2e/registry/native-runtime-qualification.ts @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const NATIVE_RUNTIME_QUALIFICATION_AGENTS = [ + "openclaw", + "hermes", + "langchain-deepagents-code", +] as const; +export const NATIVE_RUNTIME_QUALIFICATION_ARCHITECTURES = ["amd64", "arm64"] as const; +export const NATIVE_RUNTIME_QUALIFICATION_ACCELERATIONS = ["cpu", "nvidia-gpu"] as const; +export const NATIVE_RUNTIME_QUALIFICATION_INFERENCE = { + cpu: ["ollama"], + "nvidia-gpu": ["ollama", "nim", "vllm"], +} as const; + +export type NativeRuntimeQualificationAgent = (typeof NATIVE_RUNTIME_QUALIFICATION_AGENTS)[number]; +export type NativeRuntimeQualificationArchitecture = + (typeof NATIVE_RUNTIME_QUALIFICATION_ARCHITECTURES)[number]; +export type NativeRuntimeQualificationAcceleration = + (typeof NATIVE_RUNTIME_QUALIFICATION_ACCELERATIONS)[number]; +export type NativeRuntimeQualificationInference = "ollama" | "nim" | "vllm"; +export type NativeRuntimeQualificationObligation = + | "installer.install" + | "runtime.docker-unavailable" + | "agent.onboard" + | "agent.turn" + | "sandbox.stop-start" + | "sandbox.snapshot-restore" + | "sandbox.rebuild" + | "runtime.restart-reconcile" + | "cleanup.exact"; +export type NativeRuntimeQualificationEvidenceKind = + | "protected-run" + | "source-identity" + | "installer-result" + | "docker-unavailable-guard" + | "managed-images" + | "agent-turn" + | "local-inference" + | "lifecycle" + | "recovery" + | "cleanup" + | "nvidia-cdi"; + +export const NATIVE_RUNTIME_QUALIFICATION_OBLIGATIONS = [ + "installer.install", + "runtime.docker-unavailable", + "agent.onboard", + "agent.turn", + "sandbox.stop-start", + "sandbox.snapshot-restore", + "sandbox.rebuild", + "runtime.restart-reconcile", + "cleanup.exact", +] as const satisfies readonly NativeRuntimeQualificationObligation[]; + +const BASE_EVIDENCE_KINDS = [ + "protected-run", + "source-identity", + "installer-result", + "docker-unavailable-guard", + "managed-images", + "agent-turn", + "local-inference", + "lifecycle", + "recovery", + "cleanup", +] as const satisfies readonly NativeRuntimeQualificationEvidenceKind[]; +const REQUIRED_CAPABILITIES = [ + "agent.configure", + "agent.turn", + "evidence.collect", + "sandbox.lifecycle", + "state.observe", + "transport.socket-free", +] as const; +const PROVIDER_ID = /^[a-z][a-z0-9-]{0,62}$/u; +const SOURCE_REVISION = /^[a-f0-9]{40}$/u; + +export interface NativeRuntimeQualificationCase { + readonly id: string; + readonly agent: NativeRuntimeQualificationAgent; + readonly architecture: NativeRuntimeQualificationArchitecture; + readonly acceleration: NativeRuntimeQualificationAcceleration; + readonly inference: NativeRuntimeQualificationInference; + readonly platform: "linux"; + readonly rootMode: "rootless"; + readonly capabilities: readonly string[]; + readonly gate: "protected-e2e"; + readonly install: "release-installer"; + readonly dockerAvailability: "unavailable"; + readonly obligations: readonly NativeRuntimeQualificationObligation[]; + readonly evidenceKinds: readonly NativeRuntimeQualificationEvidenceKind[]; +} + +export interface NativeRuntimeQualificationDefinition { + readonly schemaVersion: 1; + readonly id: string; + readonly repository: "NVIDIA/NemoClaw"; + readonly providerId: string; + readonly executionPath: "runtime-provider-bundle"; + readonly cases: readonly NativeRuntimeQualificationCase[]; +} + +export interface NativeRuntimeCandidateEvidence { + readonly schemaVersion: 1; + readonly claim: "candidate-execution-prerequisites"; + readonly candidateId: "podman-cpu-lifecycle"; + readonly providerId: string; + readonly sourceRevision: string; + readonly executionPath: "runtime-provider-bundle"; + readonly architecture: "amd64"; + readonly acceleration: "cpu"; + readonly agents: readonly NativeRuntimeQualificationAgent[]; + readonly socketFree: true; + readonly dockerUnavailable: { + readonly service: true; + readonly socket: true; + readonly daemon: true; + readonly invocationGuard: true; + }; +} + +export interface NativeRuntimeCandidateAuthority { + readonly schemaVersion: 1; + readonly candidateId: "podman-cpu-lifecycle"; + readonly providerId: string; + readonly sourceRevision: string; + readonly executionPath: "runtime-provider-bundle"; +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function exactSet(actual: readonly T[], expected: readonly T[], label: string) { + const actualSet = new Set(actual); + const missing = expected.filter((value) => !actualSet.has(value)); + const unknown = actual.filter((value) => !expected.includes(value)); + if (actualSet.size !== actual.length || missing.length > 0 || unknown.length > 0) { + throw new Error( + `${label} is incomplete (missing: ${missing.join(", ") || "none"}; unknown: ${unknown.join(", ") || "none"})`, + ); + } +} + +export function requiredNativeRuntimeQualificationEvidenceKinds( + acceleration: NativeRuntimeQualificationAcceleration, +): readonly NativeRuntimeQualificationEvidenceKind[] { + return acceleration === "nvidia-gpu" + ? Object.freeze([...BASE_EVIDENCE_KINDS, "nvidia-cdi"]) + : BASE_EVIDENCE_KINDS; +} + +export function nativeRuntimeQualificationCaseId(input: { + readonly providerId: string; + readonly agent: NativeRuntimeQualificationAgent; + readonly architecture: NativeRuntimeQualificationArchitecture; + readonly acceleration: NativeRuntimeQualificationAcceleration; + readonly inference: NativeRuntimeQualificationInference; +}): string { + const acceleration = input.acceleration === "nvidia-gpu" ? "gpu" : input.acceleration; + return [ + input.providerId, + input.agent, + "linux", + input.architecture, + acceleration, + input.inference, + ].join("-"); +} + +function coverageKey( + value: Pick< + NativeRuntimeQualificationCase, + "agent" | "architecture" | "acceleration" | "inference" + >, +): string { + return [value.agent, value.architecture, value.acceleration, value.inference].join("|"); +} + +function requiredCoverageKeys(): readonly string[] { + return NATIVE_RUNTIME_QUALIFICATION_AGENTS.flatMap((agent) => + NATIVE_RUNTIME_QUALIFICATION_ARCHITECTURES.flatMap((architecture) => + NATIVE_RUNTIME_QUALIFICATION_ACCELERATIONS.flatMap((acceleration) => + NATIVE_RUNTIME_QUALIFICATION_INFERENCE[acceleration].map((inference) => + coverageKey({ agent, architecture, acceleration, inference }), + ), + ), + ), + ).sort(compareCodeUnits); +} + +export function compileNativeRuntimeQualification( + definition: NativeRuntimeQualificationDefinition, +): NativeRuntimeQualificationDefinition { + if ( + definition.schemaVersion !== 1 || + !PROVIDER_ID.test(definition.providerId) || + definition.id !== `${definition.providerId}-protected-host-local-inference` || + definition.repository !== "NVIDIA/NemoClaw" || + definition.executionPath !== "runtime-provider-bundle" + ) { + throw new Error("Native runtime qualification identity is invalid"); + } + const cases = definition.cases.map((entry) => { + const expectedId = nativeRuntimeQualificationCaseId({ + providerId: definition.providerId, + agent: entry.agent, + architecture: entry.architecture, + acceleration: entry.acceleration, + inference: entry.inference, + }); + const inference = NATIVE_RUNTIME_QUALIFICATION_INFERENCE[entry.acceleration]; + const capabilities = new Set(entry.capabilities); + if ( + entry.id !== expectedId || + entry.platform !== "linux" || + entry.rootMode !== "rootless" || + entry.gate !== "protected-e2e" || + entry.install !== "release-installer" || + entry.dockerAvailability !== "unavailable" || + !(inference as readonly string[]).includes(entry.inference) || + REQUIRED_CAPABILITIES.some((value) => !capabilities.has(value)) || + capabilities.has("transport.docker-socket") + ) { + throw new Error(`Native runtime qualification case '${entry.id}' is invalid`); + } + exactSet( + entry.obligations, + NATIVE_RUNTIME_QUALIFICATION_OBLIGATIONS, + `Native runtime qualification case '${entry.id}' obligations`, + ); + exactSet( + entry.evidenceKinds, + requiredNativeRuntimeQualificationEvidenceKinds(entry.acceleration), + `Native runtime qualification case '${entry.id}' evidence kinds`, + ); + return Object.freeze({ + ...entry, + capabilities: Object.freeze([...entry.capabilities]), + obligations: Object.freeze([...entry.obligations]), + evidenceKinds: Object.freeze([...entry.evidenceKinds]), + }); + }); + const coverage = new Map(cases.map((entry) => [coverageKey(entry), entry])); + const required = requiredCoverageKeys(); + const missing = required.filter((key) => !coverage.has(key)); + if (coverage.size !== cases.length || coverage.size !== required.length || missing.length > 0) { + throw new Error( + `Native runtime qualification coverage is incomplete (missing: ${missing.join(", ") || "none"})`, + ); + } + return Object.freeze({ + ...definition, + cases: Object.freeze([...cases].sort((left, right) => compareCodeUnits(left.id, right.id))), + }); +} + +export function nativeRuntimeQualificationDefinition( + providerId: string, +): NativeRuntimeQualificationDefinition { + const capabilities = [...REQUIRED_CAPABILITIES]; + return { + schemaVersion: 1, + id: `${providerId}-protected-host-local-inference`, + repository: "NVIDIA/NemoClaw", + providerId, + executionPath: "runtime-provider-bundle", + cases: NATIVE_RUNTIME_QUALIFICATION_AGENTS.flatMap((agent) => + NATIVE_RUNTIME_QUALIFICATION_ARCHITECTURES.flatMap((architecture) => + NATIVE_RUNTIME_QUALIFICATION_ACCELERATIONS.flatMap((acceleration) => + NATIVE_RUNTIME_QUALIFICATION_INFERENCE[acceleration].map((inference) => ({ + id: nativeRuntimeQualificationCaseId({ + providerId, + agent, + architecture, + acceleration, + inference, + }), + agent, + architecture, + acceleration, + inference, + platform: "linux" as const, + rootMode: "rootless" as const, + capabilities, + gate: "protected-e2e" as const, + install: "release-installer" as const, + dockerAvailability: "unavailable" as const, + obligations: NATIVE_RUNTIME_QUALIFICATION_OBLIGATIONS, + evidenceKinds: requiredNativeRuntimeQualificationEvidenceKinds(acceleration), + })), + ), + ), + ), + }; +} + +export const PODMAN_PROTECTED_HOST_LOCAL_INFERENCE_QUALIFICATION = + compileNativeRuntimeQualification(nativeRuntimeQualificationDefinition("podman")); + +/** + * Consume only the current credential-free candidate prerequisites. This does + * not issue protected qualification evidence or activate a runtime provider. + */ +export function consumeNativeRuntimeCandidateEvidence( + value: unknown, + expectedSourceRevision: string, +): NativeRuntimeCandidateAuthority { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Native runtime candidate evidence is incomplete or does not match source"); + } + const candidate = value as Partial; + const shaped = + Array.isArray(candidate.agents) && + candidate.agents.every((agent) => typeof agent === "string") && + typeof candidate.dockerUnavailable === "object" && + candidate.dockerUnavailable !== null && + !Array.isArray(candidate.dockerUnavailable); + if ( + !shaped || + candidate.schemaVersion !== 1 || + candidate.claim !== "candidate-execution-prerequisites" || + candidate.candidateId !== "podman-cpu-lifecycle" || + typeof candidate.providerId !== "string" || + !PROVIDER_ID.test(candidate.providerId) || + candidate.executionPath !== "runtime-provider-bundle" || + candidate.architecture !== "amd64" || + candidate.acceleration !== "cpu" || + candidate.socketFree !== true || + typeof candidate.sourceRevision !== "string" || + !SOURCE_REVISION.test(candidate.sourceRevision) || + candidate.sourceRevision !== expectedSourceRevision || + candidate.dockerUnavailable?.service !== true || + candidate.dockerUnavailable.socket !== true || + candidate.dockerUnavailable.daemon !== true || + candidate.dockerUnavailable.invocationGuard !== true + ) { + throw new Error("Native runtime candidate evidence is incomplete or does not match source"); + } + exactSet( + candidate.agents, + NATIVE_RUNTIME_QUALIFICATION_AGENTS, + "Native runtime candidate agents", + ); + return Object.freeze({ + schemaVersion: 1, + candidateId: candidate.candidateId, + providerId: candidate.providerId, + sourceRevision: candidate.sourceRevision, + executionPath: candidate.executionPath, + }); +} diff --git a/test/e2e/support/native-runtime-qualification.test.ts b/test/e2e/support/native-runtime-qualification.test.ts new file mode 100644 index 00000000000..e391400d8da --- /dev/null +++ b/test/e2e/support/native-runtime-qualification.test.ts @@ -0,0 +1,162 @@ +// 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 { CURRENT_RUNTIME_PROVIDER_BUNDLES } from "../../../src/lib/onboard/runtime-provider/current"; +import { + compileNativeRuntimeQualification, + consumeNativeRuntimeCandidateEvidence, + nativeRuntimeQualificationDefinition, + NATIVE_RUNTIME_QUALIFICATION_AGENTS, + NATIVE_RUNTIME_QUALIFICATION_OBLIGATIONS, + PODMAN_PROTECTED_HOST_LOCAL_INFERENCE_QUALIFICATION, + type NativeRuntimeCandidateEvidence, +} from "../registry/native-runtime-qualification"; + +const SOURCE_REVISION = "a".repeat(40); + +function candidateEvidence(): NativeRuntimeCandidateEvidence { + return { + schemaVersion: 1, + claim: "candidate-execution-prerequisites", + candidateId: "podman-cpu-lifecycle", + providerId: "podman", + sourceRevision: SOURCE_REVISION, + executionPath: "runtime-provider-bundle", + architecture: "amd64", + acceleration: "cpu", + agents: [...NATIVE_RUNTIME_QUALIFICATION_AGENTS], + socketFree: true, + dockerUnavailable: { + service: true, + socket: true, + daemon: true, + invocationGuard: true, + }, + }; +} + +describe("native runtime qualification contract", () => { + it("compiles the complete all-agent, multiarch, CPU/GPU inference matrix", () => { + const qualification = PODMAN_PROTECTED_HOST_LOCAL_INFERENCE_QUALIFICATION; + + expect(qualification.cases).toHaveLength(24); + expect(new Set(qualification.cases.map((entry) => entry.agent))).toEqual( + new Set(["openclaw", "hermes", "langchain-deepagents-code"]), + ); + expect(new Set(qualification.cases.map((entry) => entry.architecture))).toEqual( + new Set(["amd64", "arm64"]), + ); + expect(new Set(qualification.cases.map((entry) => entry.acceleration))).toEqual( + new Set(["cpu", "nvidia-gpu"]), + ); + expect(new Set(qualification.cases.map((entry) => entry.inference))).toEqual( + new Set(["ollama", "nim", "vllm"]), + ); + for (const entry of qualification.cases) { + expect(entry).toMatchObject({ + platform: "linux", + rootMode: "rootless", + gate: "protected-e2e", + install: "release-installer", + dockerAvailability: "unavailable", + obligations: NATIVE_RUNTIME_QUALIFICATION_OBLIGATIONS, + }); + expect(entry.capabilities).toContain("transport.socket-free"); + expect(entry.capabilities).not.toContain("transport.docker-socket"); + expect(entry.evidenceKinds.includes("nvidia-cdi")).toBe(entry.acceleration === "nvidia-gpu"); + } + }); + + it("preserves the provider-neutral socket-free seam without a Podman branch", () => { + const mxc = compileNativeRuntimeQualification( + nativeRuntimeQualificationDefinition("mxc-candidate"), + ); + + expect(mxc.cases).toHaveLength(24); + expect(mxc.cases.every((entry) => entry.id.startsWith("mxc-candidate-"))).toBe(true); + expect(mxc.cases.every((entry) => entry.capabilities.includes("transport.socket-free"))).toBe( + true, + ); + }); + + it("rejects missing coverage and exact evidence obligations", () => { + const missingCase = nativeRuntimeQualificationDefinition("missing-case"); + expect(() => + compileNativeRuntimeQualification({ + ...missingCase, + cases: missingCase.cases.slice(1), + }), + ).toThrow("coverage is incomplete"); + + const missingEvidence = nativeRuntimeQualificationDefinition("missing-evidence"); + const first = missingEvidence.cases[0]!; + expect(() => + compileNativeRuntimeQualification({ + ...missingEvidence, + cases: [ + { + ...first, + evidenceKinds: first.evidenceKinds.filter((value) => value !== "source-identity"), + }, + ...missingEvidence.cases.slice(1), + ], + }), + ).toThrow("evidence kinds is incomplete"); + }); + + it("rejects incomplete candidate evidence before runtime construction", () => { + const constructRuntime = vi.fn(); + const incomplete = { + ...candidateEvidence(), + dockerUnavailable: { ...candidateEvidence().dockerUnavailable, socket: false }, + } as unknown as NativeRuntimeCandidateEvidence; + + expect(() => { + consumeNativeRuntimeCandidateEvidence(incomplete, SOURCE_REVISION); + constructRuntime(); + }).toThrow("candidate evidence is incomplete"); + expect(constructRuntime).not.toHaveBeenCalled(); + }); + + it.each([ + ["duplicate", [...NATIVE_RUNTIME_QUALIFICATION_AGENTS, "openclaw"]], + ["unknown", [...NATIVE_RUNTIME_QUALIFICATION_AGENTS.slice(0, -1), "unknown-agent"]], + ])("rejects %s candidate agents before runtime construction", (_label, agents) => { + const constructRuntime = vi.fn(); + const evidence = { + ...candidateEvidence(), + agents, + } as unknown as NativeRuntimeCandidateEvidence; + + expect(() => { + consumeNativeRuntimeCandidateEvidence(evidence, SOURCE_REVISION); + constructRuntime(); + }).toThrow("Native runtime candidate agents is incomplete"); + expect(constructRuntime).not.toHaveBeenCalled(); + }); + + it.each([ + ["null evidence", null], + ["an invalid agent list", { ...candidateEvidence(), agents: "openclaw" }], + ["invalid Docker evidence", { ...candidateEvidence(), dockerUnavailable: null }], + ])("rejects %s with the candidate-evidence contract error", (_label, evidence) => { + expect(() => consumeNativeRuntimeCandidateEvidence(evidence, SOURCE_REVISION)).toThrow( + "Native runtime candidate evidence is incomplete or does not match source", + ); + }); + + it("accepts only exact-source candidate prerequisites without activating Podman", () => { + expect(consumeNativeRuntimeCandidateEvidence(candidateEvidence(), SOURCE_REVISION)).toEqual({ + schemaVersion: 1, + candidateId: "podman-cpu-lifecycle", + providerId: "podman", + sourceRevision: SOURCE_REVISION, + executionPath: "runtime-provider-bundle", + }); + expect(() => + consumeNativeRuntimeCandidateEvidence(candidateEvidence(), "b".repeat(40)), + ).toThrow("does not match source"); + expect(CURRENT_RUNTIME_PROVIDER_BUNDLES).not.toHaveProperty("podman"); + }); +}); diff --git a/test/e2e/support/podman-cpu-proof-workflow.test.ts b/test/e2e/support/podman-cpu-proof-workflow.test.ts index 0510508458a..13b8a4e1c6b 100644 --- a/test/e2e/support/podman-cpu-proof-workflow.test.ts +++ b/test/e2e/support/podman-cpu-proof-workflow.test.ts @@ -33,8 +33,8 @@ function namedStep(name: string): WorkflowStep { } describe("native Podman CPU proof workflow", () => { - // source-shape-contract: security -- Exact checkout and package pins bind the credential-free Podman proof to the reported PR head and reviewed runtime bytes - it("runs as a credential-free exact-head PR workflow", () => { + // source-shape-contract: security -- Checkout binding and package pins bind the credential-free Podman proof to the commit under review and its runtime bytes + it("runs as a credential-free PR workflow bound to the commit under review", () => { const parsed = workflow(); const job = proofJob(); @@ -46,6 +46,12 @@ describe("native Podman CPU proof workflow", () => { expect(parsed.on.pull_request.paths).toContain( "src/lib/onboard/experimental/portable-demo-lifecycle.ts", ); + expect(parsed.on.pull_request.paths).toContain( + "src/lib/onboard/runtime-provider/container-state-mutation.ts", + ); + expect(parsed.on.pull_request.paths).toContain( + "src/lib/onboard/runtime-provider/docker-state-mutation.ts", + ); expect(parsed.on.pull_request.paths).toContain("scripts/install-openshell.sh"); expect(parsed.on.pull_request.paths).toContain( "test/e2e/live/podman-cpu-lifecycle-artifacts.ts", @@ -54,10 +60,14 @@ describe("native Podman CPU proof workflow", () => { expect(parsed.on.pull_request.paths).toContain( "test/e2e/live/podman-cpu-lifecycle-policy.yaml", ); + expect(parsed.on.pull_request.paths).toContain( + "test/e2e/registry/native-runtime-qualification.ts", + ); expect(job.name).toBe("Rootless Podman CPU lifecycle with Docker disabled"); expect(job["runs-on"]).toBe("ubuntu-26.04"); expect(job["timeout-minutes"]).toBe(30); expect(job.env?.NEMOCLAW_RUN_LIVE_E2E).toBe("1"); + expect(job.env?.E2E_SOURCE_REVISION).toBe("${{ github.event.pull_request.head.sha }}"); expect(job.env?.NEMOCLAW_OPENSHELL_PIN_VERSION).toBe("0.0.101"); expect(job.env?.PODMAN_APT_VERSION).toBe("5.7.0+ds2-3build1"); expect(namedStep("Checkout").with).toMatchObject({ @@ -93,6 +103,9 @@ describe("native Podman CPU proof workflow", () => { expect(disableDocker).toContain("systemctl stop docker.service docker.socket"); expect(disableDocker).toContain("pkill -TERM -x dockerd"); expect(disableDocker).toContain("docker-absence-boundary.json"); + expect(disableDocker).toContain('source_revision="$(git rev-parse HEAD)"'); + expect(disableDocker).toContain('test "$source_revision" = "$E2E_SOURCE_REVISION"'); + expect(disableDocker).toContain("candidate-execution-prerequisites.json"); expect(disableDocker).toContain("Docker socket remained available after Docker shutdown"); const correctPastaPolicy = namedStep("Apply Ubuntu pasta signal policy correction").run ?? ""; expect(correctPastaPolicy).toContain("/etc/apparmor.d/usr.bin.pasta"); @@ -122,6 +135,12 @@ describe("native Podman CPU proof workflow", () => { expect(proof.run).toBe( "npx vitest run --project e2e-live test/e2e/live/podman-cpu-lifecycle.test.ts", ); + const liveSource = readRepoText("test/e2e/live/podman-cpu-lifecycle.test.ts"); + const authorityIndex = liveSource.indexOf("expect(candidateAuthority())"); + const enginesIndex = liveSource.indexOf("let runtimeEngines = engines()"); + expect(authorityIndex).toBeGreaterThanOrEqual(0); + expect(enginesIndex).toBeGreaterThanOrEqual(0); + expect(authorityIndex).toBeLessThan(enginesIndex); expect(scripts).not.toContain("podman create"); expect(scripts).not.toContain("openshell-sandbox-$sandbox_name"); expect(scripts).not.toContain("openshell.sandbox-name"); diff --git a/test/helpers/docker-state-mutation-harness.ts b/test/helpers/docker-state-mutation-harness.ts index 50368146425..c0c15e502d8 100644 --- a/test/helpers/docker-state-mutation-harness.ts +++ b/test/helpers/docker-state-mutation-harness.ts @@ -8,8 +8,15 @@ import path from "node:path"; import { vi } from "vitest"; import type { ContainerEngineCommandCapture } from "../../src/lib/adapters/container-engine"; +import { + createPodmanContainerEngine, + type PodmanExecutableAuthorityDeps, + type PodmanExecutableStat, + type PodmanSocketAuthority, +} from "../../src/lib/adapters/podman"; import { RUNTIME_PROVIDER_STATE_MUTATION_PLAN_SCHEMA_VERSION } from "../../src/lib/onboard/runtime-provider/contract"; import { createDockerOperationAuthority } from "../../src/lib/onboard/runtime-provider/docker-operation-authority"; +import { createContainerStateMutationOwner } from "../../src/lib/onboard/runtime-provider/container-state-mutation"; import { createDockerStateMutationOwner } from "../../src/lib/onboard/runtime-provider/docker-state-mutation"; import { createFilePersistedEngineAuthorityStore } from "../../src/lib/onboard/runtime-provider/persisted-engine-authority"; import { @@ -25,12 +32,50 @@ export const DOCKER_STATE_MUTATION_PROJECTION_SHA256 = "b".repeat(64); export const DOCKER_STATE_MUTATION_STATE_ROOT = "/sandbox/.hermes"; export const DOCKER_STATE_MUTATION_LIFECYCLE_GENERATION = "generation-7"; const SANDBOX_ID = "sandbox-alpha-id"; +const PODMAN_EXECUTABLE_BYTES = Buffer.from("qualified-podman-state-mutation", "utf8"); +const PODMAN_SOCKET_AUTHORITY = { + directoryChain: [], + device: "8", + inode: "9001", + mode: "384", + ownerUid: "1000", + socketPath: "/run/user/1000/podman/podman.sock", +} as const satisfies PodmanSocketAuthority; export const DOCKER_STATE_MUTATION_SANDBOX_FINGERPRINT = createHash("sha256") .update(SANDBOX_ID) .digest("hex"); const roots: string[] = []; +function podmanExecutableAuthorityDeps(): PodmanExecutableAuthorityDeps { + const executable: PodmanExecutableStat = { + dev: 8n, + ino: 42n, + mode: 0o100755n, + uid: 0n, + size: BigInt(PODMAN_EXECUTABLE_BYTES.byteLength), + mtimeNs: 1000n, + ctimeNs: 2000n, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }; + const directory: PodmanExecutableStat = { + ...executable, + ino: 43n, + mode: 0o40755n, + size: 0n, + isDirectory: () => true, + isFile: () => false, + }; + return { + uid: 1000, + lstat: (filePath) => (filePath === "/usr/bin/podman" ? executable : directory), + readFile: () => PODMAN_EXECUTABLE_BYTES, + realpath: (filePath) => filePath, + }; +} + function temporaryRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-state-mutation-")); roots.push(root); @@ -105,7 +150,10 @@ export interface DockerStateMutationHarnessState { overlayProc: boolean; } -export function createDockerStateMutationHarness(options: DockerStateMutationHarnessOptions = {}) { +function createContainerStateMutationHarness( + providerId: "docker" | "podman", + options: DockerStateMutationHarnessOptions = {}, +) { const lifecycleGeneration = options.lifecycleGeneration ?? DOCKER_STATE_MUTATION_LIFECYCLE_GENERATION; const state: DockerStateMutationHarnessState = { @@ -158,7 +206,8 @@ export function createDockerStateMutationHarness(options: DockerStateMutationHar }; const capture = vi.fn((_executable, args, _timeout, input) => { - const command = args.slice(4); + const commandStart = args.findIndex((value) => value === "ps" || value === "container"); + const command = commandStart < 0 ? [] : args.slice(commandStart); if (command[0] === "ps") { return { status: 0, stdout: `${DOCKER_STATE_MUTATION_RUNTIME_ID}\n`, stderr: "" }; } @@ -243,7 +292,7 @@ export function createDockerStateMutationHarness(options: DockerStateMutationHar delete active.listenerIdentity; delete active.healthSha256; delete active.activationProviderHandle; - const expected = `docker-state-mutation-v1:${String(marker.transactionId)}:${createHash("sha256").update(JSON.stringify(active), "utf8").digest("hex")}`; + const expected = `${String(active.providerId)}-state-mutation-v1:${String(marker.transactionId)}:${createHash("sha256").update(JSON.stringify(active), "utf8").digest("hex")}`; if (request.providerHandle !== expected) { return { status: 1, stdout: "", stderr: "provider handle mismatch" }; } @@ -275,7 +324,7 @@ export function createDockerStateMutationHarness(options: DockerStateMutationHar configurationGeneration, listenerIdentity, healthSha256, - activationProviderHandle: `docker-state-mutation-activation-v1:${String( + activationProviderHandle: `${String(activeMarker.providerId)}-state-mutation-activation-v1:${String( activeMarker.transactionId, )}:${createHash("sha256").update(JSON.stringify(evidence), "utf8").digest("hex")}`, }; @@ -307,18 +356,38 @@ export function createDockerStateMutationHarness(options: DockerStateMutationHar return { status: 0, stdout: `${JSON.stringify(response)}\n`, stderr: "" }; }); const root = temporaryRoot(); - const authority = createDockerOperationAuthority( - "sandbox-lifecycle", - { - HOME: "/tmp/nemoclaw-home", - DOCKER_CONFIG: "/tmp/nemoclaw-docker", - DOCKER_HOST: "unix:///tmp/nemoclaw-docker.sock", - }, - capture, - ); + const environment = + providerId === "docker" + ? { + HOME: "/tmp/nemoclaw-home", + DOCKER_CONFIG: "/tmp/nemoclaw-docker", + DOCKER_HOST: "unix:///tmp/nemoclaw-docker.sock", + } + : { HOME: "/tmp/nemoclaw-home" }; + const dockerAuthority = + providerId === "docker" + ? createDockerOperationAuthority("sandbox-lifecycle", environment, capture) + : undefined; + const podmanEngine = + providerId === "podman" + ? createPodmanContainerEngine({ + operation: "state-mutation", + socketAuthority: PODMAN_SOCKET_AUTHORITY, + executable: "/usr/bin/podman", + capture, + assertAuthority: vi.fn(), + executableAuthorityDeps: podmanExecutableAuthorityDeps(), + }) + : undefined; + const authority = + dockerAuthority ?? + Object.freeze({ + assertAuthority: podmanEngine!.assertAuthority, + engine: podmanEngine!, + }); const engineAuthorityStore = createFilePersistedEngineAuthorityStore(root); const lifecycleStore = createFilePersistedEngineLifecycleStore(root); - const owner = createDockerStateMutationOwner({ + const ownerOptions = { sandboxName: "alpha", lifecycleGeneration, lifecycleLiveIdentityFingerprint: DOCKER_STATE_MUTATION_SANDBOX_FINGERPRINT, @@ -326,19 +395,24 @@ export function createDockerStateMutationHarness(options: DockerStateMutationHar authority, engineAuthorityStore, lifecycleStore, - }); + }; + const owner = + providerId === "docker" + ? createDockerStateMutationOwner({ ...ownerOptions, authority: dockerAuthority! }) + : createContainerStateMutationOwner({ + ...ownerOptions, + providerId, + providerDisplayName: "Podman", + engineOperation: "state-mutation", + }); const sandbox: SandboxEntry = { name: "alpha", - openshellDriver: "docker", + openshellDriver: providerId, lifecycleGeneration, lifecycleLiveIdentityFingerprint: DOCKER_STATE_MUTATION_SANDBOX_FINGERPRINT, }; const context = { - environment: { - HOME: "/tmp/nemoclaw-home", - DOCKER_CONFIG: "/tmp/nemoclaw-docker", - DOCKER_HOST: "unix:///tmp/nemoclaw-docker.sock", - }, + environment, sandbox, sandboxName: "alpha", }; @@ -369,6 +443,14 @@ export function createDockerStateMutationHarness(options: DockerStateMutationHar }; } +export function createDockerStateMutationHarness(options: DockerStateMutationHarnessOptions = {}) { + return createContainerStateMutationHarness("docker", options); +} + +export function createPodmanStateMutationHarness(options: DockerStateMutationHarnessOptions = {}) { + return createContainerStateMutationHarness("podman", options); +} + export function createAmbiguousRuntimeCapture( runtime: ReturnType, ): ReturnType> { diff --git a/test/runtime-provider-source-shape.test.ts b/test/runtime-provider-source-shape.test.ts index 46c092a97ca..c568d585339 100644 --- a/test/runtime-provider-source-shape.test.ts +++ b/test/runtime-provider-source-shape.test.ts @@ -165,6 +165,7 @@ describe("runtime provider central source boundary", () => { it("inventories every runtime-provider implementation", () => { expect(providerPaths).toEqual([ "src/lib/onboard/runtime-provider/access.ts", + "src/lib/onboard/runtime-provider/container-state-mutation.ts", "src/lib/onboard/runtime-provider/contract.ts", "src/lib/onboard/runtime-provider/current.ts", "src/lib/onboard/runtime-provider/docker-llama-cpp-managed-lifecycle.ts", @@ -186,6 +187,7 @@ describe("runtime provider central source boundary", () => { "src/lib/onboard/runtime-provider/podman-inference-args.ts", "src/lib/onboard/runtime-provider/podman-lifecycle.ts", "src/lib/onboard/runtime-provider/podman-preflight.ts", + "src/lib/onboard/runtime-provider/podman-state-mutation.ts", "src/lib/onboard/runtime-provider/podman.ts", "src/lib/onboard/runtime-provider/registry.ts", "src/lib/onboard/runtime-provider/snapshot.ts", diff --git a/test/runtime-state-mutation-control.test.ts b/test/runtime-state-mutation-control.test.ts index 0458f341edf..6fdfc2d5aec 100644 --- a/test/runtime-state-mutation-control.test.ts +++ b/test/runtime-state-mutation-control.test.ts @@ -102,13 +102,13 @@ def plan(projection, intent="protection-transition"): "projectionSha256": projection, } -def acquire_value(nonce="d" * 64, selected_plan=None, lifecycle_generation="generation:7"): +def acquire_value(nonce="d" * 64, selected_plan=None, lifecycle_generation="generation:7", provider_id="docker"): projection = "b" * 64 selected = plan(projection) if selected_plan is None else selected_plan serialized = control._json_bytes(selected).decode() request = control.AcquireRequest( "0" * 64, - "docker", + provider_id, "alpha", lifecycle_generation, "3" * 64, @@ -131,7 +131,7 @@ def acquire_value(nonce="d" * 64, selected_plan=None, lifecycle_generation="gene "schemaVersion": 1, "action": "acquire", "transactionId": transaction, - "providerId": "docker", + "providerId": provider_id, "sandboxName": "alpha", "lifecycleGeneration": lifecycle_generation, "engineBindingSha256": "3" * 64, @@ -154,7 +154,7 @@ def status_value(action, acquire, provider_handle=None, activation_handle=None, "schemaVersion": 1, "action": action, "transactionId": acquire["transactionId"], - "providerId": "docker", + "providerId": acquire["providerId"], "sandboxName": "alpha", "lifecycleGeneration": "generation:7", "engineBindingSha256": "3" * 64, @@ -274,6 +274,16 @@ results["state_transition_preserves_identity"] = ( ) canonical_value = acquire_value() results["canonical"] = parse("acquire", canonical_value).plan_sha256 +podman_value = acquire_value(provider_id="podman") +results["podman_provider"] = parse("acquire", podman_value).provider_id +podman_handle = "podman-state-mutation-v1:" + podman_value["transactionId"] + ":" + "f" * 64 +results["podman_handle"] = parse( + "assert", status_value("assert", podman_value, podman_handle) +).provider_handle +docker_handle = "docker-state-mutation-v1:" + podman_value["transactionId"] + ":" + "f" * 64 +results["cross_provider_handle"] = code( + lambda: parse("assert", status_value("assert", podman_value, docker_handle)) +) results["noncanonical"] = code( lambda: control._parse_request( "acquire", @@ -1257,6 +1267,9 @@ describe("runtime state mutation controller", () => { unsorted_selectors: "plan-selector-order", plus_generation: "generation+7", punctuation_generation: "lifecycle-generation", + podman_provider: "podman", + podman_handle: expect.stringMatching(/^podman-state-mutation-v1:/u), + cross_provider_handle: "provider-handle", }); }); diff --git a/test/runtime-state-mutation-hermes-publisher.test.ts b/test/runtime-state-mutation-hermes-publisher.test.ts index 61cb74201d3..3bdf3ba0232 100644 --- a/test/runtime-state-mutation-hermes-publisher.test.ts +++ b/test/runtime-state-mutation-hermes-publisher.test.ts @@ -36,7 +36,7 @@ installed_plan = {key: value for key, value in installed_value.items() if key != def canonical(value): return json.dumps(value, ensure_ascii=False, separators=(",", ":")) -def marker(nonce="d" * 64, selectors=None): +def marker(nonce="d" * 64, selectors=None, provider_id="docker"): expected = [ *["path:" + value for value in (".config-hash", ".env", "config.yaml")], *["path:" + value for value in installed_plan["readOnlyRoots"]], @@ -66,7 +66,7 @@ def marker(nonce="d" * 64, selectors=None): "schemaVersion": 1, "phase": "fenced", "transactionId": "a" * 64, - "providerId": "docker", + "providerId": provider_id, "stateRoot": "/sandbox/.hermes", "plan": plan_text, "planSha256": hashlib.sha256(plan_text.encode()).hexdigest(), @@ -161,6 +161,44 @@ with tempfile.TemporaryDirectory() as temporary: lambda: publisher.apply_plan_posture(extra, "locked") ) +with tempfile.TemporaryDirectory() as temporary: + durable = os.path.join(temporary, "durable") + os.mkdir(durable, 0o711) + plan_path = os.path.join(temporary, "state-lock-plan.json") + shutil.copyfile(sys.argv[2], plan_path) + os.chmod(plan_path, 0o444) + publisher.DURABLE_DIRECTORY = durable + publisher.STATE_LOCK_PLAN_PATH = plan_path + publisher._verify_final_posture = lambda posture, plan_json: "4" * 64 + podman_token = "3" * 64 + + def podman_guard(action, arguments): + state_path = os.path.join(durable, publisher.GUARD_STATE_NAME) + if action == "begin-shields-transition": + posture = arguments[arguments.index("--shields-mode") + 1] + rollback = arguments[arguments.index("--rollback-shields-mode") + 1] + state = { + "version": 1, + "phase": "shields-transition-pending", + "mutation_lock_token": podman_token, + "mutation_lock_path": os.path.join(durable, "hermes-config-mutation.lock"), + "hermes_dir": "/sandbox/.hermes", + "hash_file": "/etc/nemoclaw/hermes.config-hash", + "shields_transition": {"mode": posture, "rollback_mode": rollback}, + } + with open(state_path, "w", encoding="utf-8") as stream: + json.dump(state, stream, separators=(",", ":")) + os.chmod(state_path, 0o600) + return f"lock_token={podman_token} original_locked=0\n" + if action == "finish-shields-transition": + os.unlink(state_path) + return "ok\n" + + publisher._run_guard = podman_guard + results["podman_public"] = publisher.apply_plan_posture( + marker(nonce="4" * 64, provider_id="podman"), "locked" + ) + with tempfile.TemporaryDirectory() as temporary: durable = os.path.join(temporary, "durable") os.mkdir(durable, 0o711) @@ -296,6 +334,11 @@ describe("Hermes runtime state mutation publisher", () => { posture: "locked", nonce: "d".repeat(64), }); + expect(result.podman_public).toMatchObject({ + protocol: "nemoclaw-runtime-state-mutation-publisher-v1", + posture: "locked", + nonce: "4".repeat(64), + }); expect(result.retry).toMatchObject({ posture: "locked" }); expect(result.rollback).toMatchObject({ posture: "mutable" }); const expectedStatePlan = JSON.parse(fs.readFileSync(STATE_PLAN, "utf8")) as Record<