Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
84d0425
feat(e2e): bind Podman candidate qualification
ericksoa Aug 14, 2026
9e41a62
merge(podman): reconcile qualification with current main
cv Aug 14, 2026
09a8a13
feat(runtime): add Podman state mutation
ericksoa Aug 14, 2026
3ceaad2
Merge remote-tracking branch 'origin/main' into feat/b4-e2-podman-qua…
ericksoa Aug 14, 2026
3199ce4
merge: synchronize main for CI fixes
prekshivyas Aug 15, 2026
0818319
Merge remote-tracking branch 'origin/feat/b4-e2-podman-qualification'…
ericksoa Aug 15, 2026
417e540
test(e2e): reject malformed candidate agent sets
ericksoa Aug 15, 2026
ebfc3fa
Merge remote-tracking branch 'origin/main' into feat/b4-e2-podman-qua…
ericksoa Aug 15, 2026
c72a1b3
test(runtime): keep Podman bundle assertion linear
ericksoa Aug 15, 2026
a2207dc
test(runtime-provider): inventory state mutation modules
prekshivyas Aug 15, 2026
45cca7c
test(runtime): cover Podman state mutation guards
cv Aug 15, 2026
8d27062
merge(runtime): include source inventory update
cv Aug 15, 2026
227c9d8
merge(main): incorporate portable readiness fixes
cv Aug 15, 2026
e827656
merge: resolve conflicts with main
github-actions[bot] Aug 15, 2026
36a400d
merge(runtime): include automated main resolution
cv Aug 15, 2026
8f867be
test(e2e): track merged Podman engine authority
prekshivyas Aug 15, 2026
90789eb
merge(test): include Podman authority assertion
cv Aug 15, 2026
06c9135
test(runtime): isolate Podman state mutation guards
prekshivyas Aug 15, 2026
d6fd64d
ci(podman): cover shared state mutation changes
cv Aug 15, 2026
2b04c01
test(e2e): align Podman proof exception title
prekshivyas Aug 15, 2026
cf822e3
merge: incorporate current main
cv Aug 15, 2026
932a454
merge: incorporate concurrent PR repair
cv Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/podman-cpu-proof.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"

Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
34 changes: 22 additions & 12 deletions scripts/runtime-state-mutation-control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"
)


Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"],
Expand All @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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')))}"
)

Expand All @@ -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))}"
)

Expand Down
10 changes: 8 additions & 2 deletions scripts/runtime_state_mutation_hermes_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/adapters/container-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ContainerEngineOperationScope =
| "gateway-inspection"
| "managed-bootstrap"
| "sandbox-lifecycle"
| "state-mutation"
| "workload-cleanup";

export interface ContainerEngineCommandResult {
Expand Down
29 changes: 29 additions & 0 deletions src/lib/adapters/podman/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 35 additions & 12 deletions src/lib/adapters/podman/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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 {
Expand All @@ -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.`);
},
});
}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/onboard/runtime-provider/container-state-mutation.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions src/lib/onboard/runtime-provider/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type RuntimeProviderContainerEngineOperation =
| "gateway-inspection"
| "host-local-inference"
| "sandbox-lifecycle"
| "state-mutation"
| "workload-cleanup";

export interface RuntimeProviderIdentity {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function ownerThatStopsAfterPrepare(runtime: ReturnType<typeof harness>) {
lifecycleGeneration: runtime.lifecycleGeneration,
lifecycleLiveIdentityFingerprint: SANDBOX_FINGERPRINT,
runtimeId: RUNTIME_ID,
authority: runtime.authority,
authority: runtime.authority as ReturnType<typeof createDockerOperationAuthority>,
engineAuthorityStore: runtime.engineAuthorityStore,
lifecycleStore: {
...runtime.lifecycleStore,
Expand Down
Loading
Loading