diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index 79881fac983..e18f40b2941 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -22,6 +22,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index bb00edb7698..df105f49eec 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -23,6 +23,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/agents/langchain-deepagents-code/policy-additions.yaml b/agents/langchain-deepagents-code/policy-additions.yaml index 10fb346dc7b..0c84378b9fb 100644 --- a/agents/langchain-deepagents-code/policy-additions.yaml +++ b/agents/langchain-deepagents-code/policy-additions.yaml @@ -19,6 +19,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml index 60efda4f372..9f33213ee32 100644 --- a/agents/openclaw/policy-permissive.yaml +++ b/agents/openclaw/policy-permissive.yaml @@ -19,6 +19,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml index 35f6c480f73..15070a51cff 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml @@ -24,6 +24,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 1d509768db9..7458486c77a 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -27,6 +27,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index 092d1644c92..8fe93885544 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -8,7 +8,16 @@ import { afterEach, describe, expect, it } from "vitest"; import YAML from "yaml"; import { SHIPPED_MANAGED_IMAGE_AGENTS } from "./managed-image/contract"; -import { MANAGED_STARTUP_MERGED_CA_FILE } from "./managed-startup/image-runtime"; +import { + MANAGED_STARTUP_COMPLETION_FILE, + MANAGED_STARTUP_MERGED_CA_FILE, + MANAGED_STARTUP_RUNTIME_ENV_FILE, +} from "./managed-startup/image-runtime"; +import { + MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY, + MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY, + MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY, +} from "./managed-startup/shared-state-transaction"; import { prepareInitialSandboxCreatePolicy } from "./initial-policy"; type PolicyRule = { @@ -99,8 +108,23 @@ describe("initial sandbox policy real preset merge", () => { const shippingPolicyCases = managedImagePolicyCases.filter( ({ agent }) => agent !== "langchain-deepagents-code", ); - - it("covers the complete shipped managed startup CA policy matrix", () => { + const managedStartupReadOnlyPaths = [ + { path: MANAGED_STARTUP_MERGED_CA_FILE, issue: "#9360", purpose: "CA bundle" }, + { + path: MANAGED_STARTUP_RUNTIME_ENV_FILE, + issue: "#9357", + purpose: "runtime environment", + }, + ] as const; + const protectedManagedStartupPaths = [ + MANAGED_STARTUP_COMPLETION_FILE, + "/run/nemoclaw/openclaw-config-guard", + MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY, + MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY, + MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY, + ] as const; + + it("covers the complete shipped managed startup trust policy matrix", () => { const policyIdentities = managedImagePolicyCases.map( ({ path: policyPath, agent }) => `${agent}:${policyPath.join("/")}`, ); @@ -108,11 +132,19 @@ describe("initial sandbox policy real preset merge", () => { expect(Object.keys(managedImagePolicyPathsByAgent)).toEqual([...SHIPPED_MANAGED_IMAGE_AGENTS]); expect(policyIdentities).toHaveLength(6); expect(new Set(policyIdentities).size).toBe(policyIdentities.length); + expect(managedStartupReadOnlyPaths.map(({ path: trustedPath }) => trustedPath)).toEqual([ + MANAGED_STARTUP_MERGED_CA_FILE, + MANAGED_STARTUP_RUNTIME_ENV_FILE, + ]); }); - it.each(managedImagePolicyCases)( - "grants $agent policy $path exact read-only access to the managed startup CA bundle (#9360)", - (policyCase) => { + it.each( + managedImagePolicyCases.flatMap((policyCase) => + managedStartupReadOnlyPaths.map((trustedPath) => ({ policyCase, trustedPath })), + ), + )( + "grants $policyCase.agent policy $policyCase.path exact read-only access to the managed startup $trustedPath.purpose ($trustedPath.issue)", + ({ policyCase, trustedPath }) => { const prepared = prepareInitialSandboxCreatePolicy(repoPath(...policyCase.path), [], { agentName: policyCase.agent, }); @@ -121,23 +153,48 @@ describe("initial sandbox policy real preset merge", () => { const readWrite = policy.filesystem_policy?.read_write ?? []; const normalizedReadOnly = readOnly.map(normalizeFilesystemPolicyPath); const normalizedReadWrite = readWrite.map(normalizeFilesystemPolicyPath); - const managedCaAncestors = filesystemPolicyAncestors(MANAGED_STARTUP_MERGED_CA_FILE); + const trustedPathAncestors = filesystemPolicyAncestors(trustedPath.path); - expect(readOnly, policyCase.path.join("/")).toContain(MANAGED_STARTUP_MERGED_CA_FILE); - expect(normalizedReadWrite, policyCase.path.join("/")).not.toContain( - MANAGED_STARTUP_MERGED_CA_FILE, - ); + expect(readOnly, policyCase.path.join("/")).toContain(trustedPath.path); + expect(normalizedReadWrite, policyCase.path.join("/")).not.toContain(trustedPath.path); expect( - normalizedReadOnly.filter((candidate) => managedCaAncestors.includes(candidate)), + normalizedReadOnly.filter((candidate) => trustedPathAncestors.includes(candidate)), policyCase.path.join("/"), ).toEqual([]); expect( - normalizedReadWrite.filter((candidate) => managedCaAncestors.includes(candidate)), + normalizedReadWrite.filter((candidate) => trustedPathAncestors.includes(candidate)), policyCase.path.join("/"), ).toEqual([]); }, ); + it.each( + managedImagePolicyCases.flatMap((policyCase) => + protectedManagedStartupPaths.map((protectedPath) => ({ policyCase, protectedPath })), + ), + )( + "keeps $protectedPath inaccessible in $policyCase.agent policy $policyCase.path (#9357)", + ({ policyCase, protectedPath }) => { + const prepared = prepareInitialSandboxCreatePolicy(repoPath(...policyCase.path), [], { + agentName: policyCase.agent, + }); + const policy = readPreparedPolicy(prepared); + const grantedPaths = [ + ...(policy.filesystem_policy?.read_only ?? []), + ...(policy.filesystem_policy?.read_write ?? []), + ].map(normalizeFilesystemPolicyPath); + const exposingGrants = new Set([ + ...filesystemPolicyAncestors(protectedPath), + normalizeFilesystemPolicyPath(protectedPath), + ]); + + expect( + grantedPaths.filter((candidate) => exposingGrants.has(candidate)), + `${policyCase.path.join("/")} exposes ${protectedPath}`, + ).toEqual([]); + }, + ); + it.each([ { path: ["nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"], diff --git a/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts b/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts index 1dccdd5b4ac..579ada5ee39 100644 --- a/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts @@ -84,6 +84,31 @@ describe("managed startup image runtime handoff and descriptor integrity", () => owned(realLstatSync(file, options))) as typeof fs.lstatSync); } + function mockRuntimeDescriptorOwnership( + runtimeEnvironmentFile: string, + uid: bigint, + gid: bigint, + ): void { + const realFstatSync = fs.fstatSync.bind(fs); + const runtimeInode = fs.lstatSync(runtimeEnvironmentFile, { bigint: true }).ino; + vi.spyOn(fs, "fstatSync").mockImplementation(((descriptor: number, options: { bigint: true }) => { + const stat = realFstatSync(descriptor, options); + const isRuntimeDescriptor = stat.ino === runtimeInode; + const ownership = new Map([ + ["uid", isRuntimeDescriptor ? uid : 0n], + ["gid", isRuntimeDescriptor ? gid : 0n], + ]); + return new Proxy(stat, { + get(inner, property) { + const value = ownership.has(property) + ? ownership.get(property) + : (Reflect.get(inner, property, inner) as unknown); + return typeof value === "function" ? value.bind(inner) : value; + }, + }); + }) as typeof fs.fstatSync); + } + function writeCompletionFixture( profile: ManagedStartupProfile, corporateCaMerged = false, @@ -189,11 +214,16 @@ describe("managed startup image runtime handoff and descriptor integrity", () => ).toThrow(/completion marker does not match the requested profile/u); }); - it("rejects runtime handoff drift after a matching completion", () => { + it("rejects a replaced runtime handoff after a matching completion", () => { const fixture = writeCompletionFixture(managedStartupE2eProfile("hermes")); mockDescriptorOwnership(0n, 0n); - fs.chmodSync(fixture.runtimeEnvironmentFile, 0o644); - fs.appendFileSync(fixture.runtimeEnvironmentFile, "export NEMOCLAW_MODEL='tampered/model'\n"); + const originalRuntimeEnvironment = fs.readFileSync(fixture.runtimeEnvironmentFile, "utf8"); + fs.renameSync(fixture.runtimeEnvironmentFile, `${fixture.runtimeEnvironmentFile}.original`); + fs.writeFileSync( + fixture.runtimeEnvironmentFile, + `${originalRuntimeEnvironment}export NEMOCLAW_MODEL='tampered/model'\n`, + { mode: 0o444 }, + ); fs.chmodSync(fixture.runtimeEnvironmentFile, 0o444); expect(() => @@ -206,6 +236,67 @@ describe("managed startup image runtime handoff and descriptor integrity", () => ).toThrow(/runtime environment digest mismatch/u); }); + it("fails closed when the runtime handoff is missing", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("openclaw")); + mockDescriptorOwnership(0n, 0n); + fs.unlinkSync(fixture.runtimeEnvironmentFile); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); + + it("fails closed when the runtime handoff is symlinked", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("openclaw")); + mockDescriptorOwnership(0n, 0n); + const replacement = `${fixture.runtimeEnvironmentFile}.replacement`; + fs.renameSync(fixture.runtimeEnvironmentFile, replacement); + fs.symlinkSync(replacement, fixture.runtimeEnvironmentFile); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(/refusing unsafe or unreadable file/u); + }); + + it("fails closed when the runtime handoff mode is not 0444", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("hermes")); + mockDescriptorOwnership(0n, 0n); + fs.chmodSync(fixture.runtimeEnvironmentFile, 0o640); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(/runtime environment must be root:root mode 0444/u); + }); + + it("fails closed when the runtime handoff is not root owned", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("langchain-deepagents-code")); + mockRuntimeDescriptorOwnership(fixture.runtimeEnvironmentFile, 501n, 20n); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(/runtime environment must be root:root mode 0444/u); + }); + it("accepts merged CA paths without putting the CA payload in the readable handoff", () => { const fixture = writeCompletionFixture( managedStartupE2eProfile("langchain-deepagents-code", false, true), diff --git a/test/e2e/fixtures/corporate-ca.ts b/test/e2e/fixtures/corporate-ca.ts index 87d1dc52fd0..02a12cf7c87 100644 --- a/test/e2e/fixtures/corporate-ca.ts +++ b/test/e2e/fixtures/corporate-ca.ts @@ -5,9 +5,17 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { isObjectRecord } from "../../../src/lib/core/json-types.ts"; +import { GATEWAY_PORT } from "../../../src/lib/core/ports.ts"; +import { readManagedWorkloadAuthority } from "../../../src/lib/onboard/workload/authority.ts"; +import { readConfigFile } from "../../../src/lib/state/config-io.ts"; +import { parseSandboxRegistryEntries } from "../../../src/lib/state/registry-normalization.ts"; +import { cloneSandboxWorkloadReceipt } from "../../../src/lib/state/registry/workload.ts"; +import { nemoclawStateRoot } from "../../../src/lib/state/state-root.ts"; import { trustedSandboxShellScript, type TrustedSandboxShellScript } from "./clients/sandbox.ts"; export type CorporateCaFixtureMode = "explicit" | "requests" | "host-anchor"; +export type CorporateCaWorkloadKind = "legacy-dockerfile" | "managed-image"; export interface CorporateCaFixture { dir: string; @@ -61,7 +69,10 @@ const CORPORATE_CA_ENV_BY_MODE: Record< "host-anchor": (_file, dir) => ({ NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS: dir }), }; -const CORPORATE_CA_MERGE_PROBE = trustedSandboxShellScript(` +function buildCorporateCaMergeProbe( + workloadKind: CorporateCaWorkloadKind, +): TrustedSandboxShellScript { + return trustedSandboxShellScript(` set -eu probe_fail() { printf 'CORPORATE_CA_PROBE_FAIL:%s\\n' "$1" >&2 @@ -79,16 +90,18 @@ expect_export() { } corp='/usr/local/share/nemoclaw/corporate-ca.pem' -managed_completion='/run/nemoclaw/managed-startup-complete.json' -if [ -e "$managed_completion" ] || [ -L "$managed_completion" ]; then - [ -f "$managed_completion" ] && [ ! -L "$managed_completion" ] || probe_fail invalid-managed-completion +workload_kind='${workloadKind}' +if [ "$workload_kind" = 'managed-image' ]; then bundle='/run/nemoclaw/managed-startup-ca-bundle.pem' runtime_env='/run/nemoclaw/managed-startup-runtime.env' + system_bundle='/etc/ssl/certs/ca-certificates.crt' expected_bundle_metadata='0:0:444' + expected_runtime_env_metadata='0:0:444' else bundle='/tmp/nemoclaw-ca-bundle.pem' runtime_env='/tmp/nemoclaw-proxy-env.sh' expected_bundle_metadata="$(id -u):$(id -g):444" + expected_runtime_env_metadata="$(id -u):$(id -g):444" fi [ -s "$corp" ] || probe_fail missing-corporate-ca @@ -96,20 +109,35 @@ fi [ -s "$runtime_env" ] || probe_fail missing-runtime-env [ ! -L "$bundle" ] || probe_fail symlinked-merged-bundle [ "$(stat -c '%u:%g:%a' "$bundle")" = "$expected_bundle_metadata" ] || probe_fail merged-bundle-owner-mode +[ ! -L "$runtime_env" ] || probe_fail symlinked-runtime-env +[ "$(stat -c '%u:%g:%a' "$runtime_env")" = "$expected_runtime_env_metadata" ] || probe_fail runtime-env-owner-mode grep -F '${CORPORATE_CA_CANARY_LINE}' "$corp" >/dev/null || probe_fail corporate-canary-missing grep -F '${CORPORATE_CA_CANARY_LINE}' "$bundle" >/dev/null || probe_fail bundle-canary-missing +if [ "$workload_kind" = 'managed-image' ]; then + grep -F '${CORPORATE_CA_CANARY_LINE}' "$system_bundle" >/dev/null || probe_fail system-bundle-canary-missing +fi set -- $(wc -c < "$corp") corp_bytes="$1" set -- $(wc -c < "$bundle") bundle_bytes="$1" [ "$bundle_bytes" -gt "$corp_bytes" ] || probe_fail bundle-did-not-preserve-base -for env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do - expect_export "$env_name" -done +if [ "$workload_kind" = 'managed-image' ]; then + grep -F "export _NEMOCLAW_CORPORATE_CA_MERGED='1'" "$runtime_env" >/dev/null || probe_fail managed-runtime-env-marker + for env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do + if grep -E "^(export|unset) $env_name(=|$)" "$runtime_env" >/dev/null; then + probe_fail "managed-runtime-env-$env_name" + fi + done +else + for env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do + expect_export "$env_name" + done +fi printf 'corporate CA baked and merged into %s (%s > %s bytes)\\n' "$bundle" "$bundle_bytes" "$corp_bytes" `); +} export function createCorporateCaFixture( mode: CorporateCaFixtureMode, @@ -132,6 +160,28 @@ export function cleanupCorporateCaFixture(fixture: CorporateCaFixture): void { fs.rmSync(fixture.dir, { recursive: true, force: true }); } -export function corporateCaMergeProbeScript(): TrustedSandboxShellScript { - return CORPORATE_CA_MERGE_PROBE; +export function registeredCorporateCaWorkloadKind( + sandboxName: string, + home: string = os.homedir(), + gatewayPort: number = GATEWAY_PORT, +): CorporateCaWorkloadKind { + const registryPath = path.join(nemoclawStateRoot(home, gatewayPort), "sandboxes.json"); + const registry = readConfigFile(registryPath, { sandboxes: {} }); + const sandboxes = isObjectRecord(registry) ? registry.sandboxes : undefined; + const entry = parseSandboxRegistryEntries(sandboxes).find(([name]) => name === sandboxName)?.[1]; + if (!entry) { + throw new Error(`corporate CA probe sandbox '${sandboxName}' is missing from the registry`); + } + if (readManagedWorkloadAuthority(entry)) return "managed-image"; + const workload = cloneSandboxWorkloadReceipt(entry.workload); + if (workload?.kind === "legacy-dockerfile") return workload.kind; + throw new Error( + `corporate CA probe sandbox '${sandboxName}' has no supported registered workload authority`, + ); +} + +export function corporateCaMergeProbeScript( + workloadKind: CorporateCaWorkloadKind, +): TrustedSandboxShellScript { + return buildCorporateCaMergeProbe(workloadKind); } diff --git a/test/e2e/live/cloud-onboard.test.ts b/test/e2e/live/cloud-onboard.test.ts index b6c9f3f2460..6ee4136b2b3 100644 --- a/test/e2e/live/cloud-onboard.test.ts +++ b/test/e2e/live/cloud-onboard.test.ts @@ -12,6 +12,7 @@ import { cleanupCorporateCaFixture, corporateCaMergeProbeScript, createCorporateCaFixture, + registeredCorporateCaWorkloadKind, } from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; @@ -241,11 +242,15 @@ test("cloud onboard: public installer creates healthy sandbox with security chec expect(list.exitCode, resultText(list)).toBe(0); expect(list.stdout).toContain(SANDBOX_NAME); - const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { - artifactName: "phase-2-corporate-ca-merge-probe", - env: testEnv(), - timeoutMs: 60_000, - }); + const corporateCaProbe = await sandbox.execShell( + SANDBOX_NAME, + corporateCaMergeProbeScript(registeredCorporateCaWorkloadKind(SANDBOX_NAME, testHome)), + { + artifactName: "phase-2-corporate-ca-merge-probe", + env: testEnv(), + timeoutMs: 60_000, + }, + ); expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); progress.phase("verify compatible endpoint reasoning propagation"); diff --git a/test/e2e/live/onboard-repair.test.ts b/test/e2e/live/onboard-repair.test.ts index 811edde3445..39ef1544de6 100644 --- a/test/e2e/live/onboard-repair.test.ts +++ b/test/e2e/live/onboard-repair.test.ts @@ -16,6 +16,7 @@ import { cleanupCorporateCaFixture, corporateCaMergeProbeScript, createCorporateCaFixture, + registeredCorporateCaWorkloadKind, } from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { readExtraProviders, updateExtraProviders } from "../fixtures/extra-providers-registry.ts"; @@ -359,11 +360,15 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu const status = await nemoclaw(host, [SANDBOX_NAME, "status"], "phase-2-status-after-repair"); expect(status.exitCode, resultText(status)).toBe(0); - const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { - artifactName: "phase-2-corporate-ca-merge-probe", - env: env(), - timeoutMs: 60_000, - }); + const corporateCaProbe = await sandbox.execShell( + SANDBOX_NAME, + corporateCaMergeProbeScript(registeredCorporateCaWorkloadKind(SANDBOX_NAME)), + { + artifactName: "phase-2-corporate-ca-merge-probe", + env: env(), + timeoutMs: 60_000, + }, + ); expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); progress.phase("reseed interrupted onboarding state"); diff --git a/test/e2e/live/onboard-resume.test.ts b/test/e2e/live/onboard-resume.test.ts index 59f0895eb08..e0b87fe3698 100644 --- a/test/e2e/live/onboard-resume.test.ts +++ b/test/e2e/live/onboard-resume.test.ts @@ -14,6 +14,7 @@ import { cleanupCorporateCaFixture, corporateCaMergeProbeScript, createCorporateCaFixture, + registeredCorporateCaWorkloadKind, } from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { @@ -538,11 +539,15 @@ test( }); expect(sandboxStatus.exitCode, sandboxStatus.stderr).toBe(0); - const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { - artifactName: "phase-3-corporate-ca-merge-probe", - env: probeEnv, - timeoutMs: 60_000, - }); + const corporateCaProbe = await sandbox.execShell( + SANDBOX_NAME, + corporateCaMergeProbeScript(registeredCorporateCaWorkloadKind(SANDBOX_NAME)), + { + artifactName: "phase-3-corporate-ca-merge-probe", + env: probeEnv, + timeoutMs: 60_000, + }, + ); expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); // Assertion: session-file-complete-state. diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 21537d87501..f678155d9b9 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -227,9 +227,12 @@ "src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts", "src/lib/onboard/extra-provider-reconciliation-probes.test.ts", "src/lib/onboard/extra-provider-reconciliation.test.ts", + "src/lib/onboard/initial-policy-real-policy.test.ts", "src/lib/onboard/machine/handlers/sandbox-resume.test.ts", + "src/lib/onboard/managed-startup-image-runtime-handoff.test.ts", "src/lib/onboard/sandbox-create-plan.test.ts", "test/onboard-extra-provider-reconciliation.test.ts", + "test/e2e/support/corporate-ca-workload-kind.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] @@ -240,10 +243,13 @@ "src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts", "src/lib/onboard/extra-provider-reconciliation-probes.test.ts", "src/lib/onboard/extra-provider-reconciliation.test.ts", + "src/lib/onboard/initial-policy-real-policy.test.ts", "src/lib/onboard/machine/handlers/sandbox-recreate-resume.test.ts", "src/lib/onboard/machine/handlers/sandbox-resume.test.ts", + "src/lib/onboard/managed-startup-image-runtime-handoff.test.ts", "src/lib/onboard/sandbox-create-plan.test.ts", "test/onboard-extra-provider-reconciliation.test.ts", + "test/e2e/support/corporate-ca-workload-kind.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] @@ -301,6 +307,9 @@ { "live": "test/e2e/live/cloud-onboard.test.ts", "fast": [ + "src/lib/onboard/initial-policy-real-policy.test.ts", + "src/lib/onboard/managed-startup-image-runtime-handoff.test.ts", + "test/e2e/support/corporate-ca-workload-kind.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] diff --git a/test/e2e/support/corporate-ca-workload-kind.test.ts b/test/e2e/support/corporate-ca-workload-kind.test.ts new file mode 100644 index 00000000000..e2c922d890c --- /dev/null +++ b/test/e2e/support/corporate-ca-workload-kind.test.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { managedStartupE2eProfile } from "../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { + MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + MANAGED_IMAGE_REPOSITORIES, + MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, +} from "../../../src/lib/onboard/managed-image/contract.ts"; +import { encodeManagedStartupProfile } from "../../../src/lib/onboard/managed-startup/profile.ts"; +import { nemoclawStateRoot } from "../../../src/lib/state/state-root.ts"; +import { registeredCorporateCaWorkloadKind } from "../fixtures/corporate-ca.ts"; + +const SANDBOX_NAME = "corporate-ca-authority"; +const GATEWAY_PORT = 7443; +const temporaryHomes: string[] = []; + +afterEach(() => { + for (const home of temporaryHomes.splice(0)) { + fs.rmSync(home, { force: true, recursive: true }); + } +}); + +function writeRegistry(entry: Record | null): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corporate-ca-authority-")); + temporaryHomes.push(home); + const stateRoot = nemoclawStateRoot(home, GATEWAY_PORT); + fs.mkdirSync(stateRoot, { recursive: true }); + fs.writeFileSync( + path.join(stateRoot, "sandboxes.json"), + `${JSON.stringify({ sandboxes: entry === null ? {} : { [SANDBOX_NAME]: entry } })}\n`, + "utf8", + ); + return home; +} + +function managedRegistryEntry(): Record { + const encodedProfile = encodeManagedStartupProfile(managedStartupE2eProfile("openclaw")); + const reference = `${MANAGED_IMAGE_REPOSITORIES.openclaw}@sha256:${"a".repeat(64)}`; + return { + name: SANDBOX_NAME, + agent: "openclaw", + fromDockerfile: null, + imageTag: reference, + workload: { + schemaVersion: 1, + kind: "managed-image", + reference, + platform: "linux/amd64", + release: "v0.0.100", + sourceRevision: "d".repeat(40), + sourceCohort: "ghrun-9357-1", + capabilityContractVersion: MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + startupProfileContractVersion: MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, + encodedProfile, + startupProfileSha256: createHash("sha256").update(encodedProfile, "utf8").digest("hex"), + credentialProxyReplayRequired: false, + shared: true, + }, + }; +} + +describe("corporate CA registered workload selection", () => { + it("selects managed assertions only from validated managed workload authority", () => { + const home = writeRegistry(managedRegistryEntry()); + + expect(registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toBe( + "managed-image", + ); + }); + + it("preserves the registered legacy Dockerfile assertion path", () => { + const home = writeRegistry({ + name: SANDBOX_NAME, + agent: null, + fromDockerfile: "/tmp/Dockerfile", + imageTag: "corporate-ca-legacy:local", + workload: { + schemaVersion: 1, + kind: "legacy-dockerfile", + reference: "corporate-ca-legacy:local", + shared: false, + }, + }); + + expect(registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toBe( + "legacy-dockerfile", + ); + }); + + it("fails closed when the sandbox is missing from the selected registry", () => { + const home = writeRegistry(null); + + expect(() => registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toThrow( + /missing from the registry/u, + ); + }); + + it("fails closed on a malformed registered workload receipt", () => { + const home = writeRegistry({ + name: SANDBOX_NAME, + agent: null, + fromDockerfile: "/tmp/Dockerfile", + imageTag: "corporate-ca-legacy:local", + workload: { + schemaVersion: 1, + kind: "legacy-dockerfile", + reference: "corporate-ca-legacy:local", + shared: true, + }, + }); + + expect(() => registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toThrow( + /no supported registered workload authority/u, + ); + }); +});