Skip to content

Commit c34731c

Browse files
committed
fix(e2e): converge on OpenShell live state
1 parent cc9b11d commit c34731c

14 files changed

Lines changed: 377 additions & 20 deletions

scripts/runtime-state-mutation-control.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2588,7 +2588,11 @@ def _signal_exact_process(process: ProcessIdentity, requested_signal: int) -> No
25882588
if current is None:
25892589
return
25902590
if current.identity_key() != process.identity_key():
2591-
_fail("writer-pid-reused")
2591+
# The pidfd remains bound to the original process, so a numeric PID
2592+
# replacement cannot receive this signal. Treat the old writer as
2593+
# gone; the caller's next complete writer scan will independently
2594+
# discover and handle the replacement identity.
2595+
return
25922596
try:
25932597
signal.pidfd_send_signal(pidfd, requested_signal)
25942598
except ProcessLookupError:

scripts/state-dir-guard.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
{"/sandbox/.openclaw", "/sandbox/.hermes", "/sandbox/.deepagents"}
4848
)
4949
OPENCLAW_CONFIG_DIR = "/sandbox/.openclaw"
50-
OPENCLAW_NATIVE_MUTABLE_ROOT = "devices"
50+
OPENCLAW_NATIVE_MUTABLE_ROOTS = ("devices", "identity")
5151
OPENCLAW_MUTATION_MUTEX_PATH = "/run/nemoclaw/openclaw-config-mutation.lock"
5252
MAX_TRANSITION_LOCK_BYTES = 16 * 1024
5353
# Keep this exact source/target contract aligned with
@@ -699,9 +699,9 @@ def is_private_writable_root(self, relative_path: str) -> bool:
699699
)
700700

701701
def is_openclaw_native_mutable_path(self, relative_path: str) -> bool:
702-
return self.config_path == OPENCLAW_CONFIG_DIR and (
703-
relative_path == OPENCLAW_NATIVE_MUTABLE_ROOT
704-
or relative_path.startswith(f"{OPENCLAW_NATIVE_MUTABLE_ROOT}/")
702+
return self.config_path == OPENCLAW_CONFIG_DIR and any(
703+
relative_path == root or relative_path.startswith(f"{root}/")
704+
for root in OPENCLAW_NATIVE_MUTABLE_ROOTS
705705
)
706706

707707
def is_under_writable_root(self, relative_path: str) -> bool:

src/lib/onboard/experimental/hermes-portable-onboarding-policy-source.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ function policyRequirementsInput(
110110
sandboxName: boundary.sandboxName,
111111
gatewayName: boundary.gatewayName,
112112
gatewayPort: boundary.gatewayPort,
113+
lifecycleLiveIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint,
113114
policySourcePath: boundary.policySourcePath,
114115
operation: "continue composed Hermes Portable onboarding",
115116
};
@@ -210,6 +211,7 @@ network_policies:
210211
sandboxName: "alpha",
211212
gatewayName: "nemoclaw",
212213
gatewayPort: GATEWAY_PORT,
214+
lifecycleLiveIdentityFingerprint: HERMES_PORTABLE_TEST_LIVE_IDENTITY,
213215
policySourcePath: effectivePolicySourcePath,
214216
operation: "verify composed Hermes Portable policy",
215217
});

src/lib/onboard/managed-workload/onboard-orchestration.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function createFreshOnboardingRuntime(
5151
options: {
5252
readonly stockManagedRuntime?: boolean;
5353
readonly tempManagedRuntime?: boolean;
54+
readonly tempManagedRuntimeCatalog?: string | null;
5455
readonly unavailableCatalog?: boolean;
5556
} = {},
5657
) {
@@ -73,7 +74,7 @@ function createFreshOnboardingRuntime(
7374
managedWorkloadRebuild: null,
7475
tempManagedRuntime: options.tempManagedRuntime ?? false,
7576
stockManagedRuntime: options.stockManagedRuntime ?? false,
76-
tempManagedRuntimeCatalog: null,
77+
tempManagedRuntimeCatalog: options.tempManagedRuntimeCatalog ?? null,
7778
agentName: "openclaw",
7879
legacyDockerfilePath: "agents/openclaw/Dockerfile",
7980
customDockerfilePath: null,
@@ -211,6 +212,26 @@ describe("managed workload onboard orchestration", () => {
211212
await expect(runtime.ensurePreparedWorkload()).rejects.toThrow("registry offline");
212213
});
213214

215+
it("treats an explicit temporary catalog as strict managed-image selection", async () => {
216+
const { prepared, runtime } = createFreshOnboardingRuntime(
217+
{},
218+
{ tempManagedRuntimeCatalog: "/tmp/pi-candidate-catalog.json" },
219+
);
220+
221+
await expect(runtime.ensurePreparedWorkload()).resolves.toBe(prepared);
222+
expect(prepareSandboxWorkloadSource).toHaveBeenCalledExactlyOnceWith(
223+
expect.objectContaining({
224+
catalogPath: "/tmp/pi-candidate-catalog.json",
225+
runtime: expect.objectContaining({
226+
driverName: "docker",
227+
managedImages: expect.objectContaining({
228+
exactDigestReferences: true,
229+
}),
230+
}),
231+
}),
232+
);
233+
});
234+
214235
it("selects only the shipped Hermes Dockerfile fallback without profile or prebuild work", async () => {
215236
const expectedDockerfilePath = "/workspace/agents/hermes/Dockerfile";
216237
const ensurePreparedProfile = vi.fn(() => null);

src/lib/onboard/managed-workload/onboard-orchestration.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,10 @@ export function createManagedWorkloadOnboardRuntime(
227227
const discoveredRuntimeCapabilities = resolveSandboxWorkloadRuntimeCapabilities(
228228
input.computePlan,
229229
);
230-
const strictManagedRuntime = input.tempManagedRuntime || input.managedWorkloadRebuild !== null;
230+
const strictManagedRuntime =
231+
input.tempManagedRuntime ||
232+
input.tempManagedRuntimeCatalog !== null ||
233+
input.managedWorkloadRebuild !== null;
231234
const runtimeCapabilities =
232235
strictManagedRuntime || input.stockManagedRuntime
233236
? discoveredRuntimeCapabilities
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
const state = vi.hoisted(() => ({
7+
assertGateway: vi.fn(),
8+
inspectPolicy: vi.fn(),
9+
inspectReadiness: vi.fn(),
10+
}));
11+
12+
vi.mock("../../adapters/openshell/policy-state", async (importOriginal) => ({
13+
...(await importOriginal<typeof import("../../adapters/openshell/policy-state")>()),
14+
assertOpenShellGatewayPortBinding: state.assertGateway,
15+
inspectOpenShellSandboxPolicyReadiness: state.inspectReadiness,
16+
inspectSandboxPolicy: state.inspectPolicy,
17+
}));
18+
19+
import { verifyLiveCreatedSandboxPolicyRequirements } from "./live-policy-requirements";
20+
21+
const IDENTITY = "a".repeat(64);
22+
const REQUIRED_POLICY = `
23+
version: 1
24+
network_policies:
25+
required:
26+
name: required
27+
endpoints:
28+
- host: example.com
29+
port: 443
30+
`;
31+
32+
function inspection(activeVersion: number) {
33+
return {
34+
policySource: "sandbox" as const,
35+
effectivePolicy: {
36+
version: 1,
37+
network_policies: {
38+
required: {
39+
name: "required",
40+
endpoints: [{ host: "example.com", port: 443 }],
41+
},
42+
},
43+
},
44+
policyIdentity: { hash: `hash-${String(activeVersion)}`, activeVersion },
45+
};
46+
}
47+
48+
function verify(sleep = vi.fn()) {
49+
verifyLiveCreatedSandboxPolicyRequirements(
50+
{
51+
sandboxName: "alpha",
52+
gatewayName: "nemoclaw",
53+
gatewayPort: 8080,
54+
lifecycleLiveIdentityFingerprint: IDENTITY,
55+
policySourcePath: "/tmp/required-policy.yaml",
56+
operation: "continue onboarding",
57+
},
58+
{ readFile: () => REQUIRED_POLICY, sleep },
59+
);
60+
return sleep;
61+
}
62+
63+
describe("live created sandbox policy requirements", () => {
64+
beforeEach(() => {
65+
vi.clearAllMocks();
66+
state.inspectPolicy.mockReturnValue(inspection(7));
67+
});
68+
69+
it("waits for OpenShell's exact live policy version without recording ownership", () => {
70+
state.inspectReadiness
71+
.mockReturnValueOnce({ state: "transient", reason: "policy-version-pending" })
72+
.mockReturnValueOnce({ state: "ready" });
73+
74+
const sleep = verify();
75+
76+
expect(sleep).toHaveBeenCalledExactlyOnceWith(1_000);
77+
expect(state.inspectReadiness).toHaveBeenCalledTimes(2);
78+
expect(state.inspectReadiness).toHaveBeenLastCalledWith({
79+
sandboxName: "alpha",
80+
gatewayName: "nemoclaw",
81+
sandboxIdentityFingerprint: IDENTITY,
82+
policyVersion: 7,
83+
});
84+
expect(state.inspectPolicy).toHaveBeenCalledTimes(3);
85+
});
86+
87+
it("fails after the bounded OpenShell convergence window", () => {
88+
state.inspectReadiness.mockReturnValue({
89+
state: "transient",
90+
reason: "sandbox-not-ready",
91+
});
92+
const sleep = vi.fn();
93+
94+
expect(() => verify(sleep)).toThrow(
95+
"Refusing to continue onboarding: the exact sandbox is not Ready.",
96+
);
97+
expect(state.inspectReadiness).toHaveBeenCalledTimes(5);
98+
expect(sleep).toHaveBeenCalledTimes(4);
99+
});
100+
});

src/lib/onboard/sandbox-create/live-policy-requirements.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,32 @@ import fs from "node:fs";
55

66
import {
77
assertOpenShellGatewayPortBinding,
8+
inspectOpenShellSandboxPolicyReadiness,
89
inspectSandboxPolicy,
910
PolicyObservationError,
1011
} from "../../adapters/openshell/policy-state";
1112
import { assertPolicyRequirementContainment, parseOpenShellPolicy } from "../../policy/merge";
1213

14+
const POLICY_READINESS_MAX_OBSERVATIONS = 5;
15+
const POLICY_READINESS_POLL_INTERVAL_MS = 1_000;
16+
17+
function sleepForPolicyConvergence(milliseconds: number): void {
18+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
19+
}
20+
1321
export interface LiveCreatedSandboxPolicyRequirementsInput {
1422
readonly sandboxName: string;
1523
readonly gatewayName: string;
1624
readonly gatewayPort: number;
25+
readonly lifecycleLiveIdentityFingerprint: string;
1726
readonly policySourcePath: string;
1827
}
1928

2029
export interface LiveCreatedSandboxPolicyRequirementsDeps {
21-
readonly readFile?: typeof fs.readFileSync;
30+
readonly readFile?: (path: string, encoding: "utf8") => string;
31+
readonly inspectPolicy?: typeof inspectSandboxPolicy;
32+
readonly inspectPolicyReadiness?: typeof inspectOpenShellSandboxPolicyReadiness;
33+
readonly sleep?: (milliseconds: number) => void;
2234
}
2335

2436
export interface LiveCreatedSandboxPolicyRequirementsCheck extends LiveCreatedSandboxPolicyRequirementsInput {
@@ -34,10 +46,6 @@ export function verifyLiveCreatedSandboxPolicyRequirements(
3446
gatewayName: input.gatewayName,
3547
gatewayPort: input.gatewayPort,
3648
});
37-
const inspection = inspectSandboxPolicy({
38-
sandboxName: input.sandboxName,
39-
gatewayName: input.gatewayName,
40-
});
4149
let requiredPolicy: ReturnType<typeof parseOpenShellPolicy>["policy"];
4250
try {
4351
requiredPolicy = parseOpenShellPolicy(
@@ -48,10 +56,61 @@ export function verifyLiveCreatedSandboxPolicyRequirements(
4856
`Refusing to ${input.operation}: the required sandbox policy could not be read.`,
4957
);
5058
}
51-
try {
52-
assertPolicyRequirementContainment(inspection, requiredPolicy);
53-
} catch (error) {
54-
const detail = error instanceof Error ? error.message : String(error);
55-
throw new PolicyObservationError(`Refusing to ${input.operation}: ${detail}.`);
59+
const inspectPolicy = deps.inspectPolicy ?? inspectSandboxPolicy;
60+
const inspectPolicyReadiness =
61+
deps.inspectPolicyReadiness ?? inspectOpenShellSandboxPolicyReadiness;
62+
let lastFailure = "the exact sandbox policy did not converge";
63+
let ready = false;
64+
for (let attempt = 0; attempt < POLICY_READINESS_MAX_OBSERVATIONS; attempt += 1) {
65+
ready = (() => {
66+
const before = inspectPolicy({
67+
sandboxName: input.sandboxName,
68+
gatewayName: input.gatewayName,
69+
});
70+
try {
71+
assertPolicyRequirementContainment(before, requiredPolicy);
72+
} catch (error) {
73+
lastFailure = error instanceof Error ? error.message : String(error);
74+
return false;
75+
}
76+
const readiness = inspectPolicyReadiness({
77+
sandboxName: input.sandboxName,
78+
gatewayName: input.gatewayName,
79+
sandboxIdentityFingerprint: input.lifecycleLiveIdentityFingerprint,
80+
policyVersion: before.policyIdentity.activeVersion,
81+
});
82+
if (readiness.state !== "ready") {
83+
lastFailure =
84+
readiness.reason === "sandbox-not-ready"
85+
? "the exact sandbox is not Ready"
86+
: "the observed policy version is not active";
87+
return false;
88+
}
89+
const after = inspectPolicy({
90+
sandboxName: input.sandboxName,
91+
gatewayName: input.gatewayName,
92+
});
93+
if (
94+
after.policyIdentity.hash !== before.policyIdentity.hash ||
95+
after.policyIdentity.activeVersion !== before.policyIdentity.activeVersion
96+
) {
97+
lastFailure = "the live OpenShell policy changed during verification";
98+
return false;
99+
}
100+
try {
101+
assertPolicyRequirementContainment(after, requiredPolicy);
102+
return true;
103+
} catch (error) {
104+
lastFailure = error instanceof Error ? error.message : String(error);
105+
return false;
106+
}
107+
})();
108+
if (ready) break;
109+
if (attempt + 1 < POLICY_READINESS_MAX_OBSERVATIONS) {
110+
(deps.sleep ?? sleepForPolicyConvergence)(POLICY_READINESS_POLL_INTERVAL_MS);
111+
}
112+
}
113+
if (!ready) {
114+
throw new PolicyObservationError(`Refusing to ${input.operation}: ${lastFailure}.`);
56115
}
57116
}

src/lib/onboard/sandbox-create/orchestration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2217,6 +2217,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche
22172217
sandboxName,
22182218
gatewayName: GATEWAY_NAME,
22192219
gatewayPort: GATEWAY_PORT,
2220+
lifecycleLiveIdentityFingerprint: checkpoint.sandboxIdentityFingerprint,
22202221
policySourcePath,
22212222
operation: `resume sandbox creation for '${sandboxName}'`,
22222223
});
@@ -2247,6 +2248,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche
22472248
sandboxName,
22482249
gatewayName: GATEWAY_NAME,
22492250
gatewayPort: GATEWAY_PORT,
2251+
lifecycleLiveIdentityFingerprint: boundary.lifecycleLiveIdentityFingerprint,
22502252
policySourcePath: boundary.policySourcePath,
22512253
operation,
22522254
});

src/lib/onboard/sandbox-gpu-create-run-attempt.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -732,10 +732,12 @@ export function createSandboxGpuCreateAttemptRunner(
732732
: resolveOpenShellSandboxId(input.sandboxName, deps.runCaptureOpenshell);
733733
} catch (error) {
734734
if (createAttemptNonce) persistIdentitySettlementRecovery();
735+
const diagnostic =
736+
error instanceof Error ? ` ${error.message}` : " Identity settlement failed.";
735737
throw new Error(
736738
createFailure?.kind === "sandbox_create_incomplete"
737-
? "Managed bootstrap incomplete create did not return one exact durable sandbox identity after Ready."
738-
: "Managed bootstrap create did not return one exact durable sandbox identity after Ready.",
739+
? `Managed bootstrap incomplete create did not return one exact durable sandbox identity after Ready.${diagnostic}`
740+
: `Managed bootstrap create did not return one exact durable sandbox identity after Ready.${diagnostic}`,
739741
{ cause: error },
740742
);
741743
}

0 commit comments

Comments
 (0)