diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 80eaf4a895e..744c0b21386 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -39,7 +39,8 @@ "src/lib/actions/inference-set.ts": 32, "src/lib/actions/sandbox/connect.ts": 38, "src/lib/actions/sandbox/destroy.ts": 29, - "src/lib/actions/sandbox/doctor.ts": 29, + "src/lib/actions/sandbox/doctor.ts": 30, + "src/lib/actions/sandbox/status-snapshot.ts": 21, "src/lib/actions/sandbox/policy-channel.ts": 29, "src/lib/actions/sandbox/process-recovery.ts": 21, "src/lib/actions/sandbox/rebuild-pipeline.ts": 28, diff --git a/package.json b/package.json index 6de43ffcf79..6bc5cbde7f8 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,8 @@ "nemoclaw-blueprint/", "managed-inference/", "schemas/network-policy.schema.json", + "schemas/cua-lifecycle.schema.json", + "schemas/cua-target-manifest.schema.json", "schemas/sandbox-policy.schema.json", "scripts/", "docs/resources/local-credential-form.html", diff --git a/schemas/cua-lifecycle.schema.json b/schemas/cua-lifecycle.schema.json new file mode 100644 index 00000000000..814134c4482 --- /dev/null +++ b/schemas/cua-lifecycle.schema.json @@ -0,0 +1,192 @@ +{ + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/NVIDIA/NemoClaw/schemas/cua-lifecycle.schema.json", + "title": "NemoClaw CUA candidate lifecycle contract", + "description": "Credential-free public readiness for the candidate-only CUA install slice.", + "$ref": "#/$defs/runtimeReadiness", + "$defs": { + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "safeId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]*$" + }, + "safeModel": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:/[A-Za-z0-9][A-Za-z0-9._-]{0,127}){0,7}$" + }, + "component": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version", "digest", "owner"], + "properties": { + "name": { "$ref": "#/$defs/safeId" }, + "version": { "$ref": "#/$defs/safeId" }, + "digest": { "$ref": "#/$defs/digest" }, + "owner": { "$ref": "#/$defs/safeId" } + } + }, + "candidateQualification": { + "type": "object", + "additionalProperties": false, + "required": ["state", "environmentDigest", "bundleReceiptDigest"], + "properties": { + "state": { "const": "candidate" }, + "environmentDigest": { "$ref": "#/$defs/digest" }, + "bundleReceiptDigest": { "$ref": "#/$defs/digest" } + } + }, + "runtimeReadiness": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "agent", + "mode", + "status", + "sourceRevision", + "sourceClean", + "runtimeManifestDigest", + "providerAuthorityDigest", + "qualification", + "components", + "inference", + "appliedPolicy", + "commands", + "limits", + "requiredCapabilities", + "targetOperations", + "securityOperations", + "taskOperations" + ], + "properties": { + "schemaVersion": { "const": "1.0.0" }, + "kind": { "const": "runtime-readiness" }, + "agent": { "const": "nemocua" }, + "mode": { "const": "standalone" }, + "status": { "enum": ["candidate", "unavailable", "incompatible"] }, + "sourceRevision": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "sourceClean": { "const": true }, + "runtimeManifestDigest": { "$ref": "#/$defs/digest" }, + "providerAuthorityDigest": { "$ref": "#/$defs/digest" }, + "qualification": { + "oneOf": [ + { "$ref": "#/$defs/candidateQualification" }, + { "type": "null" } + ] + }, + "components": { + "type": "object", + "additionalProperties": false, + "required": [ + "openshell", + "runtime", + "sandboxImage", + "targetAdapter", + "policy", + "taskProtocol", + "securityVerifier" + ], + "properties": { + "openshell": { "$ref": "#/$defs/component" }, + "runtime": { "$ref": "#/$defs/component" }, + "sandboxImage": { "$ref": "#/$defs/component" }, + "targetAdapter": { "$ref": "#/$defs/component" }, + "policy": { "$ref": "#/$defs/component" }, + "taskProtocol": { "$ref": "#/$defs/component" }, + "securityVerifier": { "$ref": "#/$defs/component" } + } + }, + "inference": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "model", "routeDigest"], + "properties": { + "provider": { "$ref": "#/$defs/safeId" }, + "model": { "$ref": "#/$defs/safeModel" }, + "routeDigest": { "$ref": "#/$defs/digest" } + } + }, + "appliedPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["revision", "digest"], + "properties": { + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "digest": { "$ref": "#/$defs/digest" } + } + }, + "commands": { + "type": "object", + "additionalProperties": false, + "required": ["interactive", "headless", "version", "smoke"], + "properties": { + "interactive": { "const": true }, + "headless": { "const": true }, + "version": { "const": true }, + "smoke": { "const": true } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": ["targetsPerWorker", "activeTasksPerTarget"], + "properties": { + "targetsPerWorker": { "const": 1 }, + "activeTasksPerTarget": { "const": 1 } + } + }, + "requiredCapabilities": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "uniqueItems": true, + "items": { "enum": ["browser", "computer", "terminal"] } + }, + "targetOperations": { + "type": "array", + "maxItems": 0 + }, + "securityOperations": { + "type": "array", + "maxItems": 0 + }, + "taskOperations": { + "type": "array", + "maxItems": 0 + } + }, + "oneOf": [ + { + "required": ["status", "qualification"], + "properties": { + "status": { "const": "candidate" }, + "qualification": { "$ref": "#/$defs/candidateQualification" } + } + }, + { + "required": ["status", "qualification"], + "properties": { + "status": { "enum": ["unavailable", "incompatible"] }, + "qualification": { "type": "null" } + } + } + ] + } + } +} diff --git a/schemas/cua-target-manifest.schema.json b/schemas/cua-target-manifest.schema.json new file mode 100644 index 00000000000..1a045a7bd14 --- /dev/null +++ b/schemas/cua-target-manifest.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/NVIDIA/NemoClaw/schemas/cua-target-manifest.schema.json", + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0", + "title": "NemoClaw CUA target manifest", + "description": "Secret-free immutable identities required before a host-side adapter may attach a disposable desktop target.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "identityDigest", + "platform", + "image", + "serviceBundle", + "capabilities" + ], + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + }, + "kind": { + "const": "target-manifest" + }, + "identityDigest": { + "$ref": "#/$defs/digest" + }, + "platform": { + "$ref": "#/$defs/safeSelector" + }, + "image": { + "$ref": "#/$defs/componentIdentity" + }, + "serviceBundle": { + "$ref": "#/$defs/componentIdentity" + }, + "capabilities": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "$ref": "#/$defs/capabilityIdentity" + } + } + }, + "$defs": { + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "safeId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "safeSelector": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$" + }, + "componentIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version", + "digest", + "owner" + ], + "properties": { + "name": { + "$ref": "#/$defs/safeId" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "digest": { + "$ref": "#/$defs/digest" + }, + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "capabilityIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "protocolVersion" + ], + "properties": { + "id": { + "enum": [ + "browser", + "computer", + "terminal" + ] + }, + "protocolVersion": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + } +} diff --git a/src/lib/actions/inference-get.ts b/src/lib/actions/inference-get.ts index 4e824d16431..8aef58c06c7 100644 --- a/src/lib/actions/inference-get.ts +++ b/src/lib/actions/inference-get.ts @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { captureOpenshell } from "../adapters/openshell/runtime"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { sanitizeRouteValueForDisplay } from "../inference/config"; -import { getLiveGatewayInference } from "../inference/live"; +import { captureOpenshell, getLiveGatewayInference } from "../inference/live"; export interface InferenceGetOptions { json?: boolean; diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 72fa18fb767..d0e3482524c 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -127,6 +127,7 @@ export interface InferenceSetDeps extends InferenceGatewayRestartDeps { getSandbox: (name: string) => SandboxEntry | null; listSandboxes: () => { sandboxes: SandboxEntry[]; defaultSandbox: string | null }; updateSandbox: (name: string, updates: Partial) => boolean; + updateSandboxInferenceRoute?: (name: string, updates: Partial) => boolean; getRequestedAgent: () => string | null | undefined; loadSession: () => onboardSession.Session | null; updateSession: ( @@ -243,6 +244,7 @@ function defaultDeps(): InferenceSetDeps { getSandbox: registry.getSandbox, listSandboxes: registry.listSandboxes, updateSandbox: registry.updateSandbox, + updateSandboxInferenceRoute: registry.updateSandboxInferenceRoute, getRequestedAgent: () => process.env.NEMOCLAW_AGENT, loadSession: onboardSession.loadSession, updateSession: onboardSession.updateSession, @@ -1130,7 +1132,7 @@ async function runInferenceSetWithoutHostLock( nimContainer: registryMetadata.nimContainer ?? null, }); if ( - !deps.updateSandbox( + !(deps.updateSandboxInferenceRoute ?? deps.updateSandbox)( sandboxName, registryFields( resolveAgentInferenceApi( @@ -1164,7 +1166,12 @@ async function runInferenceSetWithoutHostLock( // Refresh the registry with config-derived API-family metadata before the // crash-prone in-sandbox sync (#3725/#3726). Explicit operator-supplied // metadata remains authoritative when present. - if (!deps.updateSandbox(sandboxName, registryFields(preferredInferenceApi))) { + if ( + !(deps.updateSandboxInferenceRoute ?? deps.updateSandbox)( + sandboxName, + registryFields(preferredInferenceApi), + ) + ) { throw new InferenceSetError( `Failed to update NemoClaw registry for sandbox '${sandboxName}'.`, ); diff --git a/src/lib/actions/sandbox/agent/passthrough.test.ts b/src/lib/actions/sandbox/agent/passthrough.test.ts index 5996e0f0ba0..7cb88d4c3a2 100644 --- a/src/lib/actions/sandbox/agent/passthrough.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough.test.ts @@ -90,6 +90,33 @@ function createPluginApi(): OpenClawPluginApi { }; } +type AsyncTestLock = (name: string, operation: () => Promise | T) => Promise; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function createSerialTestLock(events: string[], label: string): AsyncTestLock { + let tail = Promise.resolve(); + return async (_name: string, operation: () => Promise | T): Promise => { + const previous = tail; + const release = deferred(); + tail = previous.then(() => release.promise); + await previous; + events.push(`${label}:acquired`); + try { + return await operation(); + } finally { + events.push(`${label}:released`); + release.resolve(); + } + }; +} + describe("runAgentPassthrough", () => { beforeEach(() => { vi.clearAllMocks(); @@ -123,6 +150,94 @@ describe("runAgentPassthrough", () => { expect(writes.join("")).toMatch(/port 8642/); }); + it("holds CUA mutation authority through the exact headless child execution (#7755)", async () => { + const entry = { name: "alpha", agent: "nemocua" }; + getSandboxMock.mockReturnValueOnce(entry as never).mockReturnValueOnce(entry as never); + listAgentsMock.mockReturnValueOnce([ + "custom-terminal", + "hermes", + "langchain-deepagents-code", + "nemocua", + "openclaw", + ]); + loadAgentMock.mockReturnValueOnce({ + name: "nemocua", + runtime: { + kind: "terminal", + interactive_command: "nemocua interactive", + headless_command: "nemocua headless", + }, + }); + const events: string[] = []; + const childStarted = deferred(); + const releaseChild = deferred(); + const withSandboxMutationLock = createSerialTestLock(events, "sandbox"); + const withGatewayRouteMutationLock = createSerialTestLock(events, "gateway"); + const requireCuaReadiness = vi.fn(() => events.push("readiness")); + execMock.mockImplementationOnce(async () => { + events.push("child"); + childStarted.resolve(); + await releaseChild.promise; + }); + + const passthrough = runAgentPassthrough( + "alpha", + {}, + { + requireCuaReadiness, + resolveSandboxGatewayName: () => "gateway-alpha", + withGatewayRouteMutationLock, + withSandboxMutationLock, + }, + ); + await childStarted.promise; + const mutation = withSandboxMutationLock("alpha", () => + withGatewayRouteMutationLock("gateway-alpha", () => events.push("mutation")), + ); + await Promise.resolve(); + + expect(requireCuaReadiness).toHaveBeenCalledWith(entry); + expect(execMock).toHaveBeenCalledWith("alpha", ["nemocua", "headless"], { tty: false }); + expect(events).toEqual(["sandbox:acquired", "gateway:acquired", "readiness", "child"]); + + releaseChild.resolve(); + await passthrough; + await mutation; + + expect(events).toEqual([ + "sandbox:acquired", + "gateway:acquired", + "readiness", + "child", + "gateway:released", + "sandbox:released", + "sandbox:acquired", + "gateway:acquired", + "mutation", + "gateway:released", + "sandbox:released", + ]); + }); + + it("rejects added NemoCUA arguments before readiness probes or execution (#7755)", async () => { + getSandboxMock.mockReturnValueOnce({ name: "alpha", agent: "nemocua" } as never); + const requireCuaReadiness = vi.fn(); + const { writes, proc } = makeProcMock(); + + await expect( + runAgentPassthrough( + "alpha", + { extraArgs: ["--help"] }, + { process: proc, requireCuaReadiness }, + ), + ).rejects.toThrow("__exit:2"); + + expect(writes.join("")).toContain("does not accept additional arguments"); + expect(requireCuaReadiness).not.toHaveBeenCalled(); + expect(ensureLiveMock).not.toHaveBeenCalled(); + expect(execMock).not.toHaveBeenCalled(); + }); + it("forwards extraArgs verbatim to `openclaw agent` for OpenClaw sandboxes with --no-tty enforced", async () => { const execNonJson = vi.fn(((): never => { throw new Error("__exit:0"); diff --git a/src/lib/actions/sandbox/agent/passthrough.ts b/src/lib/actions/sandbox/agent/passthrough.ts index b0fad34ef3a..dc7c4288511 100644 --- a/src/lib/actions/sandbox/agent/passthrough.ts +++ b/src/lib/actions/sandbox/agent/passthrough.ts @@ -105,8 +105,12 @@ import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:ch import { type AgentDefinition, isTerminalAgent, listAgents, loadAgent } from "../../../agent/defs"; import { CLI_NAME } from "../../../cli/branding"; +import { requireCuaLifecycleReadiness } from "../../../cua/lifecycle-readiness"; +import { resolveSandboxGatewayName } from "../../../gateway-runtime-action"; +import { withGatewayRouteMutationLock } from "../../../inference/gateway-route-mutation-lock"; import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit"; import { parseSandboxPhase } from "../../../state/gateway"; +import { withMcpLifecycleLock as withSandboxMutationLock } from "../../../state/mcp-lifecycle-lock-acquisition"; import * as registry from "../../../state/registry"; import { buildOpenshellExecArgs, @@ -117,8 +121,8 @@ import { import { ensureLiveSandboxOrExit } from "../gateway-state"; import { hasAgentPassthroughHelpToken, printAgentPassthroughHelp } from "./passthrough-help"; import { - defaultGetOpenshellBinary, type AgentJsonPassthroughProcess, + defaultGetOpenshellBinary, runAgentJsonPassthrough, } from "./passthrough-json"; import { OLLAMA_LOCAL_PROVIDER, runOllamaRestartRecovery } from "./passthrough-ollama-recovery"; @@ -230,6 +234,10 @@ export interface AgentPassthroughDeps { execNonJson?: typeof runAgentNonJsonPassthrough; runOllamaRestartRecovery?: typeof runOllamaRestartRecovery; getRecentShieldsAutoRestore?: (sandboxName: string) => ShieldsAutoRestoreReadResult; + requireCuaReadiness?: (entry: registry.SandboxEntry) => unknown; + resolveSandboxGatewayName?: typeof resolveSandboxGatewayName; + withGatewayRouteMutationLock?: typeof withGatewayRouteMutationLock; + withSandboxMutationLock?: typeof withSandboxMutationLock; process?: { exit(code: number): never; stdout?: { write(s: string): unknown }; @@ -245,6 +253,7 @@ type RegistryReadResult = provider: string | null; model: string | null; endpointUrl: string | null; + entry: registry.SandboxEntry; } | { kind: "error"; message: string }; type ResolvedRegistryReadResult = Exclude; @@ -265,6 +274,7 @@ function readSandboxAgentFromRegistry( provider: sandbox.provider ?? null, model: sandbox.model ?? null, endpointUrl: sandbox.endpointUrl ?? null, + entry: sandbox, }; } catch (error) { return { kind: "error", message: (error as Error).message ?? String(error) }; @@ -340,8 +350,11 @@ function splitManifestCommand(command: string): TerminalCommandResult { return { kind: "command", argv: trimmed.split(/\s+/).filter(Boolean) }; } -function getTerminalInteractiveCommand(agent: AgentDefinition): TerminalCommandResult { - const command = agent.runtime?.interactive_command ?? agent.runtime?.headless_command ?? ""; +function getTerminalPassthroughCommand(agent: AgentDefinition): TerminalCommandResult { + const command = + agent.name === "nemocua" + ? (agent.runtime?.headless_command ?? "") + : (agent.runtime?.interactive_command ?? agent.runtime?.headless_command ?? ""); return splitManifestCommand(command); } @@ -379,7 +392,7 @@ function getPassthroughCommand( rejectNonOpenclawAgent(sandboxName, agentName, proc); } - const terminalCommand = getTerminalInteractiveCommand(agent); + const terminalCommand = getTerminalPassthroughCommand(agent); if (terminalCommand.kind === "unsupported") { rejectAgentResolutionError(sandboxName, agentName, terminalCommand.message, proc); } @@ -507,6 +520,40 @@ function rejectNotReadyForAgent( return proc.exit(1); } +async function runCuaHeadlessUnderMutationLocks( + sandboxName: string, + proc: NonNullable, + deps: AgentPassthroughDeps, +): Promise { + const lockSandbox = deps.withSandboxMutationLock ?? withSandboxMutationLock; + const lockGateway = deps.withGatewayRouteMutationLock ?? withGatewayRouteMutationLock; + const resolveGateway = deps.resolveSandboxGatewayName ?? resolveSandboxGatewayName; + await lockSandbox(sandboxName, async () => { + const lockedLookup = readSandboxAgentFromRegistry(sandboxName, deps.getSandbox); + if (lockedLookup.kind === "error") { + rejectRegistryReadError(sandboxName, lockedLookup.message, proc); + } + if (lockedLookup.kind !== "agent" || lockedLookup.agent !== "nemocua") { + rejectAgentResolutionError( + sandboxName, + "nemocua", + "NemoCUA authority changed while waiting for the sandbox mutation lock", + proc, + ); + } + const gatewayName = resolveGateway(lockedLookup.entry); + await lockGateway(gatewayName, async () => { + try { + (deps.requireCuaReadiness ?? requireCuaLifecycleReadiness)(lockedLookup.entry); + } catch (error) { + rejectAgentResolutionError(sandboxName, "nemocua", (error as Error).message, proc); + } + const exec = deps.exec ?? execSandbox; + await exec(sandboxName, ["nemocua", "headless"], { tty: false }); + }); + }); +} + export async function runAgentPassthrough( sandboxName: string, { extraArgs = [] }: AgentPassthroughOptions = {}, @@ -517,7 +564,27 @@ export async function runAgentPassthrough( if (lookup.kind === "error") { rejectRegistryReadError(sandboxName, lookup.message, proc); } + if (lookup.kind === "agent" && lookup.agent === "nemocua") { + if (extraArgs.length > 0) { + rejectAgentResolutionError( + sandboxName, + lookup.agent, + "NemoCUA headless execution does not accept additional arguments", + proc, + ); + } + } const command = getPassthroughCommand(sandboxName, lookup, extraArgs, proc); + if (lookup.kind === "agent" && lookup.agent === "nemocua") { + if (command?.length !== 2 || command[0] !== "nemocua" || command[1] !== "headless") { + rejectAgentResolutionError( + sandboxName, + lookup.agent, + "NemoCUA headless command must be exactly 'nemocua headless'", + proc, + ); + } + } if (!command) return; const ensureLive = deps.ensureLive ?? ensureLiveSandboxOrExit; const state = await ensureLive(sandboxName, { allowNonReadyPhase: true }); @@ -528,6 +595,10 @@ export async function runAgentPassthrough( if (phase !== "Ready" && phase !== "Running") { rejectNotReadyForAgent(sandboxName, phase, proc); } + if (lookup.kind === "agent" && lookup.agent === "nemocua") { + await runCuaHeadlessUnderMutationLocks(sandboxName, proc, deps); + return; + } if (isOpenClawPassthroughCommand(command) && !hasTargetSelector(extraArgs)) { rejectNoTargetSelector(proc); } diff --git a/src/lib/actions/sandbox/connect-inference-gateway.ts b/src/lib/actions/sandbox/connect-inference-gateway.ts index 7cd0ab9782b..bc0bd9f1d8e 100644 --- a/src/lib/actions/sandbox/connect-inference-gateway.ts +++ b/src/lib/actions/sandbox/connect-inference-gateway.ts @@ -5,11 +5,14 @@ import { checkGatewayRouteCompatibility, GatewayRouteConflictError, isAdvisoryProviderModelRouteConflict, + resolveLiveInferenceGatewayName, } from "../../inference/gateway-route-compatibility"; import { LOCAL_INFERENCE_TIMEOUT_SECS } from "../../onboard/env"; import type { SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; +export { resolveLiveInferenceGatewayName }; + function sandboxGatewayRouteCompatibility( sandboxName: string, sb: SandboxEntry, diff --git a/src/lib/actions/sandbox/cua-status-doctor.test.ts b/src/lib/actions/sandbox/cua-status-doctor.test.ts new file mode 100644 index 00000000000..4f0aebd4836 --- /dev/null +++ b/src/lib/actions/sandbox/cua-status-doctor.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CuaRuntimeReadiness } from "../../cua/contract"; +import type { CuaStateObservationDeps } from "../../cua/state"; +import type { SandboxEntry } from "../../state/registry"; +import { collectCuaRuntimeDoctorChecks } from "./doctor"; +import { getSandboxStatusReport } from "./status"; + +const digest = (character: string): string => `sha256:${character.repeat(64)}`; + +function candidateReadiness(): CuaRuntimeReadiness { + const component = (name: string, character: string) => ({ + name, + version: "1.0.0", + digest: digest(character), + owner: "NVIDIA", + }); + return { + schemaVersion: "1.0.0", + kind: "runtime-readiness", + agent: "nemocua", + mode: "standalone", + status: "candidate", + sourceRevision: "a".repeat(40), + sourceClean: true, + runtimeManifestDigest: digest("b"), + providerAuthorityDigest: digest("c"), + qualification: { + state: "candidate", + environmentDigest: digest("d"), + bundleReceiptDigest: digest("e"), + }, + components: { + openshell: component("openshell", "1"), + runtime: component("nemocua-runtime", "2"), + sandboxImage: component("nemocua-sandbox", "3"), + targetAdapter: component("target-adapter", "4"), + policy: component("nemocua-policy", "5"), + taskProtocol: component("task-protocol", "6"), + securityVerifier: component("security-verifier", "7"), + }, + inference: { + provider: "nvidia", + model: "nvidia/model", + routeDigest: digest("8"), + }, + appliedPolicy: { revision: 2, digest: digest("9") }, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: ["browser", "computer", "terminal"], + targetOperations: [], + securityOperations: [], + taskOperations: [], + }; +} + +function candidateEntry(readiness: CuaRuntimeReadiness): SandboxEntry { + return { + name: "alpha", + agent: "nemocua", + provider: readiness.inference.provider, + model: readiness.inference.model, + cuaRuntimeReadiness: readiness, + }; +} + +function observationDeps( + readiness: CuaRuntimeReadiness, + liveProvider = readiness.inference.provider, +): CuaStateObservationDeps { + return { + observeLiveInference: () => ({ + provider: liveProvider, + model: readiness.inference.model, + providerAuthorityDigest: readiness.providerAuthorityDigest, + }), + observeLiveAppliedPolicy: () => readiness.appliedPolicy, + validation: { + validateRuntimeReadiness: (_value, context) => { + assert.deepEqual( + { + provider: context.liveInference?.provider, + model: context.liveInference?.model, + providerAuthorityDigest: context.liveProviderAuthorityDigest, + appliedPolicy: context.liveAppliedPolicy, + }, + { + provider: readiness.inference.provider, + model: readiness.inference.model, + providerAuthorityDigest: readiness.providerAuthorityDigest, + appliedPolicy: readiness.appliedPolicy, + }, + "candidate authority changed", + ); + return readiness; + }, + }, + }; +} + +function statusDeps(entry: SandboxEntry, observation: CuaStateObservationDeps) { + return { + getSandbox: () => entry, + listSandboxes: () => ({ sandboxes: [entry], defaultSandbox: "alpha" }), + reconcile: async () => ({ state: "present" as const, output: "Name: alpha\nPhase: Ready\n" }), + captureOpenshellForStatusImpl: async () => ({ + status: 0, + output: `Gateway inference:\n Provider: ${entry.provider}\n Model: ${entry.model}\n`, + }), + probeProviderHealthImpl: vi.fn(() => null), + probeSandboxInferenceGatewayHealthImpl: vi.fn(async () => null), + probeTerminalRuntimeHealth: vi.fn(() => ({ kind: "ok" as const, oomKillCount: 0 as const })), + observeCuaLiveInference: observation.observeLiveInference, + observeCuaLiveAppliedPolicy: observation.observeLiveAppliedPolicy, + validateCuaRuntimeReadiness: observation.validation?.validateRuntimeReadiness, + }; +} + +describe("private CUA candidate status and doctor projection (#7755)", () => { + beforeEach(() => { + vi.stubEnv("NEMOCLAW_CUA_ENABLED", "1"); + vi.stubEnv("NEMOCLAW_CUA_QUALIFICATION", "1"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("projects matching candidate readiness and fails closed after route authority changes", async () => { + const readiness = candidateReadiness(); + const entry = candidateEntry(readiness); + const matching = observationDeps(readiness); + const stale = observationDeps(readiness, "changed-provider"); + + await expect( + getSandboxStatusReport("alpha", statusDeps(entry, matching)), + ).resolves.toMatchObject({ + cuaRuntime: readiness, + }); + await expect(getSandboxStatusReport("alpha", statusDeps(entry, stale))).resolves.toMatchObject({ + cuaRuntime: null, + }); + }); + + it("reports matching candidate readiness and fails stale authority in doctor", () => { + const readiness = candidateReadiness(); + const entry = candidateEntry(readiness); + + expect(collectCuaRuntimeDoctorChecks(entry, observationDeps(readiness))).toEqual([ + expect.objectContaining({ label: "CUA runtime", status: "ok" }), + ]); + expect( + collectCuaRuntimeDoctorChecks(entry, observationDeps(readiness, "changed-provider")), + ).toEqual([expect.objectContaining({ label: "CUA runtime", status: "fail" })]); + }); +}); diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index b8ffe74351f..0bbbb6f6e4d 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -11,6 +11,11 @@ import { getAgentRuntimeKind, loadAgent } from "../../agent/defs"; import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { GATEWAY_PORT } from "../../core/ports"; +import { + type CuaStateObservationDeps, + getObservedValidatedCuaState, + isCuaPublicStateEnabled, +} from "../../cua/state"; import { getNamedGatewayLifecycleState, recoverNamedGatewayRuntime, @@ -486,6 +491,34 @@ function collectRegisteredSandboxChecks( return checks; } +/** Report candidate install readiness only while both exact CUA gates are enabled. */ +export function collectCuaRuntimeDoctorChecks( + sb: SandboxEntry | null | undefined, + deps: CuaStateObservationDeps = {}, +): DoctorCheck[] { + if (!isCuaPublicStateEnabled() || sb?.agent !== "nemocua") return []; + const observed = getObservedValidatedCuaState(sb, process.env, deps); + if (!observed.readiness) { + return [ + { + group: "Sandbox", + label: "CUA runtime", + status: "fail", + detail: "candidate readiness is missing, invalid, stale, or unavailable", + hint: "re-run canonical onboarding with exact candidate qualification authority", + }, + ]; + } + return [ + { + group: "Sandbox", + label: "CUA runtime", + status: "ok", + detail: `candidate; source=${observed.readiness.sourceRevision}; manifest=${observed.readiness.runtimeManifestDigest}`, + }, + ]; +} + function collectToolScopeChecks( sandboxName: string, sb: SandboxEntry | null | undefined, @@ -548,6 +581,9 @@ async function collectDoctorChecks( ...collectManagedLlamaCppDoctorChecks(sandboxName, sb?.gatewayPort), ollamaDoctorCheck(route.provider), cloudflaredDoctorCheck(sandboxName), + // Keep this last because every asynchronous check above may race an + // authority-clearing registry write. + ...collectCuaRuntimeDoctorChecks(registry.getSandbox(sandboxName)), ]; } diff --git a/src/lib/actions/sandbox/gateway-target.ts b/src/lib/actions/sandbox/gateway-target.ts index cd125ceb8b7..fe035d8133d 100644 --- a/src/lib/actions/sandbox/gateway-target.ts +++ b/src/lib/actions/sandbox/gateway-target.ts @@ -5,8 +5,12 @@ import { GATEWAY_PORT } from "../../core/ports"; import { resolveGatewayName, resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import * as registry from "../../state/registry"; +export function getKnownSandboxTarget(sandboxName: string): registry.SandboxEntry | null { + return registry.getSandbox(sandboxName); +} + export function getKnownSandboxTargetGatewayName(sandboxName = ""): string | null { - const sb = sandboxName ? registry.getSandbox(sandboxName) : null; + const sb = sandboxName ? getKnownSandboxTarget(sandboxName) : null; return sb ? resolveSandboxGatewayName(sb) : null; } diff --git a/src/lib/actions/sandbox/launch.test.ts b/src/lib/actions/sandbox/launch.test.ts index 463ee57a26f..36f846d4964 100644 --- a/src/lib/actions/sandbox/launch.test.ts +++ b/src/lib/actions/sandbox/launch.test.ts @@ -49,6 +49,33 @@ function launchedCommand(): readonly string[] { return mocks.execSandbox.mock.calls[0]?.[1] as readonly string[]; } +type AsyncTestLock = (name: string, operation: () => Promise | T) => Promise; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function createSerialTestLock(events: string[], label: string): AsyncTestLock { + let tail = Promise.resolve(); + return async (_name: string, operation: () => Promise | T): Promise => { + const previous = tail; + const release = deferred(); + tail = previous.then(() => release.promise); + await previous; + events.push(`${label}:acquired`); + try { + return await operation(); + } finally { + events.push(`${label}:released`); + release.resolve(); + } + }; +} + describe("launchSandbox", () => { beforeEach(() => { vi.clearAllMocks(); @@ -99,6 +126,65 @@ describe("launchSandbox", () => { expect(launchedCommand()).toEqual(["bash", "-lc", "hermes"]); }); + it("holds CUA mutation authority through the exact interactive child execution (#7755)", async () => { + const nemocua = { + ...loadAgent("hermes"), + name: "nemocua", + runtime: { + kind: "terminal" as const, + interactive_command: "nemocua interactive", + headless_command: "nemocua headless", + }, + }; + prepareSession("nemocua", nemocua); + const events: string[] = []; + const childStarted = deferred(); + const releaseChild = deferred(); + const withSandboxMutationLock = createSerialTestLock(events, "sandbox"); + const withGatewayRouteMutationLock = createSerialTestLock(events, "gateway"); + const requireCuaReadiness = vi.fn(() => events.push("readiness")); + mocks.execSandbox.mockImplementationOnce(async () => { + events.push("child"); + childStarted.resolve(); + await releaseChild.promise; + }); + + const launch = launchSandbox("alpha", { + getSandbox: () => sandboxEntry("nemocua"), + requireCuaReadiness, + resolveSandboxGatewayName: () => "gateway-alpha", + withGatewayRouteMutationLock, + withSandboxMutationLock, + }); + await childStarted.promise; + const mutation = withSandboxMutationLock("alpha", () => + withGatewayRouteMutationLock("gateway-alpha", () => events.push("mutation")), + ); + await Promise.resolve(); + + expect(requireCuaReadiness).toHaveBeenCalledWith(expect.objectContaining({ agent: "nemocua" })); + expect(launchedCommand()).toEqual(["nemocua", "interactive"]); + expect(events).toEqual(["sandbox:acquired", "gateway:acquired", "readiness", "child"]); + + releaseChild.resolve(); + await launch; + await mutation; + + expect(events).toEqual([ + "sandbox:acquired", + "gateway:acquired", + "readiness", + "child", + "gateway:released", + "sandbox:released", + "sandbox:acquired", + "gateway:acquired", + "mutation", + "gateway:released", + "sandbox:released", + ]); + }); + it("rejects an untrusted registry agent before starting an in-sandbox command (#6006)", async () => { prepareSession("mystery-agent; echo pwned", null); diff --git a/src/lib/actions/sandbox/launch.ts b/src/lib/actions/sandbox/launch.ts index ed00af7e6fc..7275b7930e9 100644 --- a/src/lib/actions/sandbox/launch.ts +++ b/src/lib/actions/sandbox/launch.ts @@ -2,9 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import * as agentRuntime from "../../agent/runtime"; +import { requireCuaLifecycleReadiness } from "../../cua/lifecycle-readiness"; +import { resolveSandboxGatewayName } from "../../gateway-runtime-action"; +import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock"; +import { withMcpLifecycleLock as withSandboxMutationLock } from "../../state/mcp-lifecycle-lock-acquisition"; import { prepareInteractiveSession } from "./connect"; import { prepareHermesLightTerminalSkin } from "./connect-hermes-light-skin"; import { execSandbox } from "./exec"; +import { getKnownSandboxTarget } from "./gateway-target"; /** * Connect to a sandbox and start its agent in one host-side step (#6006). @@ -13,9 +18,57 @@ import { execSandbox } from "./exec"; * agent started over `exec` without process recovery renders a TUI that sits * disconnected because the gateway was never checked or restarted. */ -export async function launchSandbox(sandboxName: string): Promise { +interface LaunchSandboxDeps { + getSandbox?: typeof getKnownSandboxTarget; + requireCuaReadiness?: (entry: NonNullable>) => unknown; + resolveSandboxGatewayName?: typeof resolveSandboxGatewayName; + withGatewayRouteMutationLock?: typeof withGatewayRouteMutationLock; + withSandboxMutationLock?: typeof withSandboxMutationLock; +} + +async function launchCuaUnderMutationLocks( + sandboxName: string, + deps: LaunchSandboxDeps, +): Promise { + const lockSandbox = deps.withSandboxMutationLock ?? withSandboxMutationLock; + const lockGateway = deps.withGatewayRouteMutationLock ?? withGatewayRouteMutationLock; + const getSandbox = deps.getSandbox ?? getKnownSandboxTarget; + const resolveGateway = deps.resolveSandboxGatewayName ?? resolveSandboxGatewayName; + await lockSandbox(sandboxName, async () => { + const lockedEntry = getSandbox(sandboxName); + if (!lockedEntry || lockedEntry.agent !== "nemocua") { + throw new Error( + `NemoCUA authority changed while waiting to launch sandbox '${sandboxName}'.`, + ); + } + const gatewayName = resolveGateway(lockedEntry); + await lockGateway(gatewayName, async () => { + (deps.requireCuaReadiness ?? requireCuaLifecycleReadiness)(lockedEntry); + await execSandbox(sandboxName, ["nemocua", "interactive"], { + tty: true, + stdin: true, + // 0 means no timeout. Any other value kills a long interactive session. + timeoutSeconds: 0, + }); + }); + }); +} + +export async function launchSandbox( + sandboxName: string, + deps: LaunchSandboxDeps = {}, +): Promise { const { agent, sb } = await prepareInteractiveSession(sandboxName); - const agentCommand = agentRuntime.getInteractiveAgentCommand(agent, sb?.agent); + const isCua = sb?.agent === "nemocua"; + const agentCommand = isCua + ? agentRuntime.getTerminalCommand(agent, "interactive") + : agentRuntime.getInteractiveAgentCommand(agent, sb?.agent); + if (!agentCommand) { + throw new Error(`Cannot resolve an interactive command for sandbox '${sandboxName}'.`); + } + if (isCua && agentCommand !== "nemocua interactive") { + throw new Error("NemoCUA interactive command must be exactly 'nemocua interactive'"); + } // `connect` runs this immediately before opening its SSH session. It is not // part of prepareInteractiveSession, so `launch` must call it too: without it @@ -31,7 +84,12 @@ export async function launchSandbox(sandboxName: string): Promise { // file through the profile. Passing bare argv here would silently start the // agent under a different auth mode than `connect` gives it, so `-l` is // load-bearing: do not flatten this to `bash -c` or to the split command. - await execSandbox(sandboxName, ["bash", "-lc", agentCommand], { + if (isCua) { + await launchCuaUnderMutationLocks(sandboxName, deps); + return; + } + const command = ["bash", "-lc", agentCommand]; + await execSandbox(sandboxName, command, { tty: true, stdin: true, // 0 means no timeout. Any other value kills a long interactive session. diff --git a/src/lib/actions/sandbox/rebuild-preflight-guards.ts b/src/lib/actions/sandbox/rebuild-preflight-guards.ts index f9e84fdb3c6..659b0a11463 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-guards.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-guards.ts @@ -173,6 +173,12 @@ export function commitRebuildRoutePreflight( if (conflict) return { ok: false, message: conflict }; Object.assign(currentTarget, input.targetUpdate); + // Rebuild and its route migration are authority changes even if a later + // phase restores the old values. Revoke candidate readiness atomically. + registry.invalidateCuaRuntimeReadinessInRegistry(sandboxRegistry, input.sandboxName); + for (const name of migratedSandboxNames) { + registry.invalidateCuaRuntimeReadinessInRegistry(sandboxRegistry, name); + } dependencies.save(sandboxRegistry); return { ok: true, diff --git a/src/lib/actions/sandbox/status-inference.test.ts b/src/lib/actions/sandbox/status-inference.test.ts index 3000c418d9f..a53a1985726 100644 --- a/src/lib/actions/sandbox/status-inference.test.ts +++ b/src/lib/actions/sandbox/status-inference.test.ts @@ -315,4 +315,33 @@ describe("sandbox status inference.local route health (#6192)", () => { expect.stringContaining("super-secret"), ); }); + + it("omits CUA state and probes while the private candidate gates are disabled (#7755)", async () => { + const originalEnabled = process.env.NEMOCLAW_CUA_ENABLED; + const originalQualification = process.env.NEMOCLAW_CUA_QUALIFICATION; + delete process.env.NEMOCLAW_CUA_ENABLED; + delete process.env.NEMOCLAW_CUA_QUALIFICATION; + const observeCuaLiveInference = vi.fn(); + const observeCuaLiveAppliedPolicy = vi.fn(); + const deps = { + ...snapshotDeps({ agent: "nemocua", routeHealth: null }), + observeCuaLiveInference, + observeCuaLiveAppliedPolicy, + }; + + try { + const report = await getSandboxStatusReport("alpha", deps); + + expect(report).not.toHaveProperty("cuaRuntime"); + expect(observeCuaLiveInference).not.toHaveBeenCalled(); + expect(observeCuaLiveAppliedPolicy).not.toHaveBeenCalled(); + } finally { + originalEnabled === undefined + ? delete process.env.NEMOCLAW_CUA_ENABLED + : (process.env.NEMOCLAW_CUA_ENABLED = originalEnabled); + originalQualification === undefined + ? delete process.env.NEMOCLAW_CUA_QUALIFICATION + : (process.env.NEMOCLAW_CUA_QUALIFICATION = originalQualification); + } + }); }); diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index 636910f8631..ffdc0b6492b 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -8,6 +8,13 @@ import { import { captureOpenshellForStatus, isCommandTimeout } from "../../adapters/openshell/runtime"; import { type AgentDefinition, getAgentRuntimeKind, loadAgent } from "../../agent/defs"; import { withStdoutRedirectedToStderr } from "../../cli/stdout-guard"; +import type { CuaAppliedPolicyIdentity } from "../../cua/contract"; +import { + type CuaStateValidationDeps, + getObservedValidatedCuaState, + isCuaPublicStateEnabled, + type ObservedCuaInferenceRoute, +} from "../../cua/state"; import { type GatewayInference, parseGatewayInference, @@ -170,6 +177,8 @@ export interface SandboxStatusReport { openshellDriver: string; openshellVersion: string; policies: string[]; + /** Current, validated, credential-free CUA candidate runtime readiness. */ + cuaRuntime?: registry.SandboxEntry["cuaRuntimeReadiness"] | null; /** Baseline network policy keys the operator has excluded, replayed on rebuild. */ baselineExclusions: string[]; /** Observed enforcement state for each recorded baseline exclusion. */ @@ -285,6 +294,9 @@ function loadRecoverSandboxProcesses(): RecoverSandboxProcesses { interface CollectSandboxStatusSnapshotDeps { getSandbox?: typeof registry.getSandbox; + observeCuaLiveInference?: (entry: registry.SandboxEntry) => ObservedCuaInferenceRoute; + observeCuaLiveAppliedPolicy?: (entry: registry.SandboxEntry) => CuaAppliedPolicyIdentity; + validateCuaRuntimeReadiness?: CuaStateValidationDeps["validateRuntimeReadiness"]; listSandboxes?: typeof registry.listSandboxes; captureOpenshellForStatusImpl?: typeof captureOpenshellForStatus; probeProviderHealthImpl?: ProbeProviderHealth; @@ -661,6 +673,13 @@ async function buildSandboxStatusReport( } : null; const agent = resolveSandboxStatusAgent(sb?.agent || "openclaw"); + const cua = getObservedValidatedCuaState(sb, process.env, { + observeLiveInference: deps.observeCuaLiveInference, + observeLiveAppliedPolicy: deps.observeCuaLiveAppliedPolicy, + ...(deps.validateCuaRuntimeReadiness + ? { validation: { validateRuntimeReadiness: deps.validateCuaRuntimeReadiness } } + : {}), + }); return { schemaVersion: 1, name: sandboxName, @@ -692,6 +711,7 @@ async function buildSandboxStatusReport( openshellDriver: (sb && sb.openshellDriver) || "unknown", openshellVersion: (sb && sb.openshellVersion) || "unknown", policies, + ...(isCuaPublicStateEnabled() ? { cuaRuntime: cua.readiness } : {}), baselineExclusions, baselineExclusionStates, baselineExclusionTransition, diff --git a/src/lib/adapters/openshell/resolve-shared.ts b/src/lib/adapters/openshell/resolve-shared.ts new file mode 100644 index 00000000000..7e446fc44ef --- /dev/null +++ b/src/lib/adapters/openshell/resolve-shared.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveOpenshell } from "./resolve"; + +/** Resolve OpenShell without exiting when it is unavailable. */ +export function resolveOpenshellBinaryOrNull(): string | null { + return resolveOpenshell(); +} diff --git a/src/lib/adapters/openshell/runtime.test.ts b/src/lib/adapters/openshell/runtime.test.ts new file mode 100644 index 00000000000..79ab195fde5 --- /dev/null +++ b/src/lib/adapters/openshell/runtime.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { captureResolvedOpenshell } from "./runtime"; + +const directories: string[] = []; + +function executable(name: string, output: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-capture-test-")); + directories.push(directory); + const filePath = path.join(directory, name); + fs.writeFileSync(filePath, `#!/bin/sh\nprintf ${output}`, { mode: 0o755 }); + return filePath; +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("captureResolvedOpenshell", () => { + it("invokes the exact canonical executable supplied by CUA authority", () => { + const decoy = executable("decoy", "decoy"); + const snapshot = executable("snapshot", "snapshot"); + + const result = captureResolvedOpenshell([], { + openshellBinary: snapshot, + env: { NEMOCLAW_OPENSHELL_BIN: decoy }, + replaceEnv: true, + }); + + expect(result.status).toBe(0); + expect(result.output).toBe("snapshot"); + }); +}); diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index 178159b13f8..8bdfd0b784b 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { StdioOptions } from "node:child_process"; +import path from "node:path"; import { ROOT } from "../../runner"; import { @@ -11,12 +12,14 @@ import { getInstalledOpenshellVersion, runOpenshellCommand, } from "./client"; -import { resolveOpenshell } from "./resolve"; +import { resolveOpenshellBinaryOrNull } from "./resolve-shared"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts"; type CommandArgs = string[]; type RunnerOptions = { + /** Exact canonical executable selected by a CUA authority snapshot. */ + openshellBinary?: string; env?: NodeJS.ProcessEnv; replaceEnv?: boolean; stdio?: StdioOptions; @@ -33,7 +36,7 @@ let openshellBin: string | null = null; /** Resolve and cache the OpenShell binary path, exiting if it is not installed. */ export function getOpenshellBinary(): string { if (!openshellBin) { - openshellBin = resolveOpenshell(); + openshellBin = resolveOpenshellBinaryOrNull(); } if (!openshellBin) { console.error("openshell CLI not found. Install OpenShell before using sandbox commands."); @@ -77,6 +80,25 @@ export function captureOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { }); } +/** Capture an OpenShell command while treating an unavailable binary as a recoverable error. */ +export function captureResolvedOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { + const openshell = opts.openshellBinary ?? resolveOpenshellBinaryOrNull(); + if (!openshell) throw new Error("OpenShell is unavailable"); + if (!path.isAbsolute(openshell)) throw new Error("OpenShell executable must be absolute"); + return captureOpenshellCommand(openshell, args, { + cwd: ROOT, + env: opts.env, + replaceEnv: opts.replaceEnv, + ignoreError: opts.ignoreError, + includeStderr: opts.includeStderr, + includeStreams: opts.includeStreams, + timeout: opts.timeout, + maxBuffer: opts.maxBuffer, + errorLine: console.error, + exit: (code: number) => process.exit(code), + }); +} + /** Capture the SSH config OpenShell emits for a sandbox. */ export function captureSandboxSshConfig(sandboxName: string, opts: RunnerOptions = {}) { return captureSandboxSshConfigCommand(getOpenshellBinary(), sandboxName, { diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index eb2d2375e74..809b1243a97 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -2,17 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeAgent, withMockedDocker } from "../../../test/helpers/base-image-test-harness"; +import { testTimeout } from "../../../test/helpers/timeouts"; +import { createCuaRuntimeTestFixture } from "../cua/runtime-test-fixture"; import { tmpDir, writeCa } from "../onboard/__test-helpers__/corporate-ca-fixtures"; -import { testTimeout } from "../../../test/helpers/timeouts"; import { createSandboxBaseImageBuildProvenanceKey, type SandboxBaseImageResolutionMetadata, } from "../sandbox-base-image"; +import { loadAgent } from "./defs"; function makeResolutionMetadata( overrides: Partial = {}, @@ -60,6 +63,118 @@ describe("agent base image provisioning", () => { vi.unstubAllEnvs(); }); + it( + "validates the complete external NemoCUA payload before resolving or building an image (#7755)", + () => { + const runtime = createCuaRuntimeTestFixture(); + try { + for (const [name, value] of Object.entries(runtime.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + )) { + vi.stubEnv(name, value); + } + const agent = loadAgent("nemocua"); + const dockerfile = agent.dockerfileBasePath!; + fs.chmodSync(dockerfile, 0o644); + fs.writeFileSync(dockerfile, "FROM mutable:latest\n"); + fs.chmodSync(dockerfile, 0o444); + + withMockedDocker( + ({ ensureAgentBaseImage, dockerBuildMock, resolveSandboxBaseImageMock }) => { + expect(() => ensureAgentBaseImage(agent)).toThrow(/declared size|content identity/); + expect(resolveSandboxBaseImageMock).not.toHaveBeenCalled(); + expect(dockerBuildMock).not.toHaveBeenCalled(); + }, + ); + } finally { + runtime.cleanup(); + } + }, + testTimeout(15_000), + ); + + it("cannot resolve or stage NemoCUA image inputs after the feature is disabled (#7755)", () => { + const runtime = createCuaRuntimeTestFixture(); + try { + const agent = loadAgent("nemocua", runtime.env); + vi.stubEnv("NEMOCLAW_CUA_ENABLED", ""); + + withMockedDocker(({ ensureAgentBaseImage, dockerBuildMock, resolveSandboxBaseImageMock }) => { + expect(() => ensureAgentBaseImage(agent)).toThrow( + "use the controlled Brev Launchable activation", + ); + expect(resolveSandboxBaseImageMock).not.toHaveBeenCalled(); + expect(dockerBuildMock).not.toHaveBeenCalled(); + }); + } finally { + runtime.cleanup(); + } + }); + + it("uses the exact manifest-bound NemoCUA sandbox image without a nested base build (#7755)", () => { + const runtime = createCuaRuntimeTestFixture(); + try { + for (const [name, value] of Object.entries(runtime.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + )) { + vi.stubEnv(name, value); + } + const agent = loadAgent("nemocua"); + + withMockedDocker(({ ensureAgentBaseImage, dockerBuildMock, resolveSandboxBaseImageMock }) => { + expect(ensureAgentBaseImage(agent)).toEqual({ + imageTag: process.env.NEMOCLAW_CUA_SANDBOX_IMAGE_REF, + built: false, + }); + expect(resolveSandboxBaseImageMock).not.toHaveBeenCalled(); + expect(dockerBuildMock).not.toHaveBeenCalled(); + }); + } finally { + runtime.cleanup(); + } + }); + + it("stages only the exact manifest-bound NemoCUA Docker context (#7755)", () => { + const runtime = createCuaRuntimeTestFixture(); + let buildContext: string | undefined; + try { + for (const [name, value] of Object.entries(runtime.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + )) { + vi.stubEnv(name, value); + } + const agent = loadAgent("nemocua"); + + withMockedDocker(({ createAgentSandbox }) => { + const result = createAgentSandbox(agent); + buildContext = result.buildCtx; + expect(fs.readdirSync(result.buildCtx).sort()).toEqual(["Dockerfile", "agents"]); + expect(fs.readdirSync(path.join(result.buildCtx, "agents"))).toEqual(["nemocua"]); + expect(fs.readdirSync(path.join(result.buildCtx, "agents", "nemocua")).sort()).toEqual([ + "Dockerfile", + "Dockerfile.base", + "manifest.yaml", + "nemocua-cli.tar.gz", + "policy-additions.yaml", + "security-adapter.sh", + "target-adapter.sh", + "target-services.tar.gz", + "task-adapter.sh", + ]); + expect(fs.existsSync(path.join(result.buildCtx, "package.json"))).toBe(false); + expect(fs.existsSync(path.join(result.buildCtx, "private-source-coordinate.txt"))).toBe( + false, + ); + expect(fs.readFileSync(result.stagedDockerfile, "utf8")).toContain( + `ARG BASE_IMAGE=${runtime.env.NEMOCLAW_CUA_SANDBOX_IMAGE_REF}`, + ); + }); + } finally { + buildContext ? fs.rmSync(buildContext, { recursive: true, force: true }) : undefined; + runtime.cleanup(); + } + }); + it( "reuses a compatible resolved agent base image during normal onboarding", () => { diff --git a/src/lib/agent/base-image.ts b/src/lib/agent/base-image.ts index cf196552dae..e2ce1ca4733 100644 --- a/src/lib/agent/base-image.ts +++ b/src/lib/agent/base-image.ts @@ -14,9 +14,15 @@ import { dockerRmi, dockerTag, } from "../adapters/docker"; -import { createCustomBuildContextFilter } from "../onboard/custom-build-context"; - +import { requireCuaFrameworkEnabled } from "../cua/feature"; +import { + getCuaSandboxImageRef, + loadCuaRuntimeManifest, + stageCuaRuntimePayload, + verifyCuaRuntimePayload, +} from "../cua/runtime-manifest"; import { encodeCorporateCaArg, resolveCorporateCa } from "../onboard/corporate-ca"; +import { createCustomBuildContextFilter } from "../onboard/custom-build-context"; import { ROOT } from "../runner"; import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../sandbox/build-context"; import { @@ -53,6 +59,13 @@ function corporateCaBuildArgs( : undefined; } +function agentBaseImageBuildArgs(agent: AgentDefinition): Record | undefined { + if (agent.name === "nemocua") { + return { NEMOCUA_RUNTIME_IMAGE: getCuaSandboxImageRef() }; + } + return agent.name === "langchain-deepagents-code" ? corporateCaBuildArgs() : undefined; +} + const HERMES_MCP_RUNTIME_PROBE_OK = "nemoclaw-hermes-mcp-runtime-ok"; // Matches the official Hermes base repository for both Dockerfile manifest-list // pins and Docker-normalized platform manifest digests. @@ -274,7 +287,7 @@ function createAgentBaseImageResolutionOptions( return { imageName, dockerfilePath, - buildArgs: agent.name === "langchain-deepagents-code" ? corporateCaBuildArgs() : undefined, + buildArgs: agentBaseImageBuildArgs(agent), localTag: buildLocalBaseTag(`nemoclaw-${agent.name}-sandbox-base-local`, ROOT), envVar: getAgentSandboxBaseImageEnvVar(agent.name), label: `${agent.displayName} sandbox base image`, @@ -483,12 +496,19 @@ export function ensureAgentBaseImage( agent: AgentDefinition, options: EnsureAgentBaseImageOptions = {}, ): EnsureAgentBaseImageResult { + if (agent.name === "nemocua") requireCuaFrameworkEnabled(); const baseDockerfile = agent.dockerfileBasePath; if (!baseDockerfile) { return { imageTag: null, built: false }; } + if (agent.name === "nemocua") { + const runtimeManifest = loadCuaRuntimeManifest(); + verifyCuaRuntimePayload(runtimeManifest); + return { imageTag: getCuaSandboxImageRef(), built: false }; + } + const resolutionOptions = createAgentBaseImageResolutionOptions(agent, baseDockerfile, options); const baseImageName = resolutionOptions.imageName; const baseImageTag = `${baseImageName}:${SANDBOX_BASE_TAG}`; @@ -659,6 +679,7 @@ export function createAgentSandbox( agent: AgentDefinition, options: CreateAgentSandboxOptions = {}, ): CreateAgentSandboxResult { + if (agent.name === "nemocua") requireCuaFrameworkEnabled(); const agentDockerfile = agent.dockerfilePath; if (!agentDockerfile) { @@ -671,19 +692,43 @@ export function createAgentSandbox( baseImageOptions, ); const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), SANDBOX_BUILD_CONTEXT_PREFIX)); - const shouldIncludeBuildContextPath = createCustomBuildContextFilter(rootDir); - fs.cpSync(rootDir, buildCtx, { - recursive: true, - filter: (src) => path.basename(src) !== ".claude" && shouldIncludeBuildContextPath(src), - }); + const stagedCuaAgentDir = path.join(buildCtx, "agents", "nemocua"); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.copyFileSync(agentDockerfile, stagedDockerfile); - if (baseImageRef) { - const dockerfile = fs.readFileSync(stagedDockerfile, "utf8"); - fs.writeFileSync( - stagedDockerfile, - dockerfile.replace(/^ARG BASE_IMAGE(?:=.*)?$/m, `ARG BASE_IMAGE=${baseImageRef}`), - ); + try { + if (agent.name === "nemocua") { + // The external CUA manifest defines the complete Docker input set. Do + // not disclose the NemoClaw checkout to the Docker daemon or its cache. + stageCuaRuntimePayload(stagedCuaAgentDir); + const dockerfile = fs.readFileSync(path.join(stagedCuaAgentDir, "Dockerfile"), "utf8"); + fs.writeFileSync( + stagedDockerfile, + baseImageRef + ? dockerfile.replace(/^ARG BASE_IMAGE(?:=.*)?$/m, `ARG BASE_IMAGE=${baseImageRef}`) + : dockerfile, + { flag: "wx", mode: 0o600 }, + ); + } else { + const shouldIncludeBuildContextPath = createCustomBuildContextFilter(rootDir); + fs.cpSync(rootDir, buildCtx, { + recursive: true, + filter: (src) => path.basename(src) !== ".claude" && shouldIncludeBuildContextPath(src), + }); + fs.copyFileSync(agentDockerfile, stagedDockerfile); + if (baseImageRef) { + const dockerfile = fs.readFileSync(stagedDockerfile, "utf8"); + fs.writeFileSync( + stagedDockerfile, + dockerfile.replace(/^ARG BASE_IMAGE(?:=.*)?$/m, `ARG BASE_IMAGE=${baseImageRef}`), + ); + } + } + } catch (error) { + try { + fs.rmSync(buildCtx, { recursive: true, force: true }); + } catch { + // Preserve the manifest or staging authority failure. + } + throw error; } console.log(` Using ${agent.displayName} Dockerfile: ${agentDockerfile}`); diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 74b4d4bec2d..b5718b85579 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AGENTS_DIR, getAgentChoices, + listAgents, loadAgent, requireAgentPolicyAdditionsPath, resolveAgentName, @@ -36,6 +37,27 @@ afterEach(() => { }); describe("agent definitions", () => { + it("cannot discover or load a local NemoCUA manifest while the feature is disabled (#7755)", () => { + const realExistsSync = fs.existsSync.bind(fs); + vi.spyOn(fs, "existsSync").mockImplementation((candidate) => + candidate === path.join(AGENTS_DIR, "nemocua", "manifest.yaml") + ? true + : realExistsSync(candidate), + ); + vi.spyOn(fs, "readdirSync").mockReturnValue([ + { name: "nemocua", isDirectory: () => true } as fs.Dirent, + ] as never); + const disabledEnv = { + NEMOCLAW_CUA_RUNTIME_MANIFEST: "/private/untrusted/runtime-manifest.json", + NEMOCLAW_CUA_RUNTIME_MANIFEST_SHA256: "a".repeat(64), + }; + + expect(listAgents(disabledEnv)).not.toContain("nemocua"); + expect(() => loadAgent("nemocua", disabledEnv)).toThrow( + "use the controlled Brev Launchable activation", + ); + }); + it("orders OpenClaw first in interactive choices", () => { const choices = getAgentChoices(); expect(choices[0]?.name).toBe("openclaw"); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index aeaeb13cbb6..1561615e5bd 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -9,6 +9,8 @@ import fs from "node:fs"; import path from "node:path"; import { DASHBOARD_PORT } from "../core/ports"; +import { isCuaFrameworkEnabled, requireCuaFrameworkEnabled } from "../cua/feature"; +import { getCuaExternalAgentManifestPath } from "../cua/runtime-manifest"; import { ROOT } from "../runner"; import { formatAgentAliasSuffix, @@ -114,14 +116,23 @@ function unknownAgentMessage( * List available agent names by scanning agents/ for directories with * a manifest.yaml file. */ -export function listAgents(): string[] { - if (!fs.existsSync(AGENTS_DIR)) return []; - return fs - .readdirSync(AGENTS_DIR, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .filter((entry) => fs.existsSync(path.join(AGENTS_DIR, entry.name, "manifest.yaml"))) - .map((entry) => entry.name) - .sort(); +export function listAgents(env: NodeJS.ProcessEnv = process.env): string[] { + const agents = fs.existsSync(AGENTS_DIR) + ? fs + .readdirSync(AGENTS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => entry.name !== "nemocua") + .filter((entry) => fs.existsSync(path.join(AGENTS_DIR, entry.name, "manifest.yaml"))) + .map((entry) => entry.name) + : []; + if ( + isCuaFrameworkEnabled(env) && + env.NEMOCLAW_CUA_RUNTIME_MANIFEST && + env.NEMOCLAW_CUA_RUNTIME_MANIFEST_SHA256 + ) { + agents.push("nemocua"); + } + return [...new Set(agents)].sort(); } /** Resolve a non-OpenClaw agent's required, readable baseline policy. */ @@ -143,17 +154,22 @@ export function requireAgentPolicyAdditionsPath( /** * Load and parse an agent manifest. */ -export function loadAgent(name: string): AgentDefinition { - const cached = _cache.get(name); +export function loadAgent(name: string, env: NodeJS.ProcessEnv = process.env): AgentDefinition { + if (name === "nemocua") requireCuaFrameworkEnabled(env); + const externalCua = name === "nemocua"; + const manifestPath = externalCua + ? getCuaExternalAgentManifestPath(env) + : path.join(AGENTS_DIR, name, "manifest.yaml"); + const cacheKey = externalCua ? null : name; + const cached = cacheKey ? _cache.get(cacheKey) : undefined; if (cached) return cached; - const manifestPath = path.join(AGENTS_DIR, name, "manifest.yaml"); if (!fs.existsSync(manifestPath)) { throw new Error(`Agent '${name}' not found: ${manifestPath}`); } const raw = loadManifestRecord(manifestPath); - const agentDir = path.join(AGENTS_DIR, name); + const agentDir = path.dirname(manifestPath); const manifestName = readString(raw, "name") ?? name; const description = readString(raw, "description"); const displayName = readString(raw, "display_name"); @@ -382,7 +398,24 @@ export function loadAgent(name: string): AgentDefinition { }, }; - _cache.set(name, agent); + if (externalCua) { + if ( + agent.name !== "nemocua" || + runtime.kind !== "terminal" || + !runtime.interactive_command || + !runtime.headless_command || + !runtime.smoke_commands?.length || + !binaryPath?.startsWith("/") || + !versionCommand || + !expectedVersion + ) { + throw new Error( + "External NemoCUA agent manifest must declare the canonical terminal runtime, binary, version, and smoke surfaces", + ); + } + } + + if (cacheKey) _cache.set(cacheKey, agent); return agent; } diff --git a/src/lib/agent/onboard-cua.test.ts b/src/lib/agent/onboard-cua.test.ts new file mode 100644 index 00000000000..11e91d721b5 --- /dev/null +++ b/src/lib/agent/onboard-cua.test.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + type CuaRuntimeTestFixture, + createCuaRuntimeTestFixture, +} from "../cua/runtime-test-fixture"; +import { loadAgent } from "./defs"; +import { getAgentPolicyPath, handleAgentSetup, type OnboardContext, resolveAgent } from "./onboard"; + +const fixtures: CuaRuntimeTestFixture[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + while (fixtures.length > 0) fixtures.pop()?.cleanup(); +}); + +describe("NemoCUA agent onboarding", () => { + it("cannot consume the external policy after the CUA gate is disabled (#7755)", () => { + const runtime = createCuaRuntimeTestFixture(); + fixtures.push(runtime); + const agent = loadAgent("nemocua", runtime.env); + vi.stubEnv("NEMOCLAW_CUA_ENABLED", ""); + + expect(() => getAgentPolicyPath(agent)).toThrow( + "use the controlled Brev Launchable activation", + ); + }); + + it("refuses candidate onboarding before loading the agent without qualification authority (#7755)", () => { + const runtime = createCuaRuntimeTestFixture(); + fixtures.push(runtime); + for (const [key, value] of Object.entries(runtime.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + )) { + vi.stubEnv(key, value); + } + vi.stubEnv("NEMOCLAW_CUA_QUALIFICATION", ""); + + expect(() => resolveAgent({ agentFlag: "nemocua" })).toThrow( + "candidate onboarding requires exact qualification authority", + ); + }); + + it("records candidate readiness on the existing standalone sandbox after terminal checks (#7755)", async () => { + const runtime = createCuaRuntimeTestFixture(); + fixtures.push(runtime); + const env = { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }; + const agent = loadAgent("nemocua", env); + const calls: string[][] = []; + const runCaptureOpenshell = vi.fn((args: string[]) => { + calls.push(args); + const command = args.at(-1) ?? ""; + switch (true) { + case command.includes("NEMOCLAW_AGENT_BINARY_CHECK"): + return "NEMOCLAW_AGENT_BINARY_CHECK:ok"; + case args.at(-2) === "nemoclaw-agent-smoke": + return "nemocua 1.0.0\nNEMOCLAW_AGENT_SMOKE_EXIT:0"; + case command === "nemocua version": + return "nemocua 1.0.0"; + default: + return ""; + } + }); + const updateSandbox = vi.fn(() => true); + const context: OnboardContext = { + step: vi.fn(), + runCaptureOpenshell, + openshellShellCommand: vi.fn(() => "openshell sandbox connect worker"), + openshellBinary: runtime.openshellPath, + startRecordedStep: vi.fn(async () => undefined), + recordStepComplete: vi.fn(async () => undefined), + recordStepFailed: vi.fn(async () => undefined), + skippedStepMessage: vi.fn(), + getSandboxInferenceSelection: () => ({ + name: "existing-worker", + agent: "nemocua", + provider: "provider-x", + model: "model-x", + gatewayName: "nemoclaw-18080", + gatewayPort: 18080, + }), + updateSandbox, + cuaRuntimeEnvironment: env, + cuaBuildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + cuaObserveLiveInference: () => ({ + provider: "provider-x", + model: "model-x", + providerAuthorityDigest: `sha256:${"8".repeat(64)}`, + }), + cuaObserveLiveAppliedPolicy: () => ({ + revision: 7, + digest: `sha256:${"9".repeat(64)}`, + }), + cuaWithGatewayRouteMutationLock: async (gatewayName, operation) => { + expect(gatewayName).toBe("nemoclaw-18080"); + return await operation(); + }, + }; + + await handleAgentSetup("existing-worker", "model-x", "provider-x", agent, false, null, context); + + expect(updateSandbox).toHaveBeenCalledWith("existing-worker", { + cuaRuntimeReadiness: expect.objectContaining({ + agent: "nemocua", + mode: "standalone", + status: "candidate", + sourceRevision: runtime.candidateCommit, + }), + }); + expect(context.recordStepComplete).toHaveBeenCalledWith("agent_setup", { + sandboxName: "existing-worker", + provider: "provider-x", + model: "model-x", + }); + expect(context.recordStepFailed).not.toHaveBeenCalled(); + expect(calls.length).toBeGreaterThan(0); + expect( + calls.every((args) => args.slice(0, 4).join(" ") === "sandbox exec -n existing-worker"), + ).toBe(true); + expect(calls.some((args) => args.includes("create"))).toBe(false); + }); +}); diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 85abe76aa60..e360c6f8c93 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -9,10 +9,24 @@ import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args"; import { getAgentBranding } from "../cli/branding"; import type { JsonObject as LooseObject } from "../core/json-types"; import { sleepSeconds } from "../core/wait"; +import { requireCuaFrameworkEnabled } from "../cua/feature"; +import { + type CuaBuildIdentity, + type CuaLiveInferenceObservation, + type CuaRuntimeReadiness, + isCuaQualificationEnabled, + observeCuaLiveAppliedPolicy, + observeCuaLiveInference, + requireCurrentCuaRuntimeReadiness, + resolveSandboxGatewayName, + withGatewayRouteMutationLock, +} from "../cua/onboard-runtime"; import { getProviderSelectionConfig } from "../inference/config"; +import { normalizeInferenceSelection } from "../inference/selection"; import { runSandboxConfigSync } from "../onboard/config-sync"; import { isValidForwardPort } from "../onboard/dashboard-runtime"; import { redact, run } from "../runner"; +import type { SandboxEntry } from "../state/registry/types"; import * as baseImage from "./base-image"; import { describeAgentBinaryFailure, verifyAgentBinaryAvailable } from "./binary-availability"; import { printOptionalDashboardUi } from "./dashboard-ui"; @@ -42,6 +56,28 @@ export interface OnboardContext { recordStepComplete: (stepName: string, updates: LooseObject) => Promise; recordStepFailed: (stepName: string, message: string | null) => Promise; skippedStepMessage: (stepName: string, sandboxName: string) => void; + getSandboxInferenceSelection?: (sandboxName: string) => SandboxEntry | null; + updateSandbox?: ( + sandboxName: string, + updates: { cuaRuntimeReadiness: CuaRuntimeReadiness }, + ) => boolean; + recordCuaRuntimeReadiness?: ( + sandboxName: string, + readiness: CuaRuntimeReadiness, + expectedEntry: SandboxEntry, + ) => boolean; + cuaRegistry?: { + getSandbox: (sandboxName: string) => SandboxEntry | null; + recordCuaRuntimeReadiness: NonNullable; + }; + cuaRuntimeEnvironment?: NodeJS.ProcessEnv; + cuaBuildIdentity?: CuaBuildIdentity; + cuaRootDir?: string; + cuaObserveLiveInference?: (entry: SandboxEntry) => CuaLiveInferenceObservation; + cuaObserveLiveAppliedPolicy?: ( + entry: SandboxEntry, + ) => import("../cua/contract").CuaAppliedPolicyIdentity; + cuaWithGatewayRouteMutationLock?: typeof withGatewayRouteMutationLock; now?: () => number; sleepSeconds?: (seconds: number) => void; } @@ -129,6 +165,9 @@ export function resolveAgent({ } = {}): AgentDefinition | null { const name = resolveAgentName({ agentFlag, session }); if (name === "openclaw") return null; + if (name === "nemocua" && !isCuaQualificationEnabled()) { + throw new Error("NemoCUA candidate onboarding requires exact qualification authority"); + } return loadAgent(name); } @@ -137,6 +176,7 @@ export function resolveAgent({ */ export function getAgentPolicyPath(agent: AgentDefinition): string | null { if (agent.name === "openclaw") return null; + if (agent.name === "nemocua") requireCuaFrameworkEnabled(); return requireAgentPolicyAdditionsPath(agent); } @@ -239,6 +279,105 @@ async function failAgentSetup( process.exit(1); } +async function recordCuaRuntimeReadiness( + sandboxName: string, + agent: AgentDefinition, + provider: string, + model: string, + context: Pick< + OnboardContext, + | "getSandboxInferenceSelection" + | "recordStepFailed" + | "updateSandbox" + | "recordCuaRuntimeReadiness" + | "cuaRegistry" + | "cuaRuntimeEnvironment" + | "cuaBuildIdentity" + | "cuaRootDir" + | "openshellBinary" + | "cuaObserveLiveInference" + | "cuaObserveLiveAppliedPolicy" + | "cuaWithGatewayRouteMutationLock" + >, +): Promise { + if (agent.name !== "nemocua") return; + try { + const storedSandbox = ( + context.getSandboxInferenceSelection ?? context.cuaRegistry?.getSandbox + )?.(sandboxName); + const recordedSandbox = storedSandbox ?? { + provider, + model, + }; + const recordedInference = normalizeInferenceSelection(recordedSandbox); + const env = context.cuaRuntimeEnvironment ?? process.env; + const entry: SandboxEntry = { + name: sandboxName, + agent: agent.name, + ...recordedInference, + ...(storedSandbox?.gatewayName !== undefined + ? { gatewayName: storedSandbox.gatewayName } + : {}), + ...(storedSandbox?.gatewayPort !== undefined + ? { gatewayPort: storedSandbox.gatewayPort } + : {}), + }; + if (!isCuaQualificationEnabled(env)) { + throw new Error("NemoCUA candidate onboarding requires exact qualification authority"); + } + await (context.cuaWithGatewayRouteMutationLock ?? withGatewayRouteMutationLock)( + resolveSandboxGatewayName(entry), + () => { + const live = context.cuaObserveLiveInference + ? context.cuaObserveLiveInference(entry) + : observeCuaLiveInference(entry, { + openshellBinary: context.openshellBinary, + env, + }); + const liveAppliedPolicy = context.cuaObserveLiveAppliedPolicy + ? context.cuaObserveLiveAppliedPolicy(entry) + : observeCuaLiveAppliedPolicy(entry, { + openshellBinary: context.openshellBinary, + env, + }); + const cuaRuntimeReadiness = requireCurrentCuaRuntimeReadiness({ + agentName: agent.name, + recordedInference, + liveInference: { + ...recordedInference, + provider: live.provider, + model: live.model, + }, + liveProviderAuthorityDigest: live.providerAuthorityDigest, + liveAppliedPolicy, + ...(live.openshellDigest ? { expectedOpenshellDigest: live.openshellDigest } : {}), + acceptance: "candidate-qualification", + env, + openshellBinary: context.openshellBinary, + ...(context.cuaBuildIdentity ? { buildIdentity: context.cuaBuildIdentity } : {}), + ...(context.cuaRootDir ? { rootDir: context.cuaRootDir } : {}), + }); + const canonicalRecord = + context.recordCuaRuntimeReadiness ?? context.cuaRegistry?.recordCuaRuntimeReadiness; + const recorded = + canonicalRecord && storedSandbox && "name" in storedSandbox + ? canonicalRecord(sandboxName, cuaRuntimeReadiness, storedSandbox as SandboxEntry) + : context.updateSandbox?.(sandboxName, { cuaRuntimeReadiness }); + if (!recorded) { + throw new Error(`NemoCUA runtime readiness could not be recorded for '${sandboxName}'`); + } + }, + ); + } catch (error) { + await failAgentSetup( + sandboxName, + agent, + error instanceof Error ? error.message : String(error), + context.recordStepFailed, + ); + } +} + /** * Interpret an agent health-probe response as healthy or unhealthy. */ @@ -277,6 +416,16 @@ export async function handleAgentSetup( recordStepComplete, recordStepFailed, skippedStepMessage, + getSandboxInferenceSelection, + updateSandbox, + recordCuaRuntimeReadiness: persistCuaRuntimeReadiness, + cuaRegistry, + cuaRuntimeEnvironment, + cuaBuildIdentity, + cuaRootDir, + cuaObserveLiveInference, + cuaObserveLiveAppliedPolicy, + cuaWithGatewayRouteMutationLock, } = ctx; const syncNemoClawConfig = (): void => { @@ -309,6 +458,20 @@ export async function handleAgentSetup( beforeFailure: () => startRecordedStep("agent_setup", { sandboxName, provider, model }), onFailure: (message) => failAgentSetup(sandboxName, agent, message, recordStepFailed), }); + await recordCuaRuntimeReadiness(sandboxName, agent, provider, model, { + getSandboxInferenceSelection, + recordStepFailed, + updateSandbox, + recordCuaRuntimeReadiness: persistCuaRuntimeReadiness, + cuaRegistry, + cuaRuntimeEnvironment, + cuaBuildIdentity, + cuaRootDir, + openshellBinary: openshellBin, + cuaObserveLiveInference, + cuaObserveLiveAppliedPolicy, + cuaWithGatewayRouteMutationLock, + }); skippedStepMessage("agent_setup", sandboxName); await recordStepComplete("agent_setup", { sandboxName, provider, model }); return; @@ -372,6 +535,20 @@ export async function handleAgentSetup( await enforceTerminalAgentVersion(sandboxName, agent, runCaptureOpenshell, { onFailure: (message) => failAgentSetup(sandboxName, agent, message, recordStepFailed), }); + await recordCuaRuntimeReadiness(sandboxName, agent, provider, model, { + getSandboxInferenceSelection, + recordStepFailed, + updateSandbox, + recordCuaRuntimeReadiness: persistCuaRuntimeReadiness, + cuaRegistry, + cuaRuntimeEnvironment, + cuaBuildIdentity, + cuaRootDir, + openshellBinary: openshellBin, + cuaObserveLiveInference, + cuaObserveLiveAppliedPolicy, + cuaWithGatewayRouteMutationLock, + }); console.log(` \u2713 ${agent.displayName} terminal runtime is ready`); await recordStepComplete("agent_setup", { sandboxName, provider, model }); return; diff --git a/src/lib/core/generate-build-identity.ts b/src/lib/core/generate-build-identity.ts index 471466c100c..60da49049d9 100644 --- a/src/lib/core/generate-build-identity.ts +++ b/src/lib/core/generate-build-identity.ts @@ -4,11 +4,17 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { createCuaBuildIdentityStamp, CUA_BUILD_IDENTITY_FILE } from "../cua/build-identity"; import { resolveSourceBuildIdentity } from "./version"; const root = join(__dirname, "..", "..", ".."); const outputPath = join(root, "dist", "build-identity.json"); const identity = resolveSourceBuildIdentity({ rootDir: root }); +const cuaIdentity = createCuaBuildIdentityStamp(root, identity.sourceRevision); mkdirSync(join(root, "dist"), { recursive: true }); writeFileSync(outputPath, `${JSON.stringify(identity, null, 2)}\n`); +writeFileSync( + join(root, "dist", CUA_BUILD_IDENTITY_FILE), + `${JSON.stringify(cuaIdentity, null, 2)}\n`, +); diff --git a/src/lib/cua/bounded-file.test.ts b/src/lib/cua/bounded-file.test.ts new file mode 100644 index 00000000000..d9e893909a7 --- /dev/null +++ b/src/lib/cua/bounded-file.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readBoundedRegularFile, snapshotBoundedExecutable } from "./bounded-file"; + +const temporaryDirectories: string[] = []; + +function temporaryFile(contents: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-bounded-file-")); + temporaryDirectories.push(directory); + const filePath = path.join(directory, "input"); + fs.writeFileSync(filePath, contents); + return filePath; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("bounded regular file reads", () => { + it("returns one stable regular file within its declared limit", () => { + const filePath = temporaryFile("bounded input"); + + expect( + readBoundedRegularFile(filePath, { + label: "fixture input", + minBytes: 1, + maxBytes: 32, + }).toString("utf8"), + ).toBe("bounded input"); + }); + + it("rejects a symbolic link", () => { + const filePath = temporaryFile("bounded input"); + const linkPath = path.join(path.dirname(filePath), "input-link"); + fs.symlinkSync(filePath, linkPath); + + expect(() => + readBoundedRegularFile(linkPath, { + label: "fixture input", + maxBytes: 32, + }), + ).toThrow(); + }); + + it("rejects same-size source mutation observed after the bounded read", () => { + const filePath = temporaryFile("12345678"); + const originalReadSync = fs.readSync; + let changed = false; + vi.spyOn(fs, "readSync").mockImplementation(((...args: unknown[]) => { + const bytesRead = Reflect.apply(originalReadSync, fs, args) as number; + const mutateAfterRead = !changed; + changed = true; + mutateAfterRead ? fs.writeFileSync(filePath, "abcdefgh") : undefined; + return bytesRead; + }) as typeof fs.readSync); + + expect(() => + readBoundedRegularFile(filePath, { + label: "fixture input", + minBytes: 1, + maxBytes: 8, + }), + ).toThrow("changed during bounded validation"); + }); + + it("rejects a script whose interpreter is caller-writable", () => { + const interpreter = temporaryFile("#!/bin/sh\nexit 0\n"); + fs.chmodSync(interpreter, 0o755); + const script = temporaryFile(`#!${interpreter}\nexit 0\n`); + fs.chmodSync(script, 0o755); + + expect(() => + snapshotBoundedExecutable(script, { + label: "fixture executable", + minBytes: 1, + maxBytes: 1024, + temporaryDirectoryPrefix: "nemoclaw-cua-executable-fixture-", + }), + ).toThrow("untrusted interpreter"); + }); +}); diff --git a/src/lib/cua/bounded-file.ts b/src/lib/cua/bounded-file.ts new file mode 100644 index 00000000000..12b7eac5fca --- /dev/null +++ b/src/lib/cua/bounded-file.ts @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export interface BoundedRegularFileOptions { + maxBytes: number; + minBytes?: number; + label: string; +} + +export interface BoundedExecutableSnapshotOptions extends BoundedRegularFileOptions { + expectedDigest?: string; + temporaryDirectoryPrefix: string; +} + +export interface BoundedExecutableSnapshot { + executable: string; + executableDigest: string; + homeDirectory: string; + temporaryDirectory: string; + cleanup: () => void; +} + +const TRUSTED_EXECUTABLE_PATH = "/usr/bin:/bin"; + +/** Build a small, deterministic process environment inside the private snapshot directory. */ +export function isolatedExecutableEnvironment( + snapshot: BoundedExecutableSnapshot, +): NodeJS.ProcessEnv { + return { + HOME: snapshot.homeDirectory, + LANG: "C", + LC_ALL: "C", + PATH: TRUSTED_EXECUTABLE_PATH, + TEMP: snapshot.temporaryDirectory, + TMP: snapshot.temporaryDirectory, + TMPDIR: snapshot.temporaryDirectory, + }; +} + +interface StableRead { + contents: Buffer; + mode: bigint; +} + +function validBounds(options: BoundedRegularFileOptions): { minBytes: number; maxBytes: number } { + const minBytes = options.minBytes ?? 0; + if ( + !Number.isSafeInteger(minBytes) || + !Number.isSafeInteger(options.maxBytes) || + minBytes < 0 || + options.maxBytes < minBytes + ) { + throw new Error(`${options.label} has invalid byte bounds`); + } + return { minBytes, maxBytes: options.maxBytes }; +} + +function hasStableIdentity(before: fs.BigIntStats, after: fs.BigIntStats): boolean { + return ( + before.dev === after.dev && + before.ino === after.ino && + before.mode === after.mode && + before.nlink === after.nlink && + before.uid === after.uid && + before.gid === after.gid && + before.rdev === after.rdev && + before.size === after.size && + before.mtimeNs === after.mtimeNs && + before.ctimeNs === after.ctimeNs && + before.birthtimeNs === after.birthtimeNs + ); +} + +function readStableDescriptor(descriptor: number, options: BoundedRegularFileOptions): StableRead { + const { minBytes, maxBytes } = validBounds(options); + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.size < BigInt(minBytes) || before.size > BigInt(maxBytes)) { + throw new Error( + `${options.label} must be a regular file from ${String(minBytes)} through ${String(maxBytes)} bytes`, + ); + } + + const declaredSize = Number(before.size); + const contents = Buffer.alloc(declaredSize + 1); + let offset = 0; + while (offset < contents.length) { + const bytesRead = fs.readSync(descriptor, contents, offset, contents.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + + const after = fs.fstatSync(descriptor, { bigint: true }); + if (offset !== declaredSize || !after.isFile() || !hasStableIdentity(before, after)) { + throw new Error(`${options.label} changed during bounded validation`); + } + return { contents: contents.subarray(0, offset), mode: before.mode }; +} + +function readBoundedFile(filePath: string, options: BoundedRegularFileOptions): StableRead { + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + return readStableDescriptor(descriptor, options); + } finally { + fs.closeSync(descriptor); + } +} + +function isWithinTrustedExecutableRoot(filePath: string): boolean { + return ["/bin", "/usr/bin", "/usr/local/bin"].some( + (root) => filePath === root || filePath.startsWith(`${root}${path.sep}`), + ); +} + +function validateTrustedInterpreterPath(interpreter: string, label: string): void { + let resolved: string; + let stat: fs.Stats; + try { + resolved = fs.realpathSync(interpreter); + stat = fs.statSync(resolved); + } catch { + throw new Error(`${label} uses an unavailable interpreter`); + } + if (!stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) { + throw new Error(`${label} uses an untrusted interpreter`); + } + + // Production CUA runs on Linux. Its script interpreter must be rooted in the + // immutable host toolchain and must not be replaceable by the invoking user. + // Non-Linux contributor tests may use the exact Node binary already executing + // this process; no other user-owned interpreter is accepted. + if (process.platform === "linux") { + if (stat.uid !== 0 || !isWithinTrustedExecutableRoot(resolved)) { + throw new Error(`${label} uses an untrusted interpreter`); + } + } else if ( + resolved !== fs.realpathSync(process.execPath) && + !isWithinTrustedExecutableRoot(resolved) + ) { + throw new Error(`${label} uses an untrusted interpreter`); + } +} + +function validateDirectInterpreter(contents: Buffer, label: string): void { + if (contents.length < 2 || contents[0] !== 0x23 || contents[1] !== 0x21) return; + const lineEnd = contents.indexOf(0x0a, 2); + const shebang = contents.subarray(2, lineEnd === -1 ? contents.length : lineEnd).toString("utf8"); + if (/[^\x20-\x7e\t]/u.test(shebang)) { + throw new Error(`${label} uses an unsupported interpreter`); + } + const words = shebang.trim().split(/[\t ]+/u); + const interpreter = words[0] ?? ""; + if (words.length !== 1 || !path.isAbsolute(interpreter) || path.basename(interpreter) === "env") { + throw new Error(`${label} uses an unsupported interpreter`); + } + validateTrustedInterpreterPath(interpreter, label); +} + +/** Read one regular, non-symlink file without allocating beyond its declared bound. */ +export function readBoundedRegularFile( + filePath: string, + options: BoundedRegularFileOptions, +): Buffer { + return readBoundedFile(filePath, options).contents; +} + +/** + * Copy one executable into a private directory and bind the copy to its source bytes. + * + * The source is opened without following links and its identity, size, mode, and + * modification timestamps must remain stable for the complete read. Script + * interpreters must be direct absolute paths; `/usr/bin/env` would reintroduce + * caller-controlled executable resolution after the byte digest is checked. + */ +export function snapshotBoundedExecutable( + filePath: string, + options: BoundedExecutableSnapshotOptions, +): BoundedExecutableSnapshot { + const source = readBoundedFile(filePath, options); + if ((source.mode & 0o111n) === 0n) { + throw new Error(`${options.label} must be executable`); + } + validateDirectInterpreter(source.contents, options.label); + + const executableDigest = `sha256:${crypto.createHash("sha256").update(source.contents).digest("hex")}`; + if (options.expectedDigest !== undefined && executableDigest !== options.expectedDigest) { + throw new Error(`${options.label} does not match its expected digest`); + } + + let directory: string | undefined; + try { + directory = fs.mkdtempSync(path.join(os.tmpdir(), options.temporaryDirectoryPrefix)); + fs.chmodSync(directory, 0o700); + const homeDirectory = path.join(directory, "home"); + const temporaryDirectory = path.join(directory, "tmp"); + fs.mkdirSync(homeDirectory, { mode: 0o700 }); + fs.mkdirSync(temporaryDirectory, { mode: 0o700 }); + + const sourceExtension = path.extname(filePath); + const executableExtension = [".cjs", ".js", ".mjs"].includes(sourceExtension) + ? sourceExtension + : ""; + const executable = path.join(directory, `executable${executableExtension}`); + const descriptor = fs.openSync( + executable, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o500, + ); + try { + fs.fchmodSync(descriptor, 0o500); + let offset = 0; + while (offset < source.contents.length) { + offset += fs.writeSync( + descriptor, + source.contents, + offset, + source.contents.length - offset, + ); + } + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + + const snapshotDirectory = directory; + return { + executable, + executableDigest, + homeDirectory, + temporaryDirectory, + cleanup: () => fs.rmSync(snapshotDirectory, { recursive: true, force: true }), + }; + } catch (error) { + if (directory) fs.rmSync(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/src/lib/cua/build-identity.test.ts b/src/lib/cua/build-identity.test.ts new file mode 100644 index 00000000000..ed0b4d3be59 --- /dev/null +++ b/src/lib/cua/build-identity.test.ts @@ -0,0 +1,393 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +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, vi } from "vitest"; + +import { createCuaBuildIdentityStamp, resolveCurrentCuaBuildIdentity } from "./build-identity"; + +const directories: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + while (directories.length > 0) { + fs.rmSync(directories.pop()!, { recursive: true, force: true }); + } +}); + +function trustedGit(root: string, args: string[]): string { + return execFileSync( + "/usr/bin/git", + [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "commit.gpgsign=false", + ...args, + ], + { + cwd: root, + encoding: "utf8", + env: { + PATH: "/usr/bin:/bin", + HOME: root, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "CUA Build Identity Test", + GIT_AUTHOR_EMAIL: "cua-build-identity@example.invalid", + GIT_COMMITTER_NAME: "CUA Build Identity Test", + GIT_COMMITTER_EMAIL: "cua-build-identity@example.invalid", + }, + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); +} + +function cleanCheckout(): { root: string; sourceRevision: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-clean-git-")); + directories.push(root); + trustedGit(root, ["init", "--quiet"]); + fs.writeFileSync(path.join(root, "README.md"), "clean CUA checkout\n"); + trustedGit(root, ["add", "--", "README.md"]); + trustedGit(root, ["commit", "--quiet", "-m", "test: clean checkout"]); + return { root, sourceRevision: trustedGit(root, ["rev-parse", "--verify", "HEAD"]) }; +} + +function checkoutWithGitlink(): { + root: string; + nested: string; + sourceRevision: string; + nestedRevision: string; +} { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-gitlink-")); + directories.push(root); + trustedGit(root, ["init", "--quiet"]); + const nested = path.join(root, "nested"); + fs.mkdirSync(nested); + trustedGit(nested, ["init", "--quiet"]); + fs.writeFileSync(path.join(nested, "tracked.txt"), "exact nested source\n"); + trustedGit(nested, ["add", "--", "tracked.txt"]); + trustedGit(nested, ["commit", "--quiet", "-m", "test: nested checkout"]); + const nestedRevision = trustedGit(nested, ["rev-parse", "--verify", "HEAD"]); + fs.writeFileSync(path.join(root, "README.md"), "checkout with gitlink\n"); + trustedGit(root, ["add", "--", "README.md"]); + trustedGit(root, ["update-index", "--add", "--cacheinfo", `160000,${nestedRevision},nested`]); + trustedGit(root, ["commit", "--quiet", "-m", "test: checkout with gitlink"]); + return { + root, + nested, + nestedRevision, + sourceRevision: trustedGit(root, ["rev-parse", "--verify", "HEAD"]), + }; +} + +function packagedBuild(sourceRevision = "c".repeat(40)): { + root: string; + stampPath: string; + sourceRevision: string; +} { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-packaged-build-")); + directories.push(root); + const dist = path.join(root, "dist"); + fs.mkdirSync(dist, { mode: 0o755 }); + fs.writeFileSync( + path.join(dist, "build-identity.json"), + JSON.stringify({ nemoclawVersion: "0.1.0", sourceRevision }), + { mode: 0o644 }, + ); + const stampPath = path.join(dist, "cua-build-identity.json"); + fs.writeFileSync( + stampPath, + JSON.stringify({ schemaVersion: 1, sourceRevision, sourceClean: true }), + { mode: 0o644 }, + ); + return { root, stampPath, sourceRevision }; +} + +describe("CUA build identity", () => { + it("treats Git inspection failure as unproven rather than clean (#7755)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-no-git-")); + directories.push(root); + + expect(createCuaBuildIdentityStamp(root, "a".repeat(40))).toEqual({ + schemaVersion: 1, + sourceRevision: "a".repeat(40), + sourceClean: false, + }); + }); + + it("does not let an ambient PATH Git substitute claim a clean build (#7755)", () => { + const checkout = cleanCheckout(); + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-fake-git-")); + directories.push(fakeBin); + const fakeGit = path.join(fakeBin, "git"); + fs.writeFileSync(fakeGit, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + vi.stubEnv("PATH", `${fakeBin}:/usr/bin:/bin`); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision)).toEqual({ + schemaVersion: 1, + sourceRevision: checkout.sourceRevision, + sourceClean: true, + }); + }); + + it.each([ + "--assume-unchanged", + "--skip-worktree", + ])("rejects the real Git %s concealment flag before and after a tracked-byte change (#7755)", (flag) => { + const checkout = cleanCheckout(); + trustedGit(checkout.root, ["update-index", flag, "--", "README.md"]); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + + fs.writeFileSync(path.join(checkout.root, "README.md"), "concealed CUA source\n"); + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it("rejects staged index bytes that do not match the exact source revision (#7755)", () => { + const checkout = cleanCheckout(); + fs.writeFileSync(path.join(checkout.root, "README.md"), "staged CUA source\n"); + trustedGit(checkout.root, ["add", "--", "README.md"]); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it("rejects a real Git replacement that makes evil index and worktree bytes appear clean (#7755)", () => { + const checkout = cleanCheckout(); + fs.writeFileSync(path.join(checkout.root, "README.md"), "replacement-controlled source\n"); + trustedGit(checkout.root, ["add", "--", "README.md"]); + trustedGit(checkout.root, ["commit", "--quiet", "-m", "test: replacement source"]); + const replacementRevision = trustedGit(checkout.root, ["rev-parse", "--verify", "HEAD"]); + trustedGit(checkout.root, ["replace", checkout.sourceRevision, replacementRevision]); + trustedGit(checkout.root, ["update-ref", "HEAD", checkout.sourceRevision]); + + expect(trustedGit(checkout.root, ["status", "--porcelain=v1"])).toBe(""); + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it.each([ + ["0664", 0o664], + ["0646", 0o646], + ])("rejects unsafe tracked regular-file mode %s even when Git ignores it (#7755)", (_label, mode) => { + const checkout = cleanCheckout(); + fs.chmodSync(path.join(checkout.root, "README.md"), mode); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it.each([ + ["set-user-ID", 0o4000n], + ["set-group-ID", 0o2000n], + ["sticky", 0o1000n], + ])("rejects a tracked regular file with the %s authority bit (#7755)", (_label, bit) => { + const checkout = cleanCheckout(); + const originalFstat = fs.fstatSync; + vi.spyOn(fs, "fstatSync").mockImplementation(((handle: number, ...args: unknown[]) => { + const stat = Reflect.apply(originalFstat, fs, [handle, ...args]) as fs.BigIntStats; + return new Proxy(stat, { + get(target, property) { + const value = + property === "mode" + ? target.mode | bit + : (Reflect.get(target, property, target) as unknown); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + }) as typeof fs.fstatSync); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it("accepts a read-only tracked file with the exact non-executable HEAD mode (#7755)", () => { + const checkout = cleanCheckout(); + fs.chmodSync(path.join(checkout.root, "README.md"), 0o444); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + true, + ); + }); + + it("verifies a materialized Git LFS file against the exact committed pointer (#7755)", () => { + const checkout = cleanCheckout(); + const payload = Buffer.from("exact digest-bound LFS payload\n"); + const pointer = [ + "version https://git-lfs.github.com/spec/v1", + `oid sha256:${createHash("sha256").update(payload).digest("hex")}`, + `size ${payload.byteLength}`, + "", + ].join("\n"); + const artifact = path.join(checkout.root, "artifact.pt"); + fs.writeFileSync(artifact, pointer); + trustedGit(checkout.root, ["add", "--", "artifact.pt"]); + trustedGit(checkout.root, ["commit", "--quiet", "-m", "test: add LFS pointer"]); + checkout.sourceRevision = trustedGit(checkout.root, ["rev-parse", "--verify", "HEAD"]); + fs.writeFileSync(artifact, payload); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + true, + ); + + const alteredPayload = Buffer.from(payload); + alteredPayload[0] = alteredPayload[0] === 0x65 ? 0x45 : 0x65; + fs.writeFileSync(artifact, alteredPayload); + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it("rejects an uninitialized Git link even when its directory is exactly empty (#7755)", () => { + const checkout = checkoutWithGitlink(); + fs.rmSync(checkout.nested, { recursive: true, force: true }); + fs.mkdirSync(checkout.nested); + + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + + fs.writeFileSync(path.join(checkout.nested, "untracked.txt"), "hidden source\n"); + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it("recursively rejects dirty or wrong-revision initialized Git links (#7755)", () => { + const checkout = checkoutWithGitlink(); + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + true, + ); + + fs.writeFileSync(path.join(checkout.nested, "tracked.txt"), "dirty nested source\n"); + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + + fs.writeFileSync(path.join(checkout.nested, "tracked.txt"), "later nested source\n"); + trustedGit(checkout.nested, ["add", "--", "tracked.txt"]); + trustedGit(checkout.nested, ["commit", "--quiet", "-m", "test: wrong nested revision"]); + expect(createCuaBuildIdentityStamp(checkout.root, checkout.sourceRevision).sourceClean).toBe( + false, + ); + }); + + it("accepts only a closed injectable exact-build identity in unit tests (#7755)", () => { + expect( + resolveCurrentCuaBuildIdentity({ + buildIdentity: { + schemaVersion: 1, + sourceRevision: "b".repeat(40), + sourceClean: true, + }, + }), + ).toEqual({ + schemaVersion: 1, + sourceRevision: "b".repeat(40), + sourceClean: true, + }); + expect(() => + resolveCurrentCuaBuildIdentity({ + buildIdentity: { + schemaVersion: 1, + sourceRevision: "main", + sourceClean: true, + }, + }), + ).toThrow(/invalid/); + }); + + it("rejects a writable packaged cleanliness stamp (#7755)", () => { + const packaged = packagedBuild(); + fs.chmodSync(packaged.stampPath, 0o666); + + expect(() => resolveCurrentCuaBuildIdentity({ rootDir: packaged.root })).toThrow( + "CUA build cleanliness could not be proven", + ); + }); + + it("rejects a symbolic-link packaged cleanliness stamp before reading it (#7755)", () => { + const packaged = packagedBuild(); + const target = path.join(packaged.root, "forged-stamp.json"); + fs.writeFileSync( + target, + JSON.stringify({ + schemaVersion: 1, + sourceRevision: packaged.sourceRevision, + sourceClean: true, + }), + { mode: 0o644 }, + ); + fs.rmSync(packaged.stampPath); + fs.symlinkSync(target, packaged.stampPath); + + expect(() => resolveCurrentCuaBuildIdentity({ rootDir: packaged.root })).toThrow( + "CUA build cleanliness could not be proven", + ); + }); + + it("rejects an oversized packaged cleanliness stamp before allocation (#7755)", () => { + const packaged = packagedBuild(); + fs.truncateSync(packaged.stampPath, 1025); + + expect(() => + resolveCurrentCuaBuildIdentity({ + rootDir: packaged.root, + assertPackagedStampAuthority: () => undefined, + }), + ).toThrow("CUA build cleanliness could not be proven"); + }); + + it("does not fall back to a clean packaged stamp when live Git inspection fails (#7755)", () => { + const packaged = packagedBuild(); + fs.mkdirSync(path.join(packaged.root, ".git")); + + expect( + resolveCurrentCuaBuildIdentity({ + rootDir: packaged.root, + assertPackagedStampAuthority: () => undefined, + }), + ).toEqual({ + schemaVersion: 1, + sourceRevision: packaged.sourceRevision, + sourceClean: false, + }); + }); + + it("rejects a user-owned grandparent in the Linux packaged authority path (#7755)", () => { + const packaged = packagedBuild(); + const originalLstat = fs.lstatSync; + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + vi.spyOn(fs, "lstatSync").mockImplementation(((target: fs.PathLike, ...args: unknown[]) => { + const stat = Reflect.apply(originalLstat, fs, [target, ...args]) as fs.Stats; + const resolved = path.resolve(String(target)); + const uid = resolved === packaged.root ? 501 : 0; + return new Proxy(stat, { + get(value, property, receiver) { + return property === "uid" ? uid : Reflect.get(value, property, receiver); + }, + }); + }) as typeof fs.lstatSync); + + expect(() => resolveCurrentCuaBuildIdentity({ rootDir: packaged.root })).toThrow( + "CUA build cleanliness could not be proven", + ); + }); +}); diff --git a/src/lib/cua/build-identity.ts b/src/lib/cua/build-identity.ts new file mode 100644 index 00000000000..3d3e6683856 --- /dev/null +++ b/src/lib/cua/build-identity.ts @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { getBuildIdentity } from "../core/version"; +import { readBoundedRegularFile } from "./bounded-file"; + +const COMMIT = /^[0-9a-f]{40}$/; +const TRUSTED_GIT_EXECUTABLE = "/usr/bin/git"; +const TRUSTED_GIT_CONFIG = [ + "--no-replace-objects", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", +] as const; +export const CUA_BUILD_IDENTITY_FILE = "cua-build-identity.json"; +const MAX_CUA_BUILD_IDENTITY_BYTES = 1024; +const MAX_TRACKED_SOURCE_BYTES = 64 * 1024 * 1024; +const MAX_GIT_METADATA_BYTES = 16 * 1024 * 1024; +const MAX_GIT_BATCH_BYTES = 32 * 1024 * 1024; +const MAX_GIT_BATCH_OBJECTS = 512; +const GIT_LFS_POINTER = + /^version https:\/\/git-lfs\.github\.com\/spec\/v1\noid sha256:([0-9a-f]{64})\nsize ([0-9]+)\n$/; + +export interface CuaBuildIdentity { + schemaVersion: 1; + sourceRevision: string; + sourceClean: boolean; +} + +function gitEnvironment(root: string): NodeJS.ProcessEnv { + return { + PATH: "/usr/bin:/bin", + HOME: root, + LANG: "C", + LC_ALL: "C", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_NO_REPLACE_OBJECTS: "1", + }; +} + +function runTrustedGit( + root: string, + args: readonly string[], + maxBuffer = MAX_GIT_METADATA_BYTES, +): Buffer { + return execFileSync(TRUSTED_GIT_EXECUTABLE, [...TRUSTED_GIT_CONFIG, ...args], { + cwd: root, + env: gitEnvironment(root), + maxBuffer, + stdio: ["ignore", "pipe", "ignore"], + }); +} + +function runTrustedGitWithInput( + root: string, + args: readonly string[], + input: Buffer, + maxBuffer: number, +): Buffer { + return execFileSync(TRUSTED_GIT_EXECUTABLE, [...TRUSTED_GIT_CONFIG, ...args], { + cwd: root, + env: gitEnvironment(root), + input, + maxBuffer, + stdio: ["pipe", "pipe", "ignore"], + }); +} + +interface TrackedBlob { + filePath: string; + mode: string; + object: string; + size: number; +} + +function authoritativeBlobBatch(root: string, blobs: readonly TrackedBlob[]): Buffer[] { + const input = Buffer.from(`${blobs.map(({ object }) => object).join("\n")}\n`, "ascii"); + const expectedBytes = blobs.reduce((total, { size }) => total + size, 0); + const output = runTrustedGitWithInput( + root, + ["cat-file", "--batch"], + input, + expectedBytes + MAX_GIT_METADATA_BYTES, + ); + const authoritative: Buffer[] = []; + let offset = 0; + for (const blob of blobs) { + const headerEnd = output.indexOf(0x0a, offset); + if (headerEnd < 0) throw new Error("CUA Git blob batch has an invalid header"); + const header = output.subarray(offset, headerEnd).toString("ascii"); + if (header !== `${blob.object} blob ${blob.size}`) { + throw new Error("CUA Git blob batch does not match the exact commit tree"); + } + const contentStart = headerEnd + 1; + const contentEnd = contentStart + blob.size; + if (contentEnd >= output.byteLength || output[contentEnd] !== 0x0a) { + throw new Error("CUA Git blob batch has an invalid content boundary"); + } + authoritative.push(output.subarray(contentStart, contentEnd)); + offset = contentEnd + 1; + } + if (offset !== output.byteLength) throw new Error("CUA Git blob batch has trailing output"); + return authoritative; +} + +function trackedPath(root: string, rawPath: Buffer): string { + const relative = rawPath.toString("utf8"); + if ( + relative.length === 0 || + !Buffer.from(relative, "utf8").equals(rawPath) || + path.isAbsolute(relative) || + path.normalize(relative) !== relative || + relative.split(path.sep).includes("..") + ) { + throw new Error("CUA source contains an unsupported tracked path"); + } + return path.join(root, relative); +} + +function regularFileMatches( + filePath: string, + expectedExecutable: boolean, + authoritative: Buffer, +): boolean { + const lfsPointer = GIT_LFS_POINTER.exec(authoritative.toString("ascii")); + const lfsSize = lfsPointer?.[2] === undefined ? null : Number(lfsPointer[2]); + if ( + lfsPointer !== null && + (!Number.isSafeInteger(lfsSize) || + lfsSize === null || + lfsSize < 0 || + lfsSize > MAX_TRACKED_SOURCE_BYTES) + ) { + return false; + } + const handle = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + try { + const before = fs.fstatSync(handle, { bigint: true }); + if ( + !before.isFile() || + (before.mode & 0o7022n) !== 0n || + ((before.mode & 0o111n) !== 0n) !== expectedExecutable || + before.size > BigInt(Number.MAX_SAFE_INTEGER) || + (lfsSize !== null && before.size !== BigInt(lfsSize)) + ) { + throw new Error("CUA tracked file type or mode does not match HEAD"); + } + const size = Number(before.size); + const buffer = Buffer.allocUnsafe(64 * 1024); + const hash = lfsPointer === null ? null : createHash("sha256"); + let position = 0; + while (position < size) { + const length = fs.readSync( + handle, + buffer, + 0, + Math.min(buffer.byteLength, size - position), + position, + ); + if (length === 0) throw new Error("CUA tracked file changed while it was inspected"); + const chunk = buffer.subarray(0, length); + if (hash !== null) { + hash.update(chunk); + } else if (!chunk.equals(authoritative.subarray(position, position + length))) { + return false; + } + position += length; + } + const after = fs.fstatSync(handle, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.nlink !== after.nlink || + before.uid !== after.uid || + before.gid !== after.gid || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + throw new Error("CUA tracked file changed while it was inspected"); + } + return hash === null + ? authoritative.byteLength === size + : hash.digest("hex") === lfsPointer?.[1]; + } finally { + fs.closeSync(handle); + } +} + +function symbolicLinkMatches(filePath: string, authoritative: Buffer): boolean { + const before = fs.lstatSync(filePath, { bigint: true }); + if (!before.isSymbolicLink()) throw new Error("CUA tracked link type does not match HEAD"); + const target = fs.readlinkSync(filePath, { encoding: "buffer" }); + const after = fs.lstatSync(filePath, { bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.mode !== after.mode || + before.nlink !== after.nlink || + before.uid !== after.uid || + before.gid !== after.gid || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + BigInt(target.byteLength) !== before.size + ) { + throw new Error("CUA tracked link changed while it was inspected"); + } + return target.equals(authoritative); +} + +function trackedFilesystemMatchesHead(root: string, sourceRevision: string): boolean { + const tree = runTrustedGit(root, ["ls-tree", "-lrz", "--full-tree", sourceRevision]); + const blobs: TrackedBlob[] = []; + for (const rawEntry of tree.subarray(0, -1).toString("binary").split("\0")) { + const entry = Buffer.from(rawEntry, "binary"); + const separator = entry.indexOf(0x09); + if (separator < 0) return false; + const metadata = /^([0-9]{6}) (blob|commit) ([0-9a-f]{40}) +(-|[0-9]+)$/.exec( + entry.subarray(0, separator).toString("ascii"), + ); + if (!metadata) return false; + const mode = metadata[1]; + const type = metadata[2]; + const object = metadata[3]; + const rawSize = metadata[4]; + if (mode === undefined || type === undefined || object === undefined || rawSize === undefined) { + return false; + } + const filePath = trackedPath(root, entry.subarray(separator + 1)); + + if (type === "commit" && mode === "160000") { + if (!fs.lstatSync(filePath).isDirectory()) return false; + const entries = fs.readdirSync(filePath); + if (entries.length === 0) return false; + const gitMarker = path.join(filePath, ".git"); + const marker = fs.lstatSync(gitMarker); + if ((!marker.isFile() && !marker.isDirectory()) || marker.isSymbolicLink()) return false; + if (inspectGitCheckout(filePath, object) !== true) return false; + continue; + } + if (type !== "blob") return false; + if (!/^[0-9]+$/.test(rawSize ?? "")) return false; + const size = Number(rawSize); + if (!Number.isSafeInteger(size) || size < 0 || size > MAX_TRACKED_SOURCE_BYTES) return false; + if (mode !== "100644" && mode !== "100755" && mode !== "120000") return false; + blobs.push({ filePath, mode, object, size }); + } + for (let start = 0; start < blobs.length; ) { + let end = start; + let bytes = 0; + while (end < blobs.length && end - start < MAX_GIT_BATCH_OBJECTS) { + const next = blobs[end]; + if (next === undefined) return false; + if (end > start && bytes + next.size > MAX_GIT_BATCH_BYTES) break; + bytes += next.size; + end += 1; + } + const batch = blobs.slice(start, end); + const authoritative = authoritativeBlobBatch(root, batch); + for (const [index, blob] of batch.entries()) { + const expected = authoritative[index]; + if (expected === undefined || expected.byteLength !== blob.size) return false; + if (blob.mode === "120000") { + if (!symbolicLinkMatches(blob.filePath, expected)) return false; + } else if (!regularFileMatches(blob.filePath, blob.mode === "100755", expected)) { + return false; + } + } + start = end; + } + return true; +} + +function inspectGitCheckout(root: string, sourceRevision: string): boolean | null { + try { + const topLevel = runTrustedGit(root, ["rev-parse", "--show-toplevel"]).toString("utf8").trim(); + if (fs.realpathSync(topLevel) !== fs.realpathSync(root)) return false; + if (runTrustedGit(root, ["for-each-ref", "--format=%(refname)", "refs/replace/"]).length) { + return false; + } + const head = runTrustedGit(root, ["rev-parse", "--verify", "HEAD"]).toString("utf8").trim(); + if (head !== sourceRevision) return false; + const flags = runTrustedGit(root, ["ls-files", "-v", "-z"]); + for (const entry of flags.subarray(0, -1).toString("binary").split("\0")) { + const tag = entry[0]; + if (tag === "S" || (tag !== undefined && tag >= "a" && tag <= "z")) return false; + } + const indexDiff = runTrustedGit(root, [ + "diff-index", + "--cached", + "--name-only", + "-z", + sourceRevision, + "--", + ]); + if (indexDiff.length !== 0) return false; + if (!trackedFilesystemMatchesHead(root, sourceRevision)) return false; + const untracked = runTrustedGit(root, ["ls-files", "--others", "--exclude-standard", "-z"]); + return untracked.length === 0; + } catch { + return null; + } +} + +function hasGitMarker(root: string): boolean { + try { + fs.lstatSync(path.join(root, ".git")); + return true; + } catch { + return false; + } +} + +function parseStamp(value: unknown): CuaBuildIdentity { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("CUA build identity must be an object"); + } + const record = value as Record; + if (Object.keys(record).sort().join("\0") !== "schemaVersion\0sourceClean\0sourceRevision") { + throw new Error("CUA build identity contains unsupported fields"); + } + if ( + record.schemaVersion !== 1 || + typeof record.sourceRevision !== "string" || + !COMMIT.test(record.sourceRevision) || + typeof record.sourceClean !== "boolean" + ) { + throw new Error("CUA build identity is invalid"); + } + return record as unknown as CuaBuildIdentity; +} + +function assertPackagedStampAuthority(filePath: string): void { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) { + throw new Error( + "CUA packaged build identity must be a regular authority file without group/world write access", + ); + } + const parent = fs.lstatSync(path.dirname(filePath)); + if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o022) !== 0) { + throw new Error( + "CUA packaged build identity parent must be a trusted directory without group/world write access", + ); + } + if (process.platform === "linux") { + if (stat.uid !== 0) { + throw new Error("CUA packaged build identity must be installed under root-owned authority"); + } + let ancestor = path.dirname(filePath); + while (true) { + const ancestorStat = fs.lstatSync(ancestor); + if ( + !ancestorStat.isDirectory() || + ancestorStat.isSymbolicLink() || + ancestorStat.uid !== 0 || + (ancestorStat.mode & 0o022) !== 0 + ) { + throw new Error( + "CUA packaged build identity must have a root-owned immutable authority path", + ); + } + if (ancestor === path.parse(ancestor).root) break; + ancestor = path.dirname(ancestor); + } + } +} + +/** Build-time stamp; a Git failure is unknown and therefore not clean. */ +export function createCuaBuildIdentityStamp( + root: string, + sourceRevision: string, +): CuaBuildIdentity { + if (!COMMIT.test(sourceRevision)) { + throw new Error("CUA requires an exact lowercase 40-character source revision"); + } + return { + schemaVersion: 1, + sourceRevision, + sourceClean: inspectGitCheckout(root, sourceRevision) === true, + }; +} + +export interface ResolveCuaBuildIdentityOptions { + rootDir?: string; + buildIdentity?: CuaBuildIdentity; + /** Test seam for packaged authority metadata; production always uses the strict validator. */ + assertPackagedStampAuthority?: (filePath: string) => void; +} + +/** + * Resolve an exact CUA build identity without changing the public NemoClaw version shape. + * A live Git checkout is re-observed; packaged installs use the build-time CUA-only stamp. + */ +export function resolveCurrentCuaBuildIdentity( + options: ResolveCuaBuildIdentityOptions = {}, +): CuaBuildIdentity { + if (options.buildIdentity) return parseStamp(options.buildIdentity); + const root = options.rootDir ?? path.resolve(__dirname, "..", "..", ".."); + const sourceRevision = getBuildIdentity({ rootDir: root }).sourceRevision; + if (!COMMIT.test(sourceRevision)) { + throw new Error("CUA requires an exact lowercase 40-character source revision"); + } + const liveClean = inspectGitCheckout(root, sourceRevision); + if (liveClean !== null || hasGitMarker(root)) { + return { schemaVersion: 1, sourceRevision, sourceClean: liveClean === true }; + } + + const stampPath = path.join(root, "dist", CUA_BUILD_IDENTITY_FILE); + let stamp: CuaBuildIdentity; + try { + (options.assertPackagedStampAuthority ?? assertPackagedStampAuthority)(stampPath); + const raw = readBoundedRegularFile(stampPath, { + label: "CUA packaged build identity", + minBytes: 2, + maxBytes: MAX_CUA_BUILD_IDENTITY_BYTES, + }); + stamp = parseStamp(JSON.parse(raw.toString("utf8")) as unknown); + } catch { + throw new Error("CUA build cleanliness could not be proven"); + } + if (stamp.sourceRevision !== sourceRevision) { + throw new Error("CUA build identity does not match the executing NemoClaw build"); + } + return stamp; +} diff --git a/src/lib/cua/contract.test.ts b/src/lib/cua/contract.test.ts new file mode 100644 index 00000000000..8270992ed9d --- /dev/null +++ b/src/lib/cua/contract.test.ts @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { type CuaRuntimeReadiness, getCuaRuntimeReadinessDigest } from "./contract"; +import { parseCuaRuntimeReadiness } from "./schema"; + +const digest = (character: string) => `sha256:${character.repeat(64)}`; + +function readiness(): CuaRuntimeReadiness { + const component = (name: string, character: string) => ({ + name, + version: "1.0.0", + digest: digest(character), + owner: "NVIDIA", + }); + return { + schemaVersion: "1.0.0", + kind: "runtime-readiness", + agent: "nemocua", + mode: "standalone", + status: "candidate", + sourceRevision: "a".repeat(40), + sourceClean: true, + runtimeManifestDigest: digest("b"), + providerAuthorityDigest: digest("c"), + qualification: { + state: "candidate", + environmentDigest: digest("d"), + bundleReceiptDigest: digest("e"), + }, + components: { + openshell: component("openshell", "1"), + runtime: component("nemocua-runtime", "2"), + sandboxImage: component("nemocua-sandbox", "3"), + targetAdapter: component("target-adapter", "4"), + policy: component("nemocua-policy", "5"), + taskProtocol: component("task-protocol", "6"), + securityVerifier: component("security-verifier", "7"), + }, + inference: { + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + routeDigest: digest("8"), + }, + appliedPolicy: { revision: 2, digest: digest("9") }, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: ["browser", "computer", "terminal"], + targetOperations: [], + securityOperations: [], + taskOperations: [], + }; +} + +describe("CUA candidate runtime contract", () => { + it("accepts candidate readiness through the public parser (#7755)", () => { + expect(parseCuaRuntimeReadiness(readiness())).toEqual(readiness()); + }); + + it.each([ + "targetOperations", + "securityOperations", + "taskOperations", + ] as const)("rejects advertised %s before its cumulative slice exists (#7755)", (field) => { + const value = readiness() as unknown as Record; + value[field] = [field.replace("Operations", ".status")]; + expect(() => parseCuaRuntimeReadiness(value)).toThrow(/schema/); + }); + + it("keeps candidate state machine-distinct from unavailable states (#7755)", () => { + const missingEvidence = readiness(); + missingEvidence.qualification = null; + expect(() => parseCuaRuntimeReadiness(missingEvidence)).toThrow(/schema/); + + const unavailable = readiness(); + unavailable.status = "unavailable"; + unavailable.qualification = null; + expect(parseCuaRuntimeReadiness(unavailable).status).toBe("unavailable"); + }); + + it.each([ + ["provider", "ghp_abcdefghijklmnopqrstuvwxyz"], + ["provider", "https://provider.invalid"], + ["model", "sk-model"], + ["model", "nvidia/model?token=1"], + ] as const)("rejects credential or coordinate shaped inference %s (#7755)", (field, value) => { + const record = readiness(); + record.inference[field] = value; + expect(() => parseCuaRuntimeReadiness(record)).toThrow(/contract|schema/); + }); + + it("binds the empty operation sets in the whole-readiness digest (#7755)", () => { + const original = readiness(); + const changed = structuredClone(original) as unknown as Record; + changed.targetOperations = ["target.status"]; + expect(getCuaRuntimeReadinessDigest(original)).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(() => + getCuaRuntimeReadinessDigest(changed as unknown as CuaRuntimeReadiness), + ).not.toThrow(); + expect(getCuaRuntimeReadinessDigest(changed as unknown as CuaRuntimeReadiness)).not.toBe( + getCuaRuntimeReadinessDigest(original), + ); + }); + + it("rejects unknown public fields (#7755)", () => { + expect(() => + parseCuaRuntimeReadiness({ ...readiness(), endpoint: "https://private.invalid" }), + ).toThrow(/schema/); + }); +}); diff --git a/src/lib/cua/contract.ts b/src/lib/cua/contract.ts new file mode 100644 index 00000000000..b8e8b6c27df --- /dev/null +++ b/src/lib/cua/contract.ts @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isCredentialShapedName } from "../security/credential-env.js"; +import { + CUA_DOMAIN_COORDINATE, + CUA_HOST_COORDINATE, + CUA_SENSITIVE_VALUE, + canonicalJsonSha256, +} from "./shared-primitives"; + +export const CUA_LIFECYCLE_SCHEMA_VERSION = "1.0.0" as const; +export const SUPPORTED_CUA_LIFECYCLE_SCHEMA_MAJOR = 1; + +export const CUA_CAPABILITIES = ["browser", "computer", "terminal"] as const; +export type CuaCapability = (typeof CUA_CAPABILITIES)[number]; + +/** Later cumulative slices add operations only when their dispatch routes exist. */ +export const CUA_TARGET_OPERATIONS = [] as const; +export const CUA_SECURITY_OPERATIONS = [] as const; +export const CUA_TASK_OPERATIONS = [] as const; + +export interface CuaComponentIdentity { + name: string; + version: string; + digest: string; + owner: string; +} + +export interface CuaInferenceIdentity { + provider: string; + model: string; + /** Secret-free identity of the complete managed inference route. */ + routeDigest: string; +} + +/** Content-free identity of the effective OpenShell policy applied to one sandbox. */ +export interface CuaAppliedPolicyIdentity { + revision: number; + digest: string; +} + +export interface CuaCapabilityIdentity { + id: CuaCapability; + protocolVersion: string; +} + +export interface CuaRuntimeReadiness { + schemaVersion: typeof CUA_LIFECYCLE_SCHEMA_VERSION; + kind: "runtime-readiness"; + agent: "nemocua"; + mode: "standalone"; + status: "candidate" | "unavailable" | "incompatible"; + sourceRevision: string; + sourceClean: true; + runtimeManifestDigest: string; + providerAuthorityDigest: string; + qualification: { + state: "candidate"; + environmentDigest: string; + bundleReceiptDigest: string; + } | null; + components: { + openshell: CuaComponentIdentity; + runtime: CuaComponentIdentity; + sandboxImage: CuaComponentIdentity; + targetAdapter: CuaComponentIdentity; + policy: CuaComponentIdentity; + taskProtocol: CuaComponentIdentity; + securityVerifier: CuaComponentIdentity; + }; + inference: CuaInferenceIdentity; + appliedPolicy: CuaAppliedPolicyIdentity; + commands: { + interactive: true; + headless: true; + version: true; + smoke: true; + }; + limits: { + targetsPerWorker: 1; + activeTasksPerTarget: 1; + }; + requiredCapabilities: readonly CuaCapability[]; + targetOperations: readonly []; + securityOperations: readonly []; + taskOperations: readonly []; +} + +export type CuaLifecycleRecord = CuaRuntimeReadiness; + +/** Content identity used to reject state replay across readiness changes. */ +export function getCuaRuntimeReadinessDigest(readiness: CuaRuntimeReadiness): string { + return `sha256:${canonicalJsonSha256(readiness)}`; +} + +export type CuaSchemaCompatibility = + | { compatible: true; major: number } + | { compatible: false; major: number | null; reason: string }; + +export function checkCuaLifecycleSchemaVersion(schemaVersion: unknown): CuaSchemaCompatibility { + if (typeof schemaVersion !== "string") { + return { compatible: false, major: null, reason: "schemaVersion must be a string" }; + } + + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(schemaVersion); + if (!match) { + return { compatible: false, major: null, reason: "schemaVersion must use major.minor.patch" }; + } + + const major = Number(match[1]); + if (major !== SUPPORTED_CUA_LIFECYCLE_SCHEMA_MAJOR) { + return { + compatible: false, + major, + reason: `unsupported CUA lifecycle schema major ${String(major)}`, + }; + } + return { compatible: true, major }; +} + +function duplicateValues(values: readonly string[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + if (seen.has(value)) duplicates.add(value); + seen.add(value); + } + return [...duplicates].sort(); +} + +function exactSetErrors( + label: string, + actual: readonly string[], + expected: readonly string[], +): string[] { + const errors: string[] = []; + const duplicates = duplicateValues(actual); + if (duplicates.length > 0) { + errors.push(`${label} contains duplicate values: ${duplicates.join(", ")}`); + } + const unexpected = actual.filter((value) => !expected.includes(value)); + const missing = expected.filter((value) => !actual.includes(value)); + if (missing.length > 0) errors.push(`${label} is missing: ${missing.join(", ")}`); + if (unexpected.length > 0) { + errors.push(`${label} contains unsupported values: ${unexpected.join(", ")}`); + } + return errors; +} + +function credentialPathErrors(value: unknown, path = "$"): string[] { + if (Array.isArray(value)) { + return value.flatMap((entry, index) => + credentialPathErrors(entry, `${path}[${String(index)}]`), + ); + } + if (typeof value !== "object" || value === null) return []; + + const errors: string[] = []; + for (const [key, child] of Object.entries(value)) { + const childPath = `${path}.${key}`; + if (isCredentialShapedName(key)) { + errors.push(`${childPath} is credential-shaped and cannot enter the public CUA contract`); + } + errors.push(...credentialPathErrors(child, childPath)); + } + return errors; +} + +const CUA_PROVIDER_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const CUA_MODEL_SELECTOR = + /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}){0,7}$/; +const CUA_COMPONENT_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const CUA_COMPONENT_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/; + +export function getCuaComponentIdentityErrors( + component: CuaComponentIdentity, + path: string, +): string[] { + const fields = [ + ["name", component.name, CUA_COMPONENT_IDENTITY], + ["version", component.version, CUA_COMPONENT_VERSION], + ["owner", component.owner, CUA_COMPONENT_IDENTITY], + ] as const; + return fields.flatMap(([field, value, pattern]) => + pattern.test(value) && !CUA_SENSITIVE_VALUE.test(value) && !CUA_HOST_COORDINATE.test(value) + ? [] + : [`${path}.${field} must be a printable coordinate- and credential-free identity`], + ); +} + +export function getCuaCoordinateFreeSelectorErrors(value: string, path: string): string[] { + return CUA_MODEL_SELECTOR.test(value) && + !CUA_SENSITIVE_VALUE.test(value) && + !CUA_HOST_COORDINATE.test(value) + ? [] + : [`${path} must be a printable coordinate- and credential-free selector`]; +} + +function inferenceIdentityErrors(inference: CuaInferenceIdentity): string[] { + const errors: string[] = []; + if ( + !CUA_PROVIDER_IDENTITY.test(inference.provider) || + CUA_SENSITIVE_VALUE.test(inference.provider) || + CUA_HOST_COORDINATE.test(inference.provider) || + CUA_DOMAIN_COORDINATE.test(inference.provider) + ) { + errors.push("inference.provider must be a printable credential-free identity"); + } + if (getCuaCoordinateFreeSelectorErrors(inference.model, "inference.model").length > 0) { + errors.push("inference.model must be a printable coordinate-free model selector"); + } + if (!/^sha256:[a-f0-9]{64}$/.test(inference.routeDigest)) { + errors.push("inference.routeDigest must be a sha256 digest"); + } + return errors; +} + +/** Validate the Slice 1 invariants that JSON Schema cannot express. */ +export function getCuaLifecycleSemanticErrors(record: CuaRuntimeReadiness): string[] { + const errors = [...credentialPathErrors(record), ...inferenceIdentityErrors(record.inference)]; + const compatibility = checkCuaLifecycleSchemaVersion(record.schemaVersion); + if (!compatibility.compatible) errors.push(compatibility.reason); + for (const [name, component] of Object.entries(record.components)) { + errors.push(...getCuaComponentIdentityErrors(component, `components.${name}`)); + } + errors.push( + ...exactSetErrors("requiredCapabilities", record.requiredCapabilities, CUA_CAPABILITIES), + ...exactSetErrors("targetOperations", record.targetOperations, CUA_TARGET_OPERATIONS), + ...exactSetErrors("securityOperations", record.securityOperations, CUA_SECURITY_OPERATIONS), + ...exactSetErrors("taskOperations", record.taskOperations, CUA_TASK_OPERATIONS), + ); + if (record.status === "candidate" && record.qualification?.state !== "candidate") { + errors.push("candidate readiness requires candidate qualification identity"); + } + if ( + (record.status === "unavailable" || record.status === "incompatible") && + record.qualification !== null + ) { + errors.push(`${record.status} readiness cannot carry qualification authority`); + } + return errors; +} diff --git a/src/lib/cua/feature.test.ts b/src/lib/cua/feature.test.ts new file mode 100644 index 00000000000..61ee1c324e7 --- /dev/null +++ b/src/lib/cua/feature.test.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + CUA_FRAMEWORK_FEATURE_ENV, + CUA_QUALIFICATION_FEATURE_ENV, + isCuaFrameworkEnabled, + isCuaQualificationEnabled, + requireCuaFrameworkEnabled, +} from "./feature"; + +describe("CUA framework activation (#7750)", () => { + it("is disabled unless the dedicated CUA flag is exactly 1", () => { + expect(CUA_FRAMEWORK_FEATURE_ENV).toBe("NEMOCLAW_CUA_ENABLED"); + for (const value of [undefined, "", "true", "0", "01", " 1", "1 "]) { + expect(isCuaFrameworkEnabled({ NEMOCLAW_CUA_ENABLED: value })).toBe(false); + } + expect(isCuaFrameworkEnabled({ NEMOCLAW_CUA_ENABLED: "1" })).toBe(true); + expect(() => requireCuaFrameworkEnabled({})).toThrow( + "use the controlled Brev Launchable activation", + ); + expect(() => requireCuaFrameworkEnabled({ NEMOCLAW_CUA_ENABLED: "1" })).not.toThrow(); + }); + + it("requires a second explicit opt-in for candidate qualification", () => { + expect(CUA_QUALIFICATION_FEATURE_ENV).toBe("NEMOCLAW_CUA_QUALIFICATION"); + expect(isCuaQualificationEnabled({ NEMOCLAW_CUA_QUALIFICATION: "1" })).toBe(false); + for (const value of [undefined, "", "true", "0", "01", " 1", "1 "]) { + expect( + isCuaQualificationEnabled({ + NEMOCLAW_CUA_ENABLED: "1", + NEMOCLAW_CUA_QUALIFICATION: value, + }), + ).toBe(false); + } + expect( + isCuaQualificationEnabled({ + NEMOCLAW_CUA_ENABLED: "1", + NEMOCLAW_CUA_QUALIFICATION: "1", + }), + ).toBe(true); + }); +}); diff --git a/src/lib/cua/feature.ts b/src/lib/cua/feature.ts new file mode 100644 index 00000000000..c430daf3fe6 --- /dev/null +++ b/src/lib/cua/feature.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const CUA_FRAMEWORK_FEATURE_ENV = "NEMOCLAW_CUA_ENABLED" as const; +export const CUA_QUALIFICATION_FEATURE_ENV = "NEMOCLAW_CUA_QUALIFICATION" as const; +export const CUA_RUNTIME_MANIFEST_ENV = "NEMOCLAW_CUA_RUNTIME_MANIFEST" as const; +export const CUA_RUNTIME_MANIFEST_SHA256_ENV = "NEMOCLAW_CUA_RUNTIME_MANIFEST_SHA256" as const; +export const CUA_QUALIFICATION_ENVIRONMENT_ENV = "NEMOCLAW_CUA_QUALIFICATION_ENVIRONMENT" as const; +export const CUA_SANDBOX_IMAGE_ENV = "NEMOCLAW_CUA_SANDBOX_IMAGE_REF" as const; + +/** Keep executable CUA lifecycle surfaces fail-closed until explicitly enabled. */ +export function isCuaFrameworkEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[CUA_FRAMEWORK_FEATURE_ENV] === "1"; +} + +/** Refuse every CUA artifact or product-surface read before the default-off gate. */ +export function requireCuaFrameworkEnabled(env: NodeJS.ProcessEnv = process.env): void { + if (!isCuaFrameworkEnabled(env)) { + throw new Error("CUA is disabled; use the controlled Brev Launchable activation"); + } +} + +/** Candidate lifecycle authority is narrower than enabling the CUA surface. */ +export function isCuaQualificationEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return isCuaFrameworkEnabled(env) && env[CUA_QUALIFICATION_FEATURE_ENV] === "1"; +} diff --git a/src/lib/cua/lifecycle-readiness.test.ts b/src/lib/cua/lifecycle-readiness.test.ts new file mode 100644 index 00000000000..7596c55bd69 --- /dev/null +++ b/src/lib/cua/lifecycle-readiness.test.ts @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { SandboxEntry } from "../state/registry/types"; +import type { CuaRuntimeReadiness } from "./contract"; +import { CUA_FRAMEWORK_FEATURE_ENV, CUA_QUALIFICATION_FEATURE_ENV } from "./feature"; +import { + observeCuaLiveInference, + parseCuaAppliedPolicyIdentity, + parseCuaProviderAuthorityDigest, + requireCuaLifecycleReadiness, +} from "./lifecycle-readiness"; +import type { CuaRuntimeReadinessContext } from "./runtime-readiness"; + +const readiness = { kind: "runtime-readiness" } as CuaRuntimeReadiness; + +function entry(): SandboxEntry { + return { + name: "alpha", + agent: "nemocua", + provider: "recorded-provider", + model: "recorded/model", + endpointUrl: "https://inference.example/v1", + endpointSource: "onboard", + preferredInferenceApi: "openai-completions", + credentialEnv: "NVIDIA_API_KEY", + cuaRuntimeReadiness: readiness, + }; +} + +describe("CUA lifecycle readiness authority", () => { + const providerOutput = ( + overrides: { + id?: string; + version?: number; + name?: string; + type?: string; + credentialKeys?: string; + configKeys?: string; + } = {}, + ) => + [ + "Provider:", + ` Id: ${overrides.id ?? "provider-id"}`, + ` Name: ${overrides.name ?? "recorded-provider"}`, + ` Type: ${overrides.type ?? "openai"}`, + ` Resource version: ${String(overrides.version ?? 1)}`, + ` Credential keys: ${overrides.credentialKeys ?? "NVIDIA_API_KEY"}`, + ` Config keys: ${overrides.configKeys ?? "OPENAI_BASE_URL"}`, + ].join("\n"); + + it("binds the opaque authority digest to the exact live provider generation", () => { + const input = { + gatewayName: "nemoclaw-alpha", + providerName: "recorded-provider", + model: "recorded/model", + }; + const current = parseCuaProviderAuthorityDigest({ + ...input, + output: providerOutput(), + }); + + expect(current).toMatch(/^sha256:[a-f0-9]{64}$/); + expect( + parseCuaProviderAuthorityDigest({ + ...input, + output: providerOutput({ version: 2 }), + }), + ).not.toBe(current); + expect( + parseCuaProviderAuthorityDigest({ + ...input, + output: providerOutput({ id: "replacement-provider" }), + }), + ).not.toBe(current); + expect( + parseCuaProviderAuthorityDigest({ + ...input, + output: providerOutput({ configKeys: "OPENAI_BASE_URL, EXTRA_CONFIG" }), + }), + ).not.toBe(current); + }); + + it.each([ + ["missing version", providerOutput().replace(/^.*Resource version:.*\n?/mu, "")], + ["duplicate id", `${providerOutput()}\nId: duplicate`], + ["unknown semantic field", `${providerOutput()}\nEndpoint: https://hidden.invalid`], + ["control in id", providerOutput({ id: "provider\u0007id" })], + ["ANSI in id value", providerOutput({ id: "provider\u001b[31mid" })], + ["oversized output", `${providerOutput()}\n${"x".repeat(64 * 1024)}`], + ])("rejects a %s provider observation", (_label, output) => { + expect(() => + parseCuaProviderAuthorityDigest({ + gatewayName: "nemoclaw-alpha", + providerName: "recorded-provider", + model: "recorded/model", + output, + }), + ).toThrow("provider identity is unavailable"); + }); + + it("projects only the exact effective OpenShell policy revision and digest", () => { + const output = JSON.stringify({ + active_version: 17, + config_revision: 23, + hash: `sha256:${"b".repeat(64)}`, + policy_source: "sandbox", + sandbox: "alpha", + status: "effective", + version: 17, + }); + + expect(parseCuaAppliedPolicyIdentity({ sandboxName: "alpha", output })).toEqual({ + revision: 17, + digest: `sha256:${"b".repeat(64)}`, + }); + }); + + it.each([ + ["wrong sandbox", { sandbox: "beta" }], + ["inactive revision", { active_version: 16 }], + ["non-effective status", { status: "pending" }], + ["mutable policy source", { policy_source: "gateway" }], + ["invalid digest", { hash: "sha256:mutable" }], + ["unknown authority field", { endpoint: "https://hidden.invalid" }], + ])("rejects %s in the live applied-policy observation", (_label, override) => { + const output = JSON.stringify({ + active_version: 17, + config_revision: 23, + hash: `sha256:${"b".repeat(64)}`, + policy_source: "sandbox", + sandbox: "alpha", + status: "effective", + version: 17, + ...override, + }); + + expect(() => parseCuaAppliedPolicyIdentity({ sandboxName: "alpha", output })).toThrow( + "applied CUA policy identity is unavailable", + ); + }); + + it("binds validation to the live route while preserving durable route metadata", () => { + const validate = vi.fn((_value: unknown, _context: CuaRuntimeReadinessContext) => readiness); + + expect( + requireCuaLifecycleReadiness(entry(), { + env: { + [CUA_FRAMEWORK_FEATURE_ENV]: "1", + [CUA_QUALIFICATION_FEATURE_ENV]: "1", + }, + observeLiveInference: () => ({ + provider: "live-provider", + model: "live/model", + providerAuthorityDigest: `sha256:${"a".repeat(64)}`, + }), + observeLiveAppliedPolicy: () => ({ + revision: 17, + digest: `sha256:${"b".repeat(64)}`, + }), + validateRuntimeReadiness: validate, + }), + ).toBe(readiness); + + expect(validate).toHaveBeenCalledWith( + readiness, + expect.objectContaining({ + agentName: "nemocua", + acceptance: "candidate-qualification", + recordedInference: expect.objectContaining({ + provider: "recorded-provider", + endpointUrl: "https://inference.example/v1", + }), + liveInference: expect.objectContaining({ + provider: "live-provider", + model: "live/model", + endpointUrl: "https://inference.example/v1", + credentialEnv: "NVIDIA_API_KEY", + }), + liveProviderAuthorityDigest: `sha256:${"a".repeat(64)}`, + }), + ); + }); + + it("allows candidate lifecycle authority only in the dedicated qualification mode", () => { + const validate = vi.fn((_value: unknown, _context: CuaRuntimeReadinessContext) => readiness); + const env = { + [CUA_FRAMEWORK_FEATURE_ENV]: "1", + [CUA_QUALIFICATION_FEATURE_ENV]: "1", + }; + + requireCuaLifecycleReadiness(entry(), { + env, + observeLiveInference: () => ({ + provider: "recorded-provider", + model: "recorded/model", + providerAuthorityDigest: `sha256:${"a".repeat(64)}`, + }), + observeLiveAppliedPolicy: () => ({ + revision: 17, + digest: `sha256:${"b".repeat(64)}`, + }), + validateRuntimeReadiness: validate, + }); + + expect(validate.mock.calls[0]?.[1]).toMatchObject({ + acceptance: "candidate-qualification", + env, + }); + }); + + it("rejects a sandbox that has no stored readiness before observing external state", () => { + const sandbox = entry(); + delete sandbox.cuaRuntimeReadiness; + const observeLiveInference = vi.fn(); + + expect(() => requireCuaLifecycleReadiness(sandbox, { observeLiveInference })).toThrow( + "CUA runtime readiness is unavailable", + ); + expect(observeLiveInference).not.toHaveBeenCalled(); + }); + + it("rejects malformed stored OpenShell authority before spawning a command (#7755)", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-no-spawn-")); + const marker = path.join(directory, "spawned"); + const executable = path.join(directory, "openshell"); + fs.writeFileSync(executable, `#!/bin/sh\ntouch ${marker}\n`, { mode: 0o755 }); + const sandbox = entry(); + sandbox.cuaRuntimeReadiness = { + kind: "runtime-readiness", + components: {}, + } as unknown as CuaRuntimeReadiness; + + try { + expect(() => observeCuaLiveInference(sandbox, { openshellBinary: executable })).toThrow(); + expect(fs.existsSync(marker)).toBe(false); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/cua/lifecycle-readiness.ts b/src/lib/cua/lifecycle-readiness.ts new file mode 100644 index 00000000000..88e3910371f --- /dev/null +++ b/src/lib/cua/lifecycle-readiness.ts @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import { resolveLiveInferenceGatewayName } from "../inference/gateway-route-compatibility"; +import { captureResolvedOpenshell, parseGatewayInference, stripAnsi } from "../inference/live"; +import { parseGatewayProviderMetadata } from "../onboard/gateway-provider-metadata"; +import type { SandboxEntry } from "../state/registry/types"; +import { type CuaAppliedPolicyIdentity, type CuaRuntimeReadiness } from "./contract"; +import { isCuaQualificationEnabled } from "./feature"; +import { getStoredCuaOpenshellDigest, snapshotCuaOpenshellExecutable } from "./openshell-authority"; +import { validateCurrentCuaRuntimeReadiness } from "./runtime-readiness"; + +const MAX_INFERENCE_STATUS_BYTES = 64 * 1024; +const INFERENCE_STATUS_TIMEOUT_MS = 10_000; +const MAX_POLICY_STATUS_BYTES = 64 * 1024; +const POLICY_STATUS_TIMEOUT_MS = 10_000; +const PROVIDER_IDENTITY = /^[A-Za-z0-9._:-]{1,128}$/; +const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/; +const POLICY_STATUS_FIELDS = new Set([ + "active_version", + "config_revision", + "hash", + "policy_source", + "sandbox", + "status", + "version", +]); +const PROVIDER_FIELDS = new Set([ + "provider", + "id", + "name", + "type", + "resource version", + "credential keys", + "config keys", +]); + +export interface CuaLiveInferenceObservation { + provider: string; + model: string; + providerAuthorityDigest: string; + /** Digest of the private OpenShell snapshot used for this observation. */ + openshellDigest?: string; +} + +interface CuaOpenshellObservationOptions { + openshellBinary?: string; + expectedDigest?: string; + env?: NodeJS.ProcessEnv; +} + +function policyStatusRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("the live applied CUA policy identity is unavailable"); + } + return value as Record; +} + +/** Parse bounded OpenShell policy status into a content-free applied-policy identity. */ +export function parseCuaAppliedPolicyIdentity(input: { + sandboxName: string; + output: string; +}): CuaAppliedPolicyIdentity { + const { sandboxName, output } = input; + if ( + Buffer.byteLength(output, "utf8") > MAX_POLICY_STATUS_BYTES || + /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/u.test(output) + ) { + throw new Error("the live applied CUA policy identity is unavailable"); + } + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + throw new Error("the live applied CUA policy identity is unavailable"); + } + const record = policyStatusRecord(parsed); + const keys = Object.keys(record); + if ( + keys.some((key) => !POLICY_STATUS_FIELDS.has(key)) || + !["active_version", "hash", "sandbox", "status", "version"].every((key) => + Object.hasOwn(record, key), + ) + ) { + throw new Error("the live applied CUA policy identity is unavailable"); + } + const revision = record.version; + if ( + !Number.isSafeInteger(revision) || + Number(revision) < 0 || + record.active_version !== revision || + record.status !== "effective" || + record.sandbox !== sandboxName || + typeof record.hash !== "string" || + !SHA256_DIGEST.test(record.hash) || + (record.policy_source !== undefined && record.policy_source !== "sandbox") + ) { + throw new Error("the live applied CUA policy identity is unavailable"); + } + if (record.config_revision !== undefined) { + const revisions = [...output.matchAll(/"config_revision"\s*:\s*(0|[1-9][0-9]*)(?=\s*[,}])/gu)]; + if (revisions.length !== 1) { + throw new Error("the live applied CUA policy identity is unavailable"); + } + } + return { revision: Number(revision), digest: record.hash }; +} + +/** Parse one bounded, content-free provider observation into an opaque host authority digest. */ +export function parseCuaProviderAuthorityDigest(input: { + gatewayName: string; + providerName: string; + model: string; + output: string; +}): string { + const { gatewayName, providerName, model, output } = input; + if ( + Buffer.byteLength(output, "utf8") > MAX_INFERENCE_STATUS_BYTES || + !PROVIDER_IDENTITY.test(gatewayName) || + !PROVIDER_IDENTITY.test(providerName) || + model.length < 1 || + model.length > 512 || + /[\x00-\x1f\x7f]/u.test(model) + ) { + throw new Error("the live managed inference provider identity is unavailable"); + } + for (const rawLine of output.split(/\r?\n/u)) { + const cleanLine = stripAnsi(rawLine).trim(); + const separator = cleanLine.indexOf(":"); + if (separator < 0) continue; + const field = cleanLine.slice(0, separator).trim().toLowerCase(); + if (!PROVIDER_FIELDS.has(field)) { + throw new Error("the live managed inference provider identity is unavailable"); + } + const rawSeparator = rawLine.indexOf(":"); + const rawValue = rawLine + .slice(rawSeparator + 1) + .replace(/^(?:\x1B\[[0-?]*[ -/]*[@-~])*[ \t]*/u, ""); + if (/[\x00-\x1f\x7f-\x9f]/u.test(rawValue)) { + throw new Error("the live managed inference provider identity is unavailable"); + } + } + const metadata = parseGatewayProviderMetadata(output); + const clean = stripAnsi(output); + const ids = Array.from(clean.matchAll(/^\s*Id:\s*([^\s]+)\s*$/gimu)); + const versions = Array.from(clean.matchAll(/^\s*Resource version:\s*([0-9]+)\s*$/gimu)); + const id = ids[0]?.[1] ?? ""; + const resourceVersion = Number(versions[0]?.[1] ?? ""); + if ( + !metadata || + metadata.name !== providerName || + ids.length !== 1 || + versions.length !== 1 || + !PROVIDER_IDENTITY.test(id) || + !Number.isSafeInteger(resourceVersion) || + resourceVersion < 1 + ) { + throw new Error("the live managed inference provider identity is unavailable"); + } + return `sha256:${crypto + .createHash("sha256") + .update( + JSON.stringify({ + gatewayName, + provider: providerName, + model, + id, + resourceVersion, + name: metadata.name, + type: metadata.type, + credentialKeys: [...metadata.credentialKeys].sort(), + configKeys: [...metadata.configKeys].sort(), + }), + ) + .digest("hex")}`; +} + +export interface CuaLifecycleReadinessDeps { + env?: NodeJS.ProcessEnv; + observeLiveInference?: (entry: SandboxEntry) => CuaLiveInferenceObservation; + observeLiveAppliedPolicy?: (entry: SandboxEntry) => CuaAppliedPolicyIdentity; + validateRuntimeReadiness?: typeof validateCurrentCuaRuntimeReadiness; +} + +/** Re-observe the exact effective OpenShell policy without exposing policy content. */ +export function observeCuaLiveAppliedPolicy( + entry: SandboxEntry, + options: CuaOpenshellObservationOptions = {}, +): CuaAppliedPolicyIdentity { + const snapshot = snapshotCuaOpenshellExecutable({ + selectedBinary: options.openshellBinary, + expectedDigest: + options.expectedDigest ?? getStoredCuaOpenshellDigest(entry.cuaRuntimeReadiness), + env: options.env, + }); + try { + const observed = captureResolvedOpenshell(["policy", "get", entry.name, "--output", "json"], { + openshellBinary: snapshot.executable, + ignoreError: true, + timeout: POLICY_STATUS_TIMEOUT_MS, + maxBuffer: MAX_POLICY_STATUS_BYTES, + }); + if (observed.status !== 0) { + throw new Error("the live applied CUA policy identity is unavailable"); + } + return parseCuaAppliedPolicyIdentity({ sandboxName: entry.name, output: observed.output }); + } finally { + snapshot.cleanup(); + } +} + +/** Require one content-free observation of the effective policy for lifecycle admission. */ +export function requireCuaLiveAppliedPolicy( + entry: SandboxEntry, + deps: CuaLifecycleReadinessDeps = {}, +): CuaAppliedPolicyIdentity { + return deps.observeLiveAppliedPolicy + ? deps.observeLiveAppliedPolicy(entry) + : observeCuaLiveAppliedPolicy(entry, { env: deps.env }); +} + +/** Re-observe the exact gateway route before granting lifecycle authority. */ +export function observeCuaLiveInference( + entry: SandboxEntry, + options: CuaOpenshellObservationOptions = {}, +): CuaLiveInferenceObservation { + const snapshot = snapshotCuaOpenshellExecutable({ + selectedBinary: options.openshellBinary, + expectedDigest: + options.expectedDigest ?? getStoredCuaOpenshellDigest(entry.cuaRuntimeReadiness), + env: options.env, + }); + try { + const gatewayName = resolveLiveInferenceGatewayName(entry); + const capture = (args: string[]) => + captureResolvedOpenshell(args, { + openshellBinary: snapshot.executable, + ignoreError: true, + timeout: INFERENCE_STATUS_TIMEOUT_MS, + maxBuffer: MAX_INFERENCE_STATUS_BYTES, + }); + const inferenceBefore = capture(["inference", "get", "-g", gatewayName]); + const live = + inferenceBefore.status === 0 ? parseGatewayInference(inferenceBefore.output) : null; + if (!live?.provider || !live.model) { + throw new Error("the live managed inference route is unavailable"); + } + const providerBefore = capture(["provider", "get", "-g", gatewayName, live.provider]); + const inferenceAfter = capture(["inference", "get", "-g", gatewayName]); + const providerAfter = capture(["provider", "get", "-g", gatewayName, live.provider]); + const after = inferenceAfter.status === 0 ? parseGatewayInference(inferenceAfter.output) : null; + if ( + providerBefore.status !== 0 || + providerAfter.status !== 0 || + after?.provider !== live.provider || + after?.model !== live.model + ) { + throw new Error("the live managed inference provider identity is unavailable"); + } + const beforeDigest = parseCuaProviderAuthorityDigest({ + gatewayName, + providerName: live.provider, + model: live.model, + output: providerBefore.output, + }); + const afterDigest = parseCuaProviderAuthorityDigest({ + gatewayName, + providerName: live.provider, + model: live.model, + output: providerAfter.output, + }); + if (beforeDigest !== afterDigest) { + throw new Error("the live managed inference provider identity changed during validation"); + } + return { + provider: live.provider, + model: live.model, + providerAuthorityDigest: beforeDigest, + openshellDigest: snapshot.executableDigest, + }; + } finally { + snapshot.cleanup(); + } +} + +/** + * Validate stored readiness against the executing build, external manifest, + * immutable qualification evidence, durable route, and live gateway route. + */ +export function requireCuaLifecycleReadiness( + entry: SandboxEntry, + deps: CuaLifecycleReadinessDeps = {}, +): CuaRuntimeReadiness { + if (!entry.cuaRuntimeReadiness) throw new Error("CUA runtime readiness is unavailable"); + const env = deps.env ?? process.env; + if (!isCuaQualificationEnabled(env)) { + throw new Error("CUA candidate readiness requires exact qualification authority"); + } + const live = deps.observeLiveInference + ? deps.observeLiveInference(entry) + : observeCuaLiveInference(entry, { env }); + const appliedPolicy = requireCuaLiveAppliedPolicy(entry, deps); + return (deps.validateRuntimeReadiness ?? validateCurrentCuaRuntimeReadiness)( + entry.cuaRuntimeReadiness, + { + agentName: entry.agent, + recordedInference: entry, + liveInference: { ...entry, provider: live.provider, model: live.model }, + liveProviderAuthorityDigest: live.providerAuthorityDigest, + liveAppliedPolicy: appliedPolicy, + ...(live.openshellDigest ? { expectedOpenshellDigest: live.openshellDigest } : {}), + acceptance: "candidate-qualification", + env, + }, + ); +} diff --git a/src/lib/cua/onboard-runtime.ts b/src/lib/cua/onboard-runtime.ts new file mode 100644 index 00000000000..811ff2e7b35 --- /dev/null +++ b/src/lib/cua/onboard-runtime.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveLiveInferenceGatewayName as resolveSandboxGatewayName } from "../inference/gateway-route-compatibility"; +import { withGatewayRouteMutationLock } from "../inference/gateway-route-mutation-lock"; +import type { CuaBuildIdentity } from "./build-identity"; +import type { CuaRuntimeReadiness } from "./contract"; +import { isCuaQualificationEnabled } from "./feature"; +import { + type CuaLiveInferenceObservation, + observeCuaLiveAppliedPolicy, + observeCuaLiveInference, +} from "./lifecycle-readiness"; +import { requireCurrentCuaRuntimeReadiness } from "./runtime-readiness"; + +/** + * CUA runtime dependencies used while onboarding an agent sandbox. + * + * Keeping this boundary explicit lets the generic agent onboarding flow + * depend on one CUA surface instead of coupling it to each lifecycle module. + */ +export { + type CuaBuildIdentity, + type CuaLiveInferenceObservation, + type CuaRuntimeReadiness, + isCuaQualificationEnabled, + observeCuaLiveInference, + observeCuaLiveAppliedPolicy, + requireCurrentCuaRuntimeReadiness, + resolveSandboxGatewayName, + withGatewayRouteMutationLock, +}; diff --git a/src/lib/cua/openshell-authority.test.ts b/src/lib/cua/openshell-authority.test.ts new file mode 100644 index 00000000000..31f96192a53 --- /dev/null +++ b/src/lib/cua/openshell-authority.test.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto 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 { snapshotCuaOpenshellExecutable } from "./openshell-authority"; + +const directories: string[] = []; + +function fixture(contents = "#!/bin/sh\nprintf original"): { + executable: string; + link: string; + digest: string; +} { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-openshell-test-")); + directories.push(directory); + const executable = path.join(directory, "openshell-real"); + const link = path.join(directory, "openshell"); + fs.writeFileSync(executable, contents, { mode: 0o755 }); + fs.symlinkSync(executable, link); + return { + executable, + link, + digest: `sha256:${crypto.createHash("sha256").update(contents).digest("hex")}`, + }; +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("CUA OpenShell executable authority", () => { + it("snapshots the canonical symlink target and binds it to the expected digest", () => { + const source = fixture(); + const snapshot = snapshotCuaOpenshellExecutable({ + selectedBinary: source.link, + expectedDigest: source.digest, + }); + directories.push(snapshot.temporaryDirectory); + + expect(snapshot.executableDigest).toBe(source.digest); + expect(fs.realpathSync(snapshot.executable)).toBe(snapshot.executable); + expect(fs.statSync(snapshot.executable).mode & 0o777).toBe(0o500); + }); + + it("rejects source tampering instead of executing bytes outside stored readiness", () => { + const source = fixture(); + fs.writeFileSync(source.executable, "#!/bin/sh\nprintf replacement", { mode: 0o755 }); + + expect(() => + snapshotCuaOpenshellExecutable({ + selectedBinary: source.link, + expectedDigest: source.digest, + }), + ).toThrow("does not match its expected digest"); + }); +}); diff --git a/src/lib/cua/openshell-authority.ts b/src/lib/cua/openshell-authority.ts new file mode 100644 index 00000000000..2e0e96f1c22 --- /dev/null +++ b/src/lib/cua/openshell-authority.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { resolveOpenshellBinaryOrNull } from "../adapters/openshell/resolve-shared"; +import { type BoundedExecutableSnapshot, snapshotBoundedExecutable } from "./bounded-file"; +import { parseCuaRuntimeReadiness } from "./schema"; + +const MAX_OPENSHELL_BINARY_BYTES = 64 * 1024 * 1024; +const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/; + +export interface CuaOpenshellSnapshotOptions { + selectedBinary?: string; + expectedDigest?: string; + env?: NodeJS.ProcessEnv; +} + +/** Read the exact OpenShell digest from stored readiness without accepting a partial shape. */ +export function getStoredCuaOpenshellDigest(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + return parseCuaRuntimeReadiness(value).components.openshell.digest; +} + +/** + * Resolve OpenShell once and copy its exact bytes into a private executable snapshot. + * + * CUA observations invoke only the returned canonical snapshot. This prevents an + * override or symlink from selecting different bytes after readiness hashes the + * source. A supplied readiness or receipt digest is checked before the copy can run. + */ +export function snapshotCuaOpenshellExecutable( + options: CuaOpenshellSnapshotOptions = {}, +): BoundedExecutableSnapshot { + const env = options.env ?? process.env; + const selected = + options.selectedBinary?.trim() || + env.NEMOCLAW_OPENSHELL_BIN?.trim() || + resolveOpenshellBinaryOrNull(); + if (!selected || !path.isAbsolute(selected)) { + throw new Error("CUA requires one absolute OpenShell executable path"); + } + if (options.expectedDigest !== undefined && !SHA256_DIGEST.test(options.expectedDigest)) { + throw new Error("CUA OpenShell executable expected digest is invalid"); + } + + let canonical: string; + try { + canonical = fs.realpathSync(selected); + } catch { + throw new Error("CUA OpenShell executable is unavailable"); + } + if (!path.isAbsolute(canonical)) { + throw new Error("CUA OpenShell executable canonical path is invalid"); + } + + const snapshot = snapshotBoundedExecutable(canonical, { + label: "CUA OpenShell executable", + minBytes: 1, + maxBytes: MAX_OPENSHELL_BINARY_BYTES, + temporaryDirectoryPrefix: "nemoclaw-cua-openshell-", + ...(options.expectedDigest ? { expectedDigest: options.expectedDigest } : {}), + }); + return { ...snapshot, executable: fs.realpathSync(snapshot.executable) }; +} diff --git a/src/lib/cua/qualification-evidence.test.ts b/src/lib/cua/qualification-evidence.test.ts new file mode 100644 index 00000000000..d3cd690bb55 --- /dev/null +++ b/src/lib/cua/qualification-evidence.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { parseCuaQualificationEnvironment } from "./qualification-evidence"; + +const candidate = { + schemaVersion: "1.0.0", + kind: "cua-candidate-environment", + nemoclawCommit: "a".repeat(40), + bundleReceiptSha256: "b".repeat(64), + runtimeManifestSha256: "c".repeat(64), +}; + +describe("CUA candidate environment", () => { + it("accepts only the narrow content-free install authority", () => { + expect(parseCuaQualificationEnvironment(candidate)).toEqual(candidate); + }); + + it("rejects later-slice qualification and lifecycle evidence", () => { + for (const extra of [ + { gpu: { count: 1 } }, + { scenarios: ["browser"] }, + { receipt: { status: "passed" } }, + { targetChannel: { endpoint: "private.invalid" } }, + ]) { + expect(() => parseCuaQualificationEnvironment({ ...candidate, ...extra })).toThrow( + /contain exactly/, + ); + } + }); + + it("rejects malformed build and receipt identities", () => { + expect(() => + parseCuaQualificationEnvironment({ ...candidate, nemoclawCommit: "main" }), + ).toThrow(/invalid identity/); + expect(() => + parseCuaQualificationEnvironment({ ...candidate, bundleReceiptSha256: "sha256:bad" }), + ).toThrow(/invalid identity/); + }); +}); diff --git a/src/lib/cua/qualification-evidence.ts b/src/lib/cua/qualification-evidence.ts new file mode 100644 index 00000000000..db1d4d9a00e --- /dev/null +++ b/src/lib/cua/qualification-evidence.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const RAW_DIGEST = /^[0-9a-f]{64}$/; +const COMMIT = /^[0-9a-f]{40}$/; + +export interface CuaQualificationEnvironment { + schemaVersion: "1.0.0"; + kind: "cua-candidate-environment"; + nemoclawCommit: string; + bundleReceiptSha256: string; + runtimeManifestSha256: string; +} +function object(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("CUA candidate environment must be an object"); + } + return value as Record; +} + +/** Parse the narrow, content-free candidate installation authority. */ +export function parseCuaQualificationEnvironment(value: unknown): CuaQualificationEnvironment { + const record = object(value); + const expected = [ + "bundleReceiptSha256", + "kind", + "nemoclawCommit", + "runtimeManifestSha256", + "schemaVersion", + ]; + if (Object.keys(record).sort().join("\0") !== expected.join("\0")) { + throw new Error(`CUA candidate environment must contain exactly: ${expected.join(", ")}`); + } + if ( + record.schemaVersion !== "1.0.0" || + record.kind !== "cua-candidate-environment" || + typeof record.nemoclawCommit !== "string" || + !COMMIT.test(record.nemoclawCommit) || + typeof record.bundleReceiptSha256 !== "string" || + !RAW_DIGEST.test(record.bundleReceiptSha256) || + typeof record.runtimeManifestSha256 !== "string" || + !RAW_DIGEST.test(record.runtimeManifestSha256) + ) { + throw new Error("CUA candidate environment has an invalid identity"); + } + return structuredClone(record) as unknown as CuaQualificationEnvironment; +} diff --git a/src/lib/cua/runtime-manifest.test.ts b/src/lib/cua/runtime-manifest.test.ts new file mode 100644 index 00000000000..7e306c5fe37 --- /dev/null +++ b/src/lib/cua/runtime-manifest.test.ts @@ -0,0 +1,469 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getAgentChoices, listAgents, loadAgent } from "../agent/defs"; +import { + getCuaSandboxImageRef, + loadCuaRuntimeManifest, + stageCuaRuntimePayload, + verifyCuaRuntimePayload, +} from "./runtime-manifest"; +import { type CuaRuntimeTestFixture, createCuaRuntimeTestFixture } from "./runtime-test-fixture"; + +const fixtures: CuaRuntimeTestFixture[] = []; + +function fixture(): CuaRuntimeTestFixture { + const value = createCuaRuntimeTestFixture(); + fixtures.push(value); + return value; +} + +function hash(value: string | Buffer): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function rewriteAgentManifest( + runtime: CuaRuntimeTestFixture, + transform: (value: string) => string, +): void { + const manifestPath = path.join(runtime.root, "manifest.yaml"); + const contents = transform(fs.readFileSync(manifestPath, "utf8")); + fs.chmodSync(manifestPath, 0o644); + fs.writeFileSync(manifestPath, contents); + fs.chmodSync(manifestPath, 0o444); + runtime.rewriteManifest((record) => { + const agent = record.agent as Record; + const identity = agent.manifest as Record; + identity.sizeBytes = Buffer.byteLength(contents); + identity.sha256 = hash(contents); + }); +} + +function rewriteDockerfilePayload( + runtime: CuaRuntimeTestFixture, + field: "dockerfile" | "baseDockerfile", + contents: string | Buffer, +): void { + const filePath = path.join(runtime.root, runtime.manifest.agent[field].filename); + fs.chmodSync(filePath, 0o644); + fs.writeFileSync(filePath, contents); + fs.chmodSync(filePath, 0o444); + runtime.rewriteManifest((record) => { + const agent = record.agent as Record; + const identity = agent[field] as Record; + identity.sizeBytes = + typeof contents === "string" ? Buffer.byteLength(contents) : contents.length; + identity.sha256 = hash(contents); + }); +} + +function dockerfileWith(field: "dockerfile" | "baseDockerfile", ...instructions: string[]): string { + const preamble = + field === "dockerfile" + ? "ARG BASE_IMAGE\nFROM ${BASE_IMAGE}\n" + : "ARG NEMOCUA_RUNTIME_IMAGE\nFROM ${NEMOCUA_RUNTIME_IMAGE}\n"; + return `${preamble}${instructions.map((instruction) => `${instruction}\n`).join("")}`; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + while (fixtures.length > 0) fixtures.pop()?.cleanup(); +}); + +describe("external NemoCUA runtime manifest", () => { + it("refuses the manifest before any artifact authority read while CUA is disabled (#7755)", () => { + const runtime = fixture(); + const assertFileOwnership = vi.fn(() => { + throw new Error("disabled artifact authority read"); + }); + + expect(() => + loadCuaRuntimeManifest( + { ...runtime.env, NEMOCLAW_CUA_ENABLED: undefined }, + { assertFileOwnership }, + ), + ).toThrow("use the controlled Brev Launchable activation"); + expect(assertFileOwnership).not.toHaveBeenCalled(); + }); + + it("discovers the canonical terminal agent only under the dedicated feature gate (#7755)", () => { + const runtime = fixture(); + + expect(listAgents({})).not.toContain("nemocua"); + expect(listAgents(runtime.env)).toContain("nemocua"); + + const agent = loadAgent("nemocua", runtime.env); + expect(agent.name).toBe("nemocua"); + expect(agent.displayName).toBe("NemoCUA"); + expect(agent.runtime).toEqual({ + kind: "terminal", + interactive_command: "nemocua interactive", + headless_command: "nemocua headless", + smoke_commands: ["nemocua version", "nemocua smoke"], + }); + expect(agent.agentDir).toBe(runtime.root); + expect(agent.configPaths.dir).toBe("/sandbox/.nemocua"); + + for (const [name, value] of Object.entries(runtime.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + )) { + vi.stubEnv(name, value); + } + expect(getAgentChoices()).toContainEqual( + expect.objectContaining({ name: "nemocua", displayName: "NemoCUA" }), + ); + }); + + it("validates the entire closed payload and stages only declared bytes (#7755)", () => { + const runtime = fixture(); + fs.writeFileSync(path.join(runtime.root, "private-source-coordinate.txt"), "do-not-copy"); + const loaded = loadCuaRuntimeManifest(runtime.env); + + expect(() => verifyCuaRuntimePayload(loaded)).not.toThrow(); + expect(getCuaSandboxImageRef(runtime.env)).toMatch(/@sha256:[0-9a-f]{64}$/); + const destination = path.join(runtime.root, "staged"); + stageCuaRuntimePayload(destination, runtime.env); + expect(fs.readdirSync(destination).sort()).toEqual([ + "Dockerfile", + "Dockerfile.base", + "manifest.yaml", + "nemocua-cli.tar.gz", + "policy-additions.yaml", + "security-adapter.sh", + "target-adapter.sh", + "target-services.tar.gz", + "task-adapter.sh", + ]); + expect(fs.existsSync(path.join(destination, "private-source-coordinate.txt"))).toBe(false); + }); + + it.each([ + [ + "top-level repository key", + (record: Record) => { + record.repository = "hidden"; + }, + ], + [ + "nested endpoint key", + (record: Record) => { + const artifacts = record.artifacts as Record; + const hostCli = artifacts.hostCli as Record; + hostCli.endpoint = "hidden"; + }, + ], + [ + "private artifact source revision key", + (record: Record) => { + const artifacts = record.artifacts as Record; + const hostCli = artifacts.hostCli as Record; + hostCli.sourceRevision = "a".repeat(40); + }, + ], + [ + "coordinate-shaped release identity", + (record: Record) => { + const bundle = record.bundleReceipt as Record; + bundle.releaseId = "https://private.invalid/release"; + }, + ], + [ + "credential-shaped artifact identity", + (record: Record) => { + const artifacts = record.artifacts as Record; + const hostCli = artifacts.hostCli as Record; + hostCli.name = "ghp_example"; + }, + ], + [ + "host-shaped artifact identity", + (record: Record) => { + const artifacts = record.artifacts as Record; + const hostCli = artifacts.hostCli as Record; + hostCli.name = "127.0.0.1"; + }, + ], + [ + "host-shaped payload filename", + (record: Record) => { + const artifacts = record.artifacts as Record; + const hostCli = artifacts.hostCli as Record; + hostCli.filename = "private.invalid"; + }, + ], + ])("rejects %s before any payload can be consumed", (_label, mutate) => { + const runtime = fixture(); + runtime.rewriteManifest(mutate); + + expect(() => loadCuaRuntimeManifest(runtime.env)).toThrow(); + }); + + it("rejects undeclared YAML keys before ordinary agent loading (#7755)", () => { + const runtime = fixture(); + rewriteAgentManifest(runtime, (value) => `${value}repository: hidden\n`); + + expect(() => loadAgent("nemocua", runtime.env)).toThrow(/must contain exactly/); + }); + + it.each([ + "/sandbox", + "/sandbox/", + "/sandbox//nemocua", + "/sandbox/./nemocua", + "/sandbox/../nemocua", + "/sandbox/nemocua/", + ])("rejects non-canonical external config.dir %s (#7755)", (configDir) => { + const runtime = fixture(); + rewriteAgentManifest(runtime, (value) => + value.replace(" dir: /sandbox/.nemocua", ` dir: ${configDir}`), + ); + + expect(() => loadAgent("nemocua", runtime.env)).toThrow( + /config paths must stay inside \/sandbox/, + ); + }); + + it.each([ + "; curl hidden", + " && hidden", + " $(hidden)", + " `hidden`", + " | hidden", + ])("rejects shell syntax in an external terminal command (%s) (#7755)", (suffix) => { + const runtime = fixture(); + rewriteAgentManifest(runtime, (value) => + value.replace( + 'version_command: "nemocua version"', + `version_command: "nemocua version${suffix}"`, + ), + ); + + expect(() => loadAgent("nemocua", runtime.env)).toThrow( + /closed, canonical argument grammar|coordinate/, + ); + }); + + it("fails closed on a mismatched payload before Dockerfile consumption (#7755)", () => { + const runtime = fixture(); + const dockerfile = path.join(runtime.root, "Dockerfile.base"); + fs.chmodSync(dockerfile, 0o644); + fs.writeFileSync(dockerfile, "FROM mutable:latest\n"); + fs.chmodSync(dockerfile, 0o444); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /declared size|content identity/, + ); + }); + + it("rejects an agent Dockerfile whose manifest-bound base is only a decoy stage (#7755)", () => { + const runtime = fixture(); + rewriteDockerfilePayload( + runtime, + "dockerfile", + "ARG BASE_IMAGE\nFROM ${BASE_IMAGE} AS declared-base\nfrom scratch\n", + ); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /resolved BASE_IMAGE as its sole FROM base/, + ); + }); + + it("rejects a base Dockerfile with a final stage outside the runtime-image binding (#7755)", () => { + const runtime = fixture(); + rewriteDockerfilePayload( + runtime, + "baseDockerfile", + "ARG NEMOCUA_RUNTIME_IMAGE\nFROM ${NEMOCUA_RUNTIME_IMAGE} AS declared-base\n FROM scratch\n", + ); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /NEMOCUA_RUNTIME_IMAGE as its sole FROM base/, + ); + }); + + it.each([ + ["dockerfile", "ARG UNDECLARED_BUILD_INPUT"], + ["baseDockerfile", "arg HTTP_PROXY"], + ] as const)("rejects an additional ARG in the %s (#7755)", (field, argument) => { + const runtime = fixture(); + rewriteDockerfilePayload(runtime, field, `${dockerfileWith(field)}${argument}\n`); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /sole FROM base/, + ); + }); + + it.each([ + [ + "agent Dockerfile local payload copy", + "dockerfile", + dockerfileWith( + "dockerfile", + "COPY agents/nemocua/nemocua-cli.tar.gz /tmp/nemocua-cli.tar.gz", + "RUN --network=none test -f /tmp/nemocua-cli.tar.gz", + ), + ], + [ + "base Dockerfile offline command", + "baseDockerfile", + dockerfileWith("baseDockerfile", "RUN --network=none /bin/true"), + ], + ] as const)("accepts a closed %s (#7755)", (_label, field, contents) => { + const runtime = fixture(); + rewriteDockerfilePayload(runtime, field, contents); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).not.toThrow(); + }); + + it.each([ + ["dockerfile", "ADD https://payload.invalid/archive.tar.gz /opt/payload/"], + ["baseDockerfile", "ADD https://payload.invalid/archive.tar.gz /opt/payload/"], + ["dockerfile", " add agents/nemocua/nemocua-cli.tar.gz /opt/payload/"], + ] as const)("rejects every ADD form in the %s (%s) (#7755)", (field, instruction) => { + const runtime = fixture(); + rewriteDockerfilePayload(runtime, field, dockerfileWith(field, instruction)); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /cannot use ADD/, + ); + }); + + it.each([ + [ + "an external image", + "dockerfile", + "COPY --from=registry.invalid/runtime:latest /runtime /runtime", + ], + ["the broad build context", "dockerfile", "COPY . /opt/nemoclaw-source"], + [ + "an undeclared local file", + "dockerfile", + "COPY agents/nemocua/not-in-manifest.tar.gz /tmp/payload.tar.gz", + ], + [ + "a staged agent payload from the base build", + "baseDockerfile", + "COPY agents/nemocua/nemocua-cli.tar.gz /tmp/nemocua-cli.tar.gz", + ], + [ + "an external image with a separated option", + "dockerfile", + "COPY --from registry.invalid/runtime:latest /runtime /runtime", + ], + [ + "a JSON-form source", + "dockerfile", + 'COPY ["agents/nemocua/nemocua-cli.tar.gz", "/tmp/nemocua-cli.tar.gz"]', + ], + ] as const)("rejects COPY from %s in the %s (#7755)", (_source, field, instruction) => { + const runtime = fixture(); + rewriteDockerfilePayload(runtime, field, dockerfileWith(field, instruction)); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /COPY must name one exact manifest-bound staged agents\/nemocua payload/, + ); + }); + + it.each([ + ["dockerfile", "RUN /bin/true"], + ["baseDockerfile", "RUN /bin/true"], + ["dockerfile", "RUN --network=host /bin/true"], + ["baseDockerfile", "RUN --network=none --mount=type=secret,id=token /bin/true"], + ["dockerfile", "RUN --network=none --mount=type=ssh /bin/true"], + ["baseDockerfile", "RUN --network=none --security=insecure /bin/true"], + ["dockerfile", "RUN --network=none --device=/dev/nvidia0 /bin/true"], + ] as const)("rejects a non-canonical build command in the %s (%s) (#7755)", (field, run) => { + const runtime = fixture(); + rewriteDockerfilePayload(runtime, field, dockerfileWith(field, run)); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /RUN must use only the canonical BuildKit --network=none option/, + ); + }); + + it.each([ + ["dockerfile", "# syntax=docker/dockerfile:1\n"], + ["baseDockerfile", "RUN --network=none echo first \\\n echo second\n"], + ["dockerfile", "ONBUILD ADD https://payload.invalid/archive /opt/payload\n"], + ] as const)("rejects ambiguous Dockerfile grammar in the %s (#7755)", (field, suffix) => { + const runtime = fixture(); + rewriteDockerfilePayload(runtime, field, `${dockerfileWith(field)}${suffix}`); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /cannot select a Dockerfile parser frontend|unsupported continuation|ONBUILD is unsupported/, + ); + }); + + it.each([ + [ + "CRLF-delimited instructions", + "dockerfile", + Buffer.from(dockerfileWith("dockerfile").replaceAll("\n", "\r\n")), + ], + [ + "invalid UTF-8", + "baseDockerfile", + Buffer.concat([Buffer.from(dockerfileWith("baseDockerfile")), Buffer.from([0xff, 0x0a])]), + ], + ] as const)("rejects %s in the %s (#7755)", (_reason, field, contents) => { + const runtime = fixture(); + rewriteDockerfilePayload(runtime, field, contents); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow( + /unambiguous LF-delimited instructions|strict UTF-8/, + ); + }); + + it("caps authority adapters independently from large release archives (#7755)", () => { + const runtime = fixture(); + runtime.rewriteManifest((record) => { + const artifacts = record.artifacts as Record; + const adapters = artifacts.adapters as Record; + const task = adapters.task as Record; + task.sizeBytes = 4 * 1024 * 1024 + 1; + }); + + expect(() => loadCuaRuntimeManifest(runtime.env)).toThrow(/4194304/); + }); + + it("rejects a symlinked authority payload even when its bytes match (#7755)", () => { + const runtime = fixture(); + const policyPath = path.join(runtime.root, "policy-additions.yaml"); + const alternate = path.join(runtime.root, "alternate-policy.yaml"); + fs.copyFileSync(policyPath, alternate); + fs.rmSync(policyPath); + fs.symlinkSync(alternate, policyPath); + + expect(() => verifyCuaRuntimePayload(loadCuaRuntimeManifest(runtime.env))).toThrow(); + }); + + it("does not let test-mode environment variables bypass Linux authority permissions (#7755)", () => { + const runtime = fixture(); + fs.chmodSync(runtime.manifestPath, 0o666); + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + + expect(() => + loadCuaRuntimeManifest({ + ...runtime.env, + NODE_ENV: "test", + VITEST: "true", + }), + ).toThrow(/group\/world write access/); + }); + + it("fails closed when the host cannot report its effective owner identity (#7755)", () => { + const runtime = fixture(); + vi.spyOn(process, "geteuid").mockReturnValue(undefined as never); + + expect(() => loadCuaRuntimeManifest(runtime.env)).toThrow( + /ownership validation requires a POSIX host/, + ); + }); +}); diff --git a/src/lib/cua/runtime-manifest.ts b/src/lib/cua/runtime-manifest.ts new file mode 100644 index 00000000000..962168bf230 --- /dev/null +++ b/src/lib/cua/runtime-manifest.ts @@ -0,0 +1,987 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { readBoundedRegularFile } from "./bounded-file"; +import { + CUA_RUNTIME_MANIFEST_ENV, + CUA_RUNTIME_MANIFEST_SHA256_ENV, + CUA_SANDBOX_IMAGE_ENV, + requireCuaFrameworkEnabled, +} from "./feature"; +import { CUA_HOST_COORDINATE, CUA_SENSITIVE_VALUE } from "./shared-primitives"; + +const yaml: { load(input: string): unknown } = require("js-yaml"); + +const MAX_MANIFEST_BYTES = 256 * 1024; +const MAX_PAYLOAD_BYTES = 8 * 1024 ** 3; +const MAX_AGENT_MANIFEST_BYTES = 256 * 1024; +const MAX_DOCKERFILE_BYTES = 1024 * 1024; +const MAX_POLICY_BYTES = 1024 * 1024; +const MAX_ADAPTER_BYTES = 4 * 1024 * 1024; +const RAW_DIGEST = /^[0-9a-f]{64}$/; +const COMMIT = /^[0-9a-f]{40}$/; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SAFE_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/; +const SAFE_COMMAND_ARG = /^[A-Za-z0-9_./:=+,-]{1,128}$/; +const CONTROL_CHARACTER = /[\x00-\x1f\x7f]/; + +export interface CuaPayloadFileIdentity { + filename: string; + sizeBytes: number; + sha256: string; +} + +export interface CuaArchiveArtifactIdentity extends CuaPayloadFileIdentity { + name: string; + version: string; +} + +export interface CuaImageArtifactIdentity { + name: string; + version: string; + platform: "linux/amd64"; + digest: string; +} + +export interface CuaAdapterArtifactIdentity extends CuaPayloadFileIdentity { + name: string; + version: string; +} + +export interface CuaRuntimeCompatibility { + status: "candidate"; + issue: 7755; + candidateSourceRevision: string; +} + +export interface CuaRuntimeManifest { + schemaVersion: "1.0.0"; + kind: "cua-runtime-manifest"; + agent: { + name: "nemocua"; + manifest: CuaPayloadFileIdentity; + dockerfile: CuaPayloadFileIdentity; + baseDockerfile: CuaPayloadFileIdentity; + policy: CuaPayloadFileIdentity; + }; + compatibility: CuaRuntimeCompatibility; + bundleReceipt: { + schema: "cua.release.bundle/v1"; + releaseId: string; + producerCommit: string; + sha256: string; + }; + artifacts: { + hostCli: CuaArchiveArtifactIdentity; + sandboxImage: CuaImageArtifactIdentity; + targetImage: CuaImageArtifactIdentity; + targetServices: CuaArchiveArtifactIdentity; + adapters: { + target: CuaAdapterArtifactIdentity; + task: CuaAdapterArtifactIdentity; + security: CuaAdapterArtifactIdentity; + }; + }; + qualificationEvidence: null; +} + +export interface LoadedCuaRuntimeManifest { + path: string; + root: string; + sha256: string; + manifest: CuaRuntimeManifest; + assertFileOwnership: CuaAuthorityFileOwnershipValidator; +} + +export type CuaAuthorityFileOwnershipValidator = (filePath: string, label: string) => void; + +export interface CuaRuntimeManifestValidationOptions { + assertFileOwnership?: CuaAuthorityFileOwnershipValidator; +} + +function object(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function exactKeys(record: Record, expected: readonly string[], label: string) { + const actual = Object.keys(record).sort(); + const wanted = [...expected].sort(); + if (actual.join("\0") !== wanted.join("\0")) { + throw new Error(`${label} must contain exactly: ${wanted.join(", ")}`); + } +} + +export function assertCuaAuthorityFileOwnership(filePath: string, label: string): void { + const effectiveUid = process.geteuid?.(); + if (effectiveUid === undefined) { + throw new Error(`${label} ownership validation requires a POSIX host`); + } + const hasTrustedOwner = (uid: number): boolean => uid === 0 || uid === effectiveUid; + const stat = fs.lstatSync(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + !hasTrustedOwner(stat.uid) || + (stat.mode & 0o022) !== 0 + ) { + throw new Error( + `${label} must be a root- or process-owned regular file without group/world write access`, + ); + } + const parent = fs.lstatSync(path.dirname(filePath)); + if ( + !parent.isDirectory() || + parent.isSymbolicLink() || + !hasTrustedOwner(parent.uid) || + (parent.mode & 0o022) !== 0 + ) { + throw new Error( + `${label} parent must be a root- or process-owned directory without group/world write access`, + ); + } +} + +function requiredString(record: Record, key: string, label: string): string { + const value = record[key]; + if (typeof value !== "string" || value.length === 0 || value.length > 256) { + throw new Error(`${label}.${key} must be a non-empty bounded string`); + } + return value; +} + +function safeIdentity(record: Record, key: string, label: string): string { + const value = requiredString(record, key, label); + if (!SAFE_ID.test(value) || CUA_SENSITIVE_VALUE.test(value) || CUA_HOST_COORDINATE.test(value)) { + throw new Error(`${label}.${key} must be a coordinate- and credential-free identity`); + } + return value; +} + +function rawDigest(record: Record, key: string, label: string): string { + const value = requiredString(record, key, label); + if (!RAW_DIGEST.test(value)) throw new Error(`${label}.${key} must be a lowercase SHA-256`); + return value; +} + +function exactCommit(record: Record, key: string, label: string): string { + const value = requiredString(record, key, label); + if (!COMMIT.test(value)) throw new Error(`${label}.${key} must be an exact lowercase commit`); + return value; +} + +function sizeBytes(record: Record, label: string, maxBytes: number): number { + const value = record.sizeBytes; + if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > maxBytes) { + throw new Error(`${label}.sizeBytes must be from 1 through ${String(maxBytes)}`); + } + return Number(value); +} + +function payloadFile( + value: unknown, + label: string, + maxBytes = MAX_PAYLOAD_BYTES, +): CuaPayloadFileIdentity { + const record = object(value, label); + exactKeys(record, ["filename", "sizeBytes", "sha256"], label); + const filename = requiredString(record, "filename", label); + if ( + !SAFE_FILENAME.test(filename) || + path.basename(filename) !== filename || + CUA_SENSITIVE_VALUE.test(filename) || + CUA_HOST_COORDINATE.test(filename) + ) { + throw new Error(`${label}.filename must be one safe basename`); + } + return { + filename, + sizeBytes: sizeBytes(record, label, maxBytes), + sha256: rawDigest(record, "sha256", label), + }; +} + +function archiveArtifact(value: unknown, label: string): CuaArchiveArtifactIdentity { + const record = object(value, label); + exactKeys(record, ["name", "version", "filename", "sizeBytes", "sha256"], label); + const file = payloadFile( + { + filename: record.filename, + sizeBytes: record.sizeBytes, + sha256: record.sha256, + }, + label, + ); + return { + name: safeIdentity(record, "name", label), + version: safeIdentity(record, "version", label), + ...file, + }; +} + +function adapterArtifact(value: unknown, label: string): CuaAdapterArtifactIdentity { + const record = object(value, label); + exactKeys(record, ["name", "version", "filename", "sizeBytes", "sha256"], label); + return { + name: safeIdentity(record, "name", label), + version: safeIdentity(record, "version", label), + ...payloadFile( + { + filename: record.filename, + sizeBytes: record.sizeBytes, + sha256: record.sha256, + }, + label, + MAX_ADAPTER_BYTES, + ), + }; +} + +function imageArtifact(value: unknown, label: string): CuaImageArtifactIdentity { + const record = object(value, label); + exactKeys(record, ["name", "version", "platform", "digest"], label); + const digest = requiredString(record, "digest", label); + if (!/^sha256:[0-9a-f]{64}$/.test(digest)) { + throw new Error(`${label}.digest must be a sha256 digest`); + } + if (record.platform !== "linux/amd64") { + throw new Error(`${label}.platform must be linux/amd64`); + } + return { + name: safeIdentity(record, "name", label), + version: safeIdentity(record, "version", label), + platform: "linux/amd64", + digest, + }; +} + +function compatibility(value: unknown): CuaRuntimeCompatibility { + const record = object(value, "compatibility"); + if (record.status === "candidate") { + exactKeys(record, ["status", "issue", "candidateSourceRevision"], "compatibility"); + if (record.issue !== 7755) throw new Error("compatibility.issue must be 7755"); + return { + status: "candidate", + issue: 7755, + candidateSourceRevision: exactCommit(record, "candidateSourceRevision", "compatibility"), + }; + } + throw new Error("compatibility.status must be candidate"); +} + +export function parseCuaRuntimeManifest(value: unknown): CuaRuntimeManifest { + const record = object(value, "CUA runtime manifest"); + exactKeys( + record, + [ + "schemaVersion", + "kind", + "agent", + "compatibility", + "bundleReceipt", + "artifacts", + "qualificationEvidence", + ], + "CUA runtime manifest", + ); + if (record.schemaVersion !== "1.0.0" || record.kind !== "cua-runtime-manifest") { + throw new Error("CUA runtime manifest must use cua-runtime-manifest schema 1.0.0"); + } + + const agent = object(record.agent, "agent"); + exactKeys(agent, ["name", "manifest", "dockerfile", "baseDockerfile", "policy"], "agent"); + if (agent.name !== "nemocua") throw new Error("CUA runtime manifest agent must be nemocua"); + + const bundle = object(record.bundleReceipt, "bundleReceipt"); + exactKeys(bundle, ["schema", "releaseId", "producerCommit", "sha256"], "bundleReceipt"); + if (bundle.schema !== "cua.release.bundle/v1") { + throw new Error("bundleReceipt.schema must be cua.release.bundle/v1"); + } + + const artifacts = object(record.artifacts, "artifacts"); + exactKeys( + artifacts, + ["hostCli", "sandboxImage", "targetImage", "targetServices", "adapters"], + "artifacts", + ); + const adapters = object(artifacts.adapters, "artifacts.adapters"); + exactKeys(adapters, ["target", "task", "security"], "artifacts.adapters"); + const parsedCompatibility = compatibility(record.compatibility); + + if (record.qualificationEvidence !== null) { + throw new Error("qualificationEvidence must be absent for candidate"); + } + + const result: CuaRuntimeManifest = { + schemaVersion: "1.0.0", + kind: "cua-runtime-manifest", + agent: { + name: "nemocua", + manifest: payloadFile(agent.manifest, "agent.manifest", MAX_AGENT_MANIFEST_BYTES), + dockerfile: payloadFile(agent.dockerfile, "agent.dockerfile", MAX_DOCKERFILE_BYTES), + baseDockerfile: payloadFile( + agent.baseDockerfile, + "agent.baseDockerfile", + MAX_DOCKERFILE_BYTES, + ), + policy: payloadFile(agent.policy, "agent.policy", MAX_POLICY_BYTES), + }, + compatibility: parsedCompatibility, + bundleReceipt: { + schema: "cua.release.bundle/v1", + releaseId: safeIdentity(bundle, "releaseId", "bundleReceipt"), + producerCommit: exactCommit(bundle, "producerCommit", "bundleReceipt"), + sha256: rawDigest(bundle, "sha256", "bundleReceipt"), + }, + artifacts: { + hostCli: archiveArtifact(artifacts.hostCli, "artifacts.hostCli"), + sandboxImage: imageArtifact(artifacts.sandboxImage, "artifacts.sandboxImage"), + targetImage: imageArtifact(artifacts.targetImage, "artifacts.targetImage"), + targetServices: archiveArtifact(artifacts.targetServices, "artifacts.targetServices"), + adapters: { + target: adapterArtifact(adapters.target, "artifacts.adapters.target"), + task: adapterArtifact(adapters.task, "artifacts.adapters.task"), + security: adapterArtifact(adapters.security, "artifacts.adapters.security"), + }, + }, + qualificationEvidence: null, + }; + + for (const [field, actual, expected] of [ + ["agent.manifest.filename", result.agent.manifest.filename, "manifest.yaml"], + ["agent.dockerfile.filename", result.agent.dockerfile.filename, "Dockerfile"], + ["agent.baseDockerfile.filename", result.agent.baseDockerfile.filename, "Dockerfile.base"], + ["agent.policy.filename", result.agent.policy.filename, "policy-additions.yaml"], + ] as const) { + if (actual !== expected) throw new Error(`${field} must be ${expected}`); + } + + const filenames = [ + result.agent.manifest, + result.agent.dockerfile, + result.agent.baseDockerfile, + result.agent.policy, + result.artifacts.hostCli, + result.artifacts.targetServices, + result.artifacts.adapters.target, + result.artifacts.adapters.task, + result.artifacts.adapters.security, + ].map((identity) => identity.filename); + if (new Set(filenames).size !== filenames.length) { + throw new Error("CUA runtime manifest payload filenames must be unique"); + } + return result; +} + +function expectedManifestSha256(env: NodeJS.ProcessEnv): string { + const value = env[CUA_RUNTIME_MANIFEST_SHA256_ENV]?.trim() ?? ""; + if (!RAW_DIGEST.test(value)) { + throw new Error(`${CUA_RUNTIME_MANIFEST_SHA256_ENV} must be a lowercase SHA-256`); + } + return value; +} + +export function loadCuaRuntimeManifest( + env: NodeJS.ProcessEnv = process.env, + options: CuaRuntimeManifestValidationOptions = {}, +): LoadedCuaRuntimeManifest { + requireCuaFrameworkEnabled(env); + const configuredPath = env[CUA_RUNTIME_MANIFEST_ENV]?.trim() ?? ""; + if (!path.isAbsolute(configuredPath)) { + throw new Error(`${CUA_RUNTIME_MANIFEST_ENV} must be an absolute path`); + } + const assertFileOwnership = options.assertFileOwnership ?? assertCuaAuthorityFileOwnership; + assertFileOwnership(configuredPath, "CUA runtime manifest"); + const raw = readBoundedRegularFile(configuredPath, { + label: "CUA runtime manifest", + minBytes: 2, + maxBytes: MAX_MANIFEST_BYTES, + }); + const sha256 = crypto.createHash("sha256").update(raw).digest("hex"); + if (sha256 !== expectedManifestSha256(env)) { + throw new Error("CUA runtime manifest does not match its expected content identity"); + } + let value: unknown; + try { + value = JSON.parse(raw.toString("utf8")) as unknown; + } catch { + throw new Error("CUA runtime manifest must contain strict JSON"); + } + return { + path: configuredPath, + root: path.dirname(configuredPath), + sha256, + manifest: parseCuaRuntimeManifest(value), + assertFileOwnership, + }; +} + +function verifyPayloadFile( + root: string, + identity: CuaPayloadFileIdentity, + label: string, + assertFileOwnership: CuaAuthorityFileOwnershipValidator, +): string { + const filePath = path.join(root, identity.filename); + assertFileOwnership(filePath, label); + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.size !== BigInt(identity.sizeBytes)) { + throw new Error(`${label} does not match its declared size`); + } + const hash = crypto.createHash("sha256"); + const buffer = Buffer.allocUnsafe(64 * 1024); + let total = 0; + for (;;) { + const read = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (read === 0) break; + total += read; + if (total > identity.sizeBytes) throw new Error(`${label} changed during validation`); + hash.update(buffer.subarray(0, read)); + } + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + total !== identity.sizeBytes || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + hash.digest("hex") !== identity.sha256 + ) { + throw new Error(`${label} does not match its declared content identity`); + } + return filePath; + } finally { + fs.closeSync(descriptor); + } +} + +function copyVerifiedPayloadFile( + root: string, + identity: CuaPayloadFileIdentity, + destination: string, + label: string, + assertFileOwnership: CuaAuthorityFileOwnershipValidator, +): void { + const sourcePath = path.join(root, identity.filename); + assertFileOwnership(sourcePath, label); + const source = fs.openSync(sourcePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + let output: number | undefined; + try { + const before = fs.fstatSync(source, { bigint: true }); + if (!before.isFile() || before.size !== BigInt(identity.sizeBytes)) { + throw new Error(`${label} does not match its declared size`); + } + output = fs.openSync( + destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + Number(before.mode & 0o777n), + ); + const hash = crypto.createHash("sha256"); + const buffer = Buffer.allocUnsafe(64 * 1024); + let total = 0; + for (;;) { + const bytesRead = fs.readSync(source, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + total += bytesRead; + if (total > identity.sizeBytes) throw new Error(`${label} changed during staging`); + hash.update(buffer.subarray(0, bytesRead)); + let offset = 0; + while (offset < bytesRead) { + offset += fs.writeSync(output, buffer, offset, bytesRead - offset); + } + } + fs.fsyncSync(output); + const after = fs.fstatSync(source, { bigint: true }); + if ( + total !== identity.sizeBytes || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + hash.digest("hex") !== identity.sha256 + ) { + throw new Error(`${label} changed or failed its content identity during staging`); + } + } catch (error) { + if (output !== undefined) { + fs.closeSync(output); + output = undefined; + } + try { + fs.rmSync(destination, { force: true }); + } catch { + // Preserve the authority failure; the temporary build context is cleaned by its owner. + } + throw error; + } finally { + if (output !== undefined) fs.closeSync(output); + fs.closeSync(source); + } +} + +function manifestString(record: Record, key: string, label: string): string { + const value = record[key]; + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 512 || + CONTROL_CHARACTER.test(value) || + CUA_HOST_COORDINATE.test(value) || + CUA_SENSITIVE_VALUE.test(value) + ) { + throw new Error(`${label}.${key} must be bounded, printable, coordinate- and credential-free`); + } + return value; +} + +function manifestCommand( + record: Record, + key: string, + label: string, + binary: string, +): string { + const command = manifestString(record, key, label); + const argv = command.split(" "); + if ( + command !== argv.join(" ") || + argv.length < 1 || + argv.length > 16 || + argv[0] !== binary || + argv.some( + (argument) => + !SAFE_COMMAND_ARG.test(argument) || + argument.split("/").some((segment) => segment === "." || segment === ".."), + ) + ) { + throw new Error( + `${label}.${key} must use the declared binary and a closed, canonical argument grammar`, + ); + } + return command; +} + +/** Validate the Launchable-provided YAML before it enters ordinary agent loading. */ +export function validateExternalCuaAgentManifest(raw: Buffer): void { + let parsed: unknown; + try { + parsed = yaml.load(raw.toString("utf8")); + } catch { + throw new Error("External NemoCUA agent manifest must contain strict YAML"); + } + const record = object(parsed, "external NemoCUA agent manifest"); + exactKeys( + record, + [ + "name", + "display_name", + "description", + "binary_path", + "version_command", + "expected_version", + "version_scheme", + "runtime", + "config", + "state_dirs", + "device_pairing", + "inference", + "mcp", + ], + "external NemoCUA agent manifest", + ); + if (record.name !== "nemocua" || record.display_name !== "NemoCUA") { + throw new Error("External NemoCUA agent manifest must identify nemocua and NemoCUA"); + } + manifestString(record, "description", "external NemoCUA agent manifest"); + const binaryPath = manifestString(record, "binary_path", "external NemoCUA agent manifest"); + if (!/^\/(?:usr\/local\/bin|opt\/[A-Za-z0-9._-]+\/bin)\/[A-Za-z0-9._+-]+$/.test(binaryPath)) { + throw new Error("External NemoCUA binary_path must be a canonical sandbox binary path"); + } + const binary = path.basename(binaryPath); + if (binary !== "nemocua") { + throw new Error("External NemoCUA binary_path must name the canonical nemocua executable"); + } + const versionCommand = manifestCommand( + record, + "version_command", + "external NemoCUA agent manifest", + binary, + ); + if (versionCommand !== "nemocua version") { + throw new Error("External NemoCUA version_command must be exactly 'nemocua version'"); + } + manifestString(record, "expected_version", "external NemoCUA agent manifest"); + if (record.version_scheme !== "semver" || record.device_pairing !== false) { + throw new Error("External NemoCUA must use semver and disable device pairing"); + } + + const runtime = object(record.runtime, "external NemoCUA runtime"); + exactKeys( + runtime, + ["kind", "interactive_command", "headless_command", "smoke_commands"], + "external NemoCUA runtime", + ); + if (runtime.kind !== "terminal") throw new Error("External NemoCUA runtime must be terminal"); + const exactRuntimeCommands = { + interactive_command: "nemocua interactive", + headless_command: "nemocua headless", + } as const; + for (const key of ["interactive_command", "headless_command"] as const) { + const command = manifestCommand(runtime, key, "external NemoCUA runtime", binary); + if (command !== exactRuntimeCommands[key]) { + throw new Error(`External NemoCUA ${key} must be exactly '${exactRuntimeCommands[key]}'`); + } + } + if ( + !Array.isArray(runtime.smoke_commands) || + runtime.smoke_commands.length < 1 || + runtime.smoke_commands.length > 8 + ) { + throw new Error("External NemoCUA smoke_commands must contain 1 through 8 commands"); + } + for (const [index, command] of runtime.smoke_commands.entries()) { + if (typeof command !== "string") { + throw new Error(`External NemoCUA smoke_commands[${String(index)}] must be a string`); + } + manifestCommand( + { command }, + "command", + `external NemoCUA smoke_commands[${String(index)}]`, + binary, + ); + } + + const config = object(record.config, "external NemoCUA config"); + exactKeys(config, ["dir", "config_file", "format"], "external NemoCUA config"); + const configDir = manifestString(config, "dir", "external NemoCUA config"); + const configFile = manifestString(config, "config_file", "external NemoCUA config"); + const configSegments = configDir.slice("/sandbox/".length).split("/"); + if ( + !/^\/sandbox\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(configDir) || + configSegments.some((segment) => segment === "." || segment === "..") || + !SAFE_FILENAME.test(configFile) + ) { + throw new Error("External NemoCUA config paths must stay inside /sandbox"); + } + if (!SAFE_ID.test(manifestString(config, "format", "external NemoCUA config"))) { + throw new Error("External NemoCUA config.format is invalid"); + } + + if (!Array.isArray(record.state_dirs) || record.state_dirs.length > 32) { + throw new Error("External NemoCUA state_dirs must be a bounded list"); + } + for (const [index, value] of record.state_dirs.entries()) { + if ( + typeof value !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(value) || + value.split("/").some((segment) => segment === "." || segment === "..") + ) { + throw new Error(`External NemoCUA state_dirs[${String(index)}] is invalid`); + } + } + + const inference = object(record.inference, "external NemoCUA inference"); + exactKeys( + inference, + ["provider_type", "default_model", "proxy_support"], + "external NemoCUA inference", + ); + if (inference.provider_type !== "openai_compatible" || inference.proxy_support !== "implicit") { + throw new Error("External NemoCUA must use managed OpenAI-compatible inference"); + } + const model = manifestString(inference, "default_model", "external NemoCUA inference"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}){0,7}$/.test(model)) { + throw new Error("External NemoCUA inference.default_model is invalid"); + } + const mcp = object(record.mcp, "external NemoCUA mcp"); + exactKeys(mcp, ["support", "reason"], "external NemoCUA mcp"); + if (mcp.support !== "disabled") throw new Error("External NemoCUA MCP support must be disabled"); + manifestString(mcp, "reason", "external NemoCUA mcp"); +} + +function assertSingleBoundDockerfileBase( + dockerfile: string, + options: { + argument: string; + expectedArgument: RegExp; + expectedFrom: RegExp; + error: string; + }, +): void { + const escapedArgument = options.argument.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const boundArgumentInstructions = + dockerfile.match( + new RegExp(`^[\\t ]*ARG[\\t ]+${escapedArgument}(?:[\\t ]*=.*)?[\\t ]*$`, "gim"), + ) ?? []; + const argumentInstructions = dockerfile.match(/^[\t ]*ARG(?:[\t ]|$).*$/gim) ?? []; + const fromInstructions = dockerfile.match(/^[\t ]*FROM(?:[\t ]|$).*$/gim) ?? []; + if ( + (dockerfile.match(options.expectedArgument) ?? []).length !== 1 || + boundArgumentInstructions.length !== 1 || + argumentInstructions.length !== 1 || + (dockerfile.match(options.expectedFrom) ?? []).length !== 1 || + fromInstructions.length !== 1 + ) { + throw new Error(options.error); + } +} + +const CLOSED_DOCKERFILE_METADATA_INSTRUCTIONS = new Set([ + "ARG", + "CMD", + "ENTRYPOINT", + "ENV", + "EXPOSE", + "FROM", + "HEALTHCHECK", + "LABEL", + "SHELL", + "STOPSIGNAL", + "USER", + "VOLUME", + "WORKDIR", +]); +const DOCKERFILE_PARSER_DIRECTIVE = /^#\s*(?:check|escape|syntax)\s*=/i; +const DOCKERFILE_LINE_CONTROL_CHARACTER = /[\x00-\x08\x0b-\x1f\x7f]/; +const CLOSED_COPY_TOKEN = /^[A-Za-z0-9_./:+-]+$/; + +function assertClosedDockerfileBuildInputs( + dockerfile: string, + options: { + label: string; + allowedCopySources: ReadonlySet; + }, +): void { + if (dockerfile.includes("\r")) { + throw new Error(`${options.label} must use unambiguous LF-delimited instructions`); + } + + for (const [index, line] of dockerfile.split("\n").entries()) { + const trimmed = line.trim(); + if (trimmed === "") continue; + if (DOCKERFILE_LINE_CONTROL_CHARACTER.test(line) || line.trimEnd().endsWith("\\")) { + throw new Error( + `${options.label} instruction ${String(index + 1)} uses an unsupported continuation or control character`, + ); + } + if (trimmed.startsWith("#")) { + if (DOCKERFILE_PARSER_DIRECTIVE.test(trimmed)) { + throw new Error(`${options.label} cannot select a Dockerfile parser frontend`); + } + continue; + } + + const match = /^[\t ]*([A-Za-z]+)[\t ]+(.+?)[\t ]*$/.exec(line); + if (!match) { + throw new Error(`${options.label} instruction ${String(index + 1)} is ambiguous`); + } + const instruction = match[1].toUpperCase(); + const body = match[2]; + + if (instruction === "ADD") { + throw new Error(`${options.label} cannot use ADD`); + } + if (instruction === "COPY") { + const tokens = body.split(/[\t ]+/); + if ( + tokens.length !== 2 || + tokens.some((token) => !CLOSED_COPY_TOKEN.test(token)) || + !options.allowedCopySources.has(tokens[0]) + ) { + throw new Error( + `${options.label} COPY must name one exact manifest-bound staged agents/nemocua payload`, + ); + } + continue; + } + if (instruction === "RUN") { + const offline = /^--network=none[\t ]+(.+)$/.exec(body); + if (!offline || offline[1].trimStart().startsWith("--")) { + throw new Error( + `${options.label} RUN must use only the canonical BuildKit --network=none option`, + ); + } + continue; + } + if (!CLOSED_DOCKERFILE_METADATA_INSTRUCTIONS.has(instruction)) { + throw new Error(`${options.label} instruction ${instruction} is unsupported`); + } + } +} + +function decodeDockerfile(bytes: Buffer, label: string): string { + const dockerfile = bytes.toString("utf8"); + if (!Buffer.from(dockerfile, "utf8").equals(bytes)) { + throw new Error(`${label} must contain strict UTF-8`); + } + return dockerfile; +} + +function stagedCuaPayloadSources(manifest: CuaRuntimeManifest): ReadonlySet { + return new Set( + [ + manifest.agent.manifest, + manifest.agent.dockerfile, + manifest.agent.baseDockerfile, + manifest.agent.policy, + manifest.artifacts.hostCli, + manifest.artifacts.targetServices, + manifest.artifacts.adapters.target, + manifest.artifacts.adapters.task, + manifest.artifacts.adapters.security, + ].map((identity) => path.posix.join("agents", "nemocua", identity.filename)), + ); +} + +export function verifyCuaRuntimePayload(loaded: LoadedCuaRuntimeManifest): void { + const { root, manifest } = loaded; + for (const [label, identity] of [ + ["agent manifest", manifest.agent.manifest], + ["agent Dockerfile", manifest.agent.dockerfile], + ["agent base Dockerfile", manifest.agent.baseDockerfile], + ["agent policy", manifest.agent.policy], + ["host CLI", manifest.artifacts.hostCli], + ["target services", manifest.artifacts.targetServices], + ["target adapter", manifest.artifacts.adapters.target], + ["task adapter", manifest.artifacts.adapters.task], + ["security adapter", manifest.artifacts.adapters.security], + ] as const) { + verifyPayloadFile(root, identity, label, loaded.assertFileOwnership); + } + const baseDockerfile = decodeDockerfile( + readBoundedRegularFile(path.join(root, manifest.agent.baseDockerfile.filename), { + label: "agent base Dockerfile", + minBytes: 2, + maxBytes: 1024 * 1024, + }), + "NemoCUA base Dockerfile", + ); + assertSingleBoundDockerfileBase(baseDockerfile, { + argument: "NEMOCUA_RUNTIME_IMAGE", + expectedArgument: /^ARG NEMOCUA_RUNTIME_IMAGE$/gm, + expectedFrom: + /^FROM \$\{NEMOCUA_RUNTIME_IMAGE\}(?:[ \t]+AS[ \t]+[A-Za-z0-9][A-Za-z0-9._-]{0,127})?[ \t]*$/gm, + error: "NemoCUA base Dockerfile must use NEMOCUA_RUNTIME_IMAGE as its sole FROM base", + }); + assertClosedDockerfileBuildInputs(baseDockerfile, { + label: "NemoCUA base Dockerfile", + allowedCopySources: new Set(), + }); + const dockerfile = decodeDockerfile( + readBoundedRegularFile(path.join(root, manifest.agent.dockerfile.filename), { + label: "agent Dockerfile", + minBytes: 2, + maxBytes: 1024 * 1024, + }), + "NemoCUA Dockerfile", + ); + assertSingleBoundDockerfileBase(dockerfile, { + argument: "BASE_IMAGE", + expectedArgument: /^ARG BASE_IMAGE(?:=.*)?$/gm, + expectedFrom: + /^FROM \$\{BASE_IMAGE\}(?:[ \t]+AS[ \t]+[A-Za-z0-9][A-Za-z0-9._-]{0,127})?[ \t]*$/gm, + error: "NemoCUA Dockerfile must use its resolved BASE_IMAGE as its sole FROM base", + }); + assertClosedDockerfileBuildInputs(dockerfile, { + label: "NemoCUA Dockerfile", + allowedCopySources: stagedCuaPayloadSources(manifest), + }); +} + +export function getCuaExternalAgentManifestPath( + env: NodeJS.ProcessEnv = process.env, + options: CuaRuntimeManifestValidationOptions = {}, +): string { + const loaded = loadCuaRuntimeManifest(env, options); + const manifestPath = verifyPayloadFile( + loaded.root, + loaded.manifest.agent.manifest, + "agent manifest", + loaded.assertFileOwnership, + ); + validateExternalCuaAgentManifest( + readBoundedRegularFile(manifestPath, { + label: "external NemoCUA agent manifest", + minBytes: 2, + maxBytes: 256 * 1024, + }), + ); + return manifestPath; +} + +export function getCuaSandboxImageRef( + env: NodeJS.ProcessEnv = process.env, + options: CuaRuntimeManifestValidationOptions = {}, +): string { + const loaded = loadCuaRuntimeManifest(env, options); + const imageRef = env[CUA_SANDBOX_IMAGE_ENV]?.trim() ?? ""; + if ( + !/^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$/.test(imageRef) || + !imageRef.endsWith(`@${loaded.manifest.artifacts.sandboxImage.digest}`) + ) { + throw new Error( + `${CUA_SANDBOX_IMAGE_ENV} must be an immutable reference matching the runtime manifest`, + ); + } + return imageRef; +} + +/** Revalidate the small authority-bearing files without rereading large release archives. */ +export function verifyCuaRuntimeAuthorityPayload( + env: NodeJS.ProcessEnv = process.env, + options: CuaRuntimeManifestValidationOptions = {}, +): LoadedCuaRuntimeManifest { + const loaded = loadCuaRuntimeManifest(env, options); + for (const [label, identity] of [ + ["agent manifest", loaded.manifest.agent.manifest], + ["agent Dockerfile", loaded.manifest.agent.dockerfile], + ["agent base Dockerfile", loaded.manifest.agent.baseDockerfile], + ["agent policy", loaded.manifest.agent.policy], + ["target adapter", loaded.manifest.artifacts.adapters.target], + ["task adapter", loaded.manifest.artifacts.adapters.task], + ["security adapter", loaded.manifest.artifacts.adapters.security], + ] as const) { + verifyPayloadFile(loaded.root, identity, label, loaded.assertFileOwnership); + } + getCuaExternalAgentManifestPath(env, options); + return loaded; +} + +/** Copy only manifest-declared, verified files into the temporary Docker context. */ +export function stageCuaRuntimePayload( + destination: string, + env: NodeJS.ProcessEnv = process.env, + options: CuaRuntimeManifestValidationOptions = {}, +): void { + const loaded = loadCuaRuntimeManifest(env, options); + verifyCuaRuntimePayload(loaded); + fs.mkdirSync(destination, { recursive: true }); + for (const identity of [ + loaded.manifest.agent.manifest, + loaded.manifest.agent.dockerfile, + loaded.manifest.agent.baseDockerfile, + loaded.manifest.agent.policy, + loaded.manifest.artifacts.hostCli, + loaded.manifest.artifacts.targetServices, + loaded.manifest.artifacts.adapters.target, + loaded.manifest.artifacts.adapters.task, + loaded.manifest.artifacts.adapters.security, + ]) { + copyVerifiedPayloadFile( + loaded.root, + identity, + path.join(destination, identity.filename), + identity.filename, + loaded.assertFileOwnership, + ); + } +} diff --git a/src/lib/cua/runtime-readiness.test.ts b/src/lib/cua/runtime-readiness.test.ts new file mode 100644 index 00000000000..c9ff295920d --- /dev/null +++ b/src/lib/cua/runtime-readiness.test.ts @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import fs from "node:fs"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CUA_TASK_OPERATIONS } from "./contract"; +import { + buildCurrentCuaRuntimeReadiness, + getCuaInferenceRouteIdentity, + getPublicCuaRuntimeReadiness, + validateCurrentCuaRuntimeReadiness, +} from "./runtime-readiness"; +import { type CuaRuntimeTestFixture, createCuaRuntimeTestFixture } from "./runtime-test-fixture"; + +const fixtures: CuaRuntimeTestFixture[] = []; +const inference = { + provider: "nvidia", + model: "nvidia/nemotron-3-super-120b-a12b", +}; +const providerAuthorityDigest = `sha256:${"8".repeat(64)}`; +const liveAppliedPolicy = { revision: 7, digest: `sha256:${"9".repeat(64)}` }; + +function fixture(input: Parameters[0] = {}) { + const value = createCuaRuntimeTestFixture(input); + fixtures.push(value); + return value; +} + +afterEach(() => { + vi.restoreAllMocks(); + while (fixtures.length > 0) fixtures.pop()?.cleanup(); +}); + +describe("current CUA runtime readiness", () => { + it("publishes a distinct exact-build candidate only to the qualification lifecycle (#7755)", () => { + const runtime = fixture(); + const env = { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }; + const context = { + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification" as const, + env, + buildIdentity: { + schemaVersion: 1 as const, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }; + + const readiness = buildCurrentCuaRuntimeReadiness(context); + + expect(readiness.status).toBe("candidate"); + expect(readiness.sourceRevision).toBe(runtime.candidateCommit); + expect(readiness.providerAuthorityDigest).toBe(providerAuthorityDigest); + expect(readiness.components.openshell).toEqual({ + name: "openshell", + version: "qualification-bound", + digest: `sha256:${crypto + .createHash("sha256") + .update(fs.readFileSync(runtime.openshellPath)) + .digest("hex")}`, + owner: "NVIDIA", + }); + expect(readiness.qualification).toEqual({ + state: "candidate", + environmentDigest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + bundleReceiptDigest: `sha256:${runtime.manifest.bundleReceipt.sha256}`, + }); + expect(readiness.taskOperations).toEqual(CUA_TASK_OPERATIONS); + expect(getPublicCuaRuntimeReadiness(readiness, context)).toEqual(readiness); + expect( + getPublicCuaRuntimeReadiness(readiness, { + ...context, + acceptance: "final", + }), + ).toBeNull(); + }); + + it("rejects candidate activation when the executing revision does not match (#7755)", () => { + const runtime = fixture(); + + expect(() => + buildCurrentCuaRuntimeReadiness({ + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env: { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }, + buildIdentity: { + schemaVersion: 1, + sourceRevision: "b".repeat(40), + sourceClean: true, + }, + }), + ).toThrow(/qualification environment/); + }); + + it("rejects a candidate environment bound to another runtime manifest (#7755)", () => { + const runtime = fixture(); + const environment = JSON.parse(fs.readFileSync(runtime.environmentPath, "utf8")) as { + runtimeManifestSha256: string; + }; + environment.runtimeManifestSha256 = "f".repeat(64); + fs.chmodSync(runtime.environmentPath, 0o644); + fs.writeFileSync(runtime.environmentPath, JSON.stringify(environment)); + fs.chmodSync(runtime.environmentPath, 0o444); + + expect(() => + buildCurrentCuaRuntimeReadiness({ + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env: { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }, + buildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }), + ).toThrow(/qualification environment/); + }); + + it("rejects an unclean candidate even when every artifact digest matches (#7755)", () => { + const runtime = fixture(); + + expect(() => + buildCurrentCuaRuntimeReadiness({ + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env: { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }, + buildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: false, + }, + }), + ).toThrow(/clean exact NemoClaw build/); + }); + + it("does not let test-mode environment variables bypass candidate evidence permissions (#7755)", () => { + const runtime = fixture(); + fs.chmodSync(runtime.environmentPath, 0o666); + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + + expect(() => + buildCurrentCuaRuntimeReadiness({ + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env: { + ...runtime.env, + NEMOCLAW_CUA_QUALIFICATION: "1", + NODE_ENV: "test", + VITEST: "true", + }, + buildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }), + ).toThrow(/qualification environment.*group\/world write access/i); + }); + + it("rejects an oversized candidate environment before parsing it (#7755)", () => { + const runtime = fixture(); + fs.chmodSync(runtime.environmentPath, 0o644); + fs.truncateSync(runtime.environmentPath, 4097); + fs.chmodSync(runtime.environmentPath, 0o444); + + expect(() => + buildCurrentCuaRuntimeReadiness({ + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env: { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }, + buildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }), + ).toThrow(/through 4096 bytes/); + }); + + it("rejects live inference drift and credential-shaped public selectors (#7755)", () => { + const runtime = fixture(); + const env = { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }; + const readiness = buildCurrentCuaRuntimeReadiness({ + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env, + buildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }); + + expect(() => + validateCurrentCuaRuntimeReadiness(readiness, { + agentName: "nemocua", + recordedInference: inference, + liveInference: { ...inference, model: "nvidia/a-different-model" }, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env, + buildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }), + ).toThrow(/live route/); + + expect(() => + validateCurrentCuaRuntimeReadiness(readiness, { + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: `sha256:${"9".repeat(64)}`, + liveAppliedPolicy, + acceptance: "candidate-qualification", + env, + buildIdentity: { + schemaVersion: 1, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }), + ).toThrow(/current runtime identity/); + + for (const provider of [ + "ghp_example", + "sk-test", + "https://provider.invalid", + "provider.example.xyz", + "2001:db8::1", + "user@host", + "localhost", + "127.0.0.1", + ]) { + expect(() => getCuaInferenceRouteIdentity({ provider, model: "safe-model" })).toThrow( + /coordinate- and credential-free/, + ); + } + for (const model of [ + "ghp_example", + "sk-test", + "https://models.invalid/value", + "user@host/model", + "model?query", + "model#fragment", + "model\nother", + "localhost/model", + "127.0.0.1/model", + ]) { + expect(() => getCuaInferenceRouteIdentity({ provider: "nvidia", model })).toThrow( + /coordinate- and credential-free/, + ); + } + expect( + getCuaInferenceRouteIdentity({ + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-ultra", + }).model, + ).toBe("nvidia/nvidia/nemotron-3-ultra"); + }); + + it("invalidates candidate readiness when the selected OpenShell executable changes (#7755)", () => { + const runtime = fixture(); + const context = { + agentName: "nemocua", + recordedInference: inference, + liveInference: inference, + liveProviderAuthorityDigest: providerAuthorityDigest, + liveAppliedPolicy, + acceptance: "candidate-qualification" as const, + env: { ...runtime.env, NEMOCLAW_CUA_QUALIFICATION: "1" }, + buildIdentity: { + schemaVersion: 1 as const, + sourceRevision: runtime.candidateCommit, + sourceClean: true, + }, + }; + const readiness = buildCurrentCuaRuntimeReadiness(context); + fs.writeFileSync(runtime.openshellPath, "#!/bin/sh\nexit 9\n"); + + expect(() => validateCurrentCuaRuntimeReadiness(readiness, context)).toThrow( + /current runtime identity/, + ); + }); +}); diff --git a/src/lib/cua/runtime-readiness.ts b/src/lib/cua/runtime-readiness.ts new file mode 100644 index 00000000000..d6144ff84e9 --- /dev/null +++ b/src/lib/cua/runtime-readiness.ts @@ -0,0 +1,456 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import path from "node:path"; + +import type { InferenceSelectionInput } from "../inference/selection"; +import { normalizeInferenceSelection } from "../inference/selection"; +import { readBoundedRegularFile } from "./bounded-file"; +import { type CuaBuildIdentity, resolveCurrentCuaBuildIdentity } from "./build-identity"; +import { + CUA_CAPABILITIES, + CUA_LIFECYCLE_SCHEMA_VERSION, + CUA_SECURITY_OPERATIONS, + CUA_TARGET_OPERATIONS, + CUA_TASK_OPERATIONS, + type CuaAppliedPolicyIdentity, + type CuaComponentIdentity, + type CuaInferenceIdentity, + type CuaRuntimeReadiness, +} from "./contract"; +import { + CUA_QUALIFICATION_ENVIRONMENT_ENV, + isCuaFrameworkEnabled, + isCuaQualificationEnabled, +} from "./feature"; +import { snapshotCuaOpenshellExecutable } from "./openshell-authority"; +import { parseCuaQualificationEnvironment } from "./qualification-evidence"; +import { + assertCuaAuthorityFileOwnership, + type CuaArchiveArtifactIdentity, + type CuaImageArtifactIdentity, + type CuaRuntimeManifest, + getCuaSandboxImageRef, + verifyCuaRuntimeAuthorityPayload, +} from "./runtime-manifest"; +import { parseCuaRuntimeReadiness } from "./schema"; +import { + CUA_DOMAIN_COORDINATE, + CUA_HOST_COORDINATE, + CUA_SENSITIVE_VALUE, + canonicalJsonSha256, +} from "./shared-primitives"; + +const SAFE_PROVIDER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SAFE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}){0,7}$/; +const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SAFE_CREDENTIAL_ENV = /^[A-Z][A-Z0-9_]{0,127}$/; +const MAX_QUALIFICATION_ENVIRONMENT_BYTES = 4096; + +export type CuaReadinessAcceptance = "final" | "candidate-qualification"; + +export type CuaInferenceRouteInput = InferenceSelectionInput; + +export interface CuaRuntimeReadinessContext { + agentName: string | null | undefined; + recordedInference: CuaInferenceRouteInput; + liveInference?: CuaInferenceRouteInput; + liveProviderAuthorityDigest?: string; + liveAppliedPolicy?: CuaAppliedPolicyIdentity; + acceptance?: CuaReadinessAcceptance; + env?: NodeJS.ProcessEnv; + rootDir?: string; + buildIdentity?: CuaBuildIdentity; + /** Exact executable already selected by the OpenShell command facade. */ + openshellBinary?: string; + /** Digest of the exact snapshot used for the preceding live observation. */ + expectedOpenshellDigest?: string; +} + +function digestJson(value: unknown): string { + return canonicalJsonSha256(value); +} + +function contentDigest(value: unknown): string { + return `sha256:${digestJson(value)}`; +} + +function safePublicValue( + value: string, + pattern: RegExp, + label: string, + rejectDomain = false, +): string { + if ( + !pattern.test(value) || + CUA_SENSITIVE_VALUE.test(value) || + CUA_HOST_COORDINATE.test(value) || + (rejectDomain && CUA_DOMAIN_COORDINATE.test(value)) || + /[\x00-\x1f\x7f]/.test(value) + ) { + throw new Error(`${label} must be a printable coordinate- and credential-free identity`); + } + return value; +} + +function canonicalEndpoint(value: string | null): string | null { + if (!value) return null; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("CUA inference endpoint must be an absolute HTTP(S) URL"); + } + if ( + (parsed.protocol !== "https:" && parsed.protocol !== "http:") || + parsed.username !== "" || + parsed.password !== "" || + parsed.search !== "" || + parsed.hash !== "" + ) { + throw new Error("CUA inference endpoint must not contain credentials, query, or fragment"); + } + parsed.hostname = parsed.hostname.toLowerCase(); + parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; + return parsed.toString().replace(/\/$/, parsed.pathname === "/" ? "" : ""); +} + +/** + * Compute the public identity of every secret-free field that selects an inference route. + * Credential values are never read; only the configured environment-variable name is bound. + */ +export function getCuaInferenceRouteIdentity(input: CuaInferenceRouteInput): CuaInferenceIdentity { + const route = normalizeInferenceSelection(input); + if (!route.provider || !route.model) { + throw new Error("CUA inference route requires provider and model"); + } + const provider = safePublicValue(route.provider, SAFE_PROVIDER, "inference.provider", true); + const model = safePublicValue(route.model, SAFE_MODEL, "inference.model"); + const endpointSource = route.endpointSource; + const preferredInferenceApi = route.preferredInferenceApi; + const credentialEnv = route.credentialEnv; + const nimContainer = route.nimContainer; + const compatibleEndpointReasoning = route.compatibleEndpointReasoning; + const compatibleEndpointReasoningEffort = route.compatibleEndpointReasoningEffort; + if ( + endpointSource !== null && + endpointSource !== "onboard" && + endpointSource !== "inference-set" + ) { + throw new Error("CUA inference endpoint source is unsupported"); + } + if ( + preferredInferenceApi !== null && + !["openai-completions", "anthropic-messages", "openai-responses"].includes( + preferredInferenceApi, + ) + ) { + throw new Error("CUA inference API family is unsupported"); + } + if (credentialEnv !== null && !SAFE_CREDENTIAL_ENV.test(credentialEnv)) { + throw new Error("CUA inference credential binding name is invalid"); + } + if (nimContainer !== null) { + safePublicValue(nimContainer, SAFE_ROUTE_VALUE, "inference.nimContainer"); + } + const routeDigest = contentDigest({ + provider, + model, + endpointUrl: canonicalEndpoint(route.endpointUrl), + endpointSource, + preferredInferenceApi, + credentialEnv, + nimContainer, + compatibleEndpointReasoning, + compatibleEndpointReasoningEffort, + }); + return { provider, model, routeDigest }; +} + +export function cuaInferenceRoutesMatch( + expected: CuaInferenceIdentity, + actual: CuaInferenceRouteInput, +): boolean { + const identity = getCuaInferenceRouteIdentity(actual); + return ( + identity.provider === expected.provider && + identity.model === expected.model && + identity.routeDigest === expected.routeDigest + ); +} + +function archiveComponent( + identity: CuaArchiveArtifactIdentity, + owner: string, +): CuaComponentIdentity { + return { + name: identity.name, + version: identity.version, + digest: `sha256:${identity.sha256}`, + owner, + }; +} + +function imageComponent(identity: CuaImageArtifactIdentity): CuaComponentIdentity { + return { + name: identity.name, + version: identity.version, + digest: identity.digest, + owner: "NVIDIA", + }; +} + +function openshellComponent( + context: CuaRuntimeReadinessContext, + env: NodeJS.ProcessEnv, +): CuaComponentIdentity { + const snapshot = snapshotCuaOpenshellExecutable({ + selectedBinary: context.openshellBinary, + expectedDigest: context.expectedOpenshellDigest, + env, + }); + try { + return { + name: "openshell", + version: "qualification-bound", + digest: snapshot.executableDigest, + owner: "NVIDIA", + }; + } finally { + snapshot.cleanup(); + } +} + +function expectedComponents( + manifest: CuaRuntimeManifest, + openshell: CuaComponentIdentity, +): CuaRuntimeReadiness["components"] { + return { + openshell, + runtime: archiveComponent(manifest.artifacts.hostCli, "NVIDIA"), + sandboxImage: imageComponent(manifest.artifacts.sandboxImage), + targetAdapter: { + name: manifest.artifacts.adapters.target.name, + version: manifest.artifacts.adapters.target.version, + digest: `sha256:${manifest.artifacts.adapters.target.sha256}`, + owner: "NVIDIA", + }, + policy: { + name: "nemocua-policy", + version: "1.0.0", + digest: `sha256:${manifest.agent.policy.sha256}`, + owner: "NVIDIA", + }, + taskProtocol: { + name: manifest.artifacts.adapters.task.name, + version: manifest.artifacts.adapters.task.version, + digest: `sha256:${manifest.artifacts.adapters.task.sha256}`, + owner: "NVIDIA", + }, + securityVerifier: { + name: manifest.artifacts.adapters.security.name, + version: manifest.artifacts.adapters.security.version, + digest: `sha256:${manifest.artifacts.adapters.security.sha256}`, + owner: "NVIDIA", + }, + }; +} + +function qualificationEnvironment(env: NodeJS.ProcessEnv): { + value: ReturnType; + sha256: string; +} { + const filePath = env[CUA_QUALIFICATION_ENVIRONMENT_ENV]?.trim() ?? ""; + if (!path.isAbsolute(filePath)) { + throw new Error(`${CUA_QUALIFICATION_ENVIRONMENT_ENV} must be an absolute path`); + } + assertCuaAuthorityFileOwnership(filePath, "CUA qualification environment"); + const raw = readBoundedRegularFile(filePath, { + label: "CUA qualification environment", + minBytes: 2, + maxBytes: MAX_QUALIFICATION_ENVIRONMENT_BYTES, + }); + let value: unknown; + try { + value = JSON.parse(raw.toString("utf8")) as unknown; + } catch { + throw new Error("CUA qualification environment must contain strict JSON"); + } + return { + value: parseCuaQualificationEnvironment(value), + sha256: crypto.createHash("sha256").update(raw).digest("hex"), + }; +} + +function assertCandidateManifestBindings( + readiness: CuaRuntimeReadiness, + manifest: CuaRuntimeManifest & { + compatibility: Extract; + }, + env: NodeJS.ProcessEnv, +): void { + const environment = qualificationEnvironment(env); + if ( + environment.value.nemoclawCommit !== readiness.sourceRevision || + environment.value.nemoclawCommit !== manifest.compatibility.candidateSourceRevision || + environment.value.bundleReceiptSha256 !== manifest.bundleReceipt.sha256 || + environment.value.runtimeManifestSha256 !== readiness.runtimeManifestDigest.slice(7) || + readiness.qualification?.state !== "candidate" || + readiness.qualification.environmentDigest !== `sha256:${environment.sha256}` || + readiness.qualification.bundleReceiptDigest !== `sha256:${manifest.bundleReceipt.sha256}` + ) { + throw new Error("CUA candidate readiness does not match its qualification environment"); + } +} + +function resolveContext(context: CuaRuntimeReadinessContext) { + const env = context.env ?? process.env; + if (!isCuaFrameworkEnabled(env)) throw new Error("CUA is disabled"); + if (context.agentName !== "nemocua") { + throw new Error("CUA runtime readiness requires sandbox agent nemocua"); + } + const rootDir = context.rootDir ?? path.resolve(__dirname, "..", "..", ".."); + const build = resolveCurrentCuaBuildIdentity({ + rootDir, + ...(context.buildIdentity ? { buildIdentity: context.buildIdentity } : {}), + }); + if (!build.sourceClean) throw new Error("CUA requires a clean exact NemoClaw build"); + const loaded = verifyCuaRuntimeAuthorityPayload(env); + getCuaSandboxImageRef(env); + const openshell = openshellComponent(context, env); + if ( + !context.liveInference || + !context.liveProviderAuthorityDigest || + !/^sha256:[a-f0-9]{64}$/.test(context.liveProviderAuthorityDigest) + ) { + throw new Error("CUA requires a live managed inference provider identity"); + } + if ( + !context.liveAppliedPolicy || + !Number.isSafeInteger(context.liveAppliedPolicy.revision) || + !/^sha256:[a-f0-9]{64}$/.test(context.liveAppliedPolicy.digest) + ) { + throw new Error("CUA requires a live applied policy identity"); + } + const inference = getCuaInferenceRouteIdentity(context.recordedInference); + if (!cuaInferenceRoutesMatch(inference, context.liveInference)) { + throw new Error("CUA inference route no longer matches the live route"); + } + return { + env, + rootDir, + build, + loaded, + openshell, + inference, + providerAuthorityDigest: context.liveProviderAuthorityDigest, + appliedPolicy: context.liveAppliedPolicy, + }; +} + +export function validateCurrentCuaRuntimeReadiness( + value: unknown, + context: CuaRuntimeReadinessContext, +): CuaRuntimeReadiness { + const readiness = parseCuaRuntimeReadiness(value); + const { env, build, loaded, openshell, inference, providerAuthorityDigest, appliedPolicy } = + resolveContext(context); + if ( + readiness.agent !== context.agentName || + readiness.sourceRevision !== build.sourceRevision || + readiness.sourceClean !== true || + readiness.runtimeManifestDigest !== `sha256:${loaded.sha256}` || + readiness.providerAuthorityDigest !== providerAuthorityDigest || + digestJson(readiness.appliedPolicy) !== digestJson(appliedPolicy) || + digestJson(readiness.inference) !== digestJson(inference) || + digestJson(readiness.components) !== digestJson(expectedComponents(loaded.manifest, openshell)) + ) { + throw new Error("stored CUA readiness does not match the current runtime identity"); + } + + if (readiness.status === "candidate") { + if ( + context.acceptance !== "candidate-qualification" || + !isCuaQualificationEnabled(env) || + loaded.manifest.compatibility.status !== "candidate" + ) { + throw new Error("candidate CUA readiness is not final runtime authority"); + } + assertCandidateManifestBindings( + readiness, + { + ...loaded.manifest, + compatibility: loaded.manifest.compatibility, + }, + env, + ); + } + return readiness; +} + +export function buildCurrentCuaRuntimeReadiness( + context: CuaRuntimeReadinessContext, +): CuaRuntimeReadiness { + const { env, build, loaded, openshell, inference, providerAuthorityDigest, appliedPolicy } = + resolveContext(context); + const manifest = loaded.manifest; + let status: CuaRuntimeReadiness["status"] = "unavailable"; + let qualification: CuaRuntimeReadiness["qualification"] = null; + if (manifest.compatibility.status === "candidate" && isCuaQualificationEnabled(env)) { + const environment = qualificationEnvironment(env); + status = "candidate"; + qualification = { + state: "candidate", + environmentDigest: `sha256:${environment.sha256}`, + bundleReceiptDigest: `sha256:${manifest.bundleReceipt.sha256}`, + }; + } + const readiness: CuaRuntimeReadiness = { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "runtime-readiness", + agent: "nemocua", + mode: "standalone", + status, + sourceRevision: build.sourceRevision, + sourceClean: true, + runtimeManifestDigest: `sha256:${loaded.sha256}`, + providerAuthorityDigest, + appliedPolicy, + qualification, + components: expectedComponents(manifest, openshell), + inference, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: [...CUA_CAPABILITIES], + targetOperations: [...CUA_TARGET_OPERATIONS], + taskOperations: [...CUA_TASK_OPERATIONS], + securityOperations: [...CUA_SECURITY_OPERATIONS], + }; + const parsed = parseCuaRuntimeReadiness(readiness); + if (parsed.status === "candidate") { + return validateCurrentCuaRuntimeReadiness(parsed, context); + } + return parsed; +} + +export function requireCurrentCuaRuntimeReadiness( + context: CuaRuntimeReadinessContext, +): CuaRuntimeReadiness { + const readiness = buildCurrentCuaRuntimeReadiness(context); + if (readiness.status !== "candidate" || context.acceptance !== "candidate-qualification") { + throw new Error("CUA runtime artifacts are not qualified for the selected lifecycle mode"); + } + return readiness; +} + +export function getPublicCuaRuntimeReadiness( + value: unknown, + context: CuaRuntimeReadinessContext, +): CuaRuntimeReadiness | null { + try { + return validateCurrentCuaRuntimeReadiness(value, context); + } catch { + return null; + } +} diff --git a/src/lib/cua/runtime-test-fixture.ts b/src/lib/cua/runtime-test-fixture.ts new file mode 100644 index 00000000000..f59e588e397 --- /dev/null +++ b/src/lib/cua/runtime-test-fixture.ts @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { CuaQualificationEnvironment } from "./qualification-evidence"; +import type { CuaPayloadFileIdentity, CuaRuntimeManifest } from "./runtime-manifest"; + +const CANDIDATE_COMMIT = "a".repeat(40); +const BUNDLE_SHA256 = "c".repeat(64); +const SANDBOX_IMAGE_DIGEST = `sha256:${"d".repeat(64)}`; +const TARGET_IMAGE_DIGEST = `sha256:${"e".repeat(64)}`; + +function digest(bytes: Buffer | string): string { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} +function writePayload(root: string, filename: string, contents: string): CuaPayloadFileIdentity { + const bytes = Buffer.from(contents); + fs.writeFileSync(path.join(root, filename), bytes, { + mode: filename.endsWith(".sh") ? 0o755 : 0o444, + }); + return { filename, sizeBytes: bytes.length, sha256: digest(bytes) }; +} + +function agentManifest(): string { + return [ + "name: nemocua", + "display_name: NemoCUA", + "description: NemoCUA terminal runtime", + "binary_path: /usr/local/bin/nemocua", + 'version_command: "nemocua version"', + "expected_version: 1.0.0", + "version_scheme: semver", + "runtime:", + " kind: terminal", + " interactive_command: nemocua interactive", + " headless_command: nemocua headless", + " smoke_commands:", + " - nemocua version", + " - nemocua smoke", + "config:", + " dir: /sandbox/.nemocua", + " config_file: config.json", + " format: json", + "state_dirs:", + " - nemocua-state", + "device_pairing: false", + "inference:", + " provider_type: openai_compatible", + " default_model: nvidia/nemotron-3-super-120b-a12b", + " proxy_support: implicit", + "mcp:", + " support: disabled", + " reason: Candidate install-and-inspect only", + "", + ].join("\n"); +} + +export interface CuaRuntimeTestFixture { + root: string; + manifestPath: string; + environmentPath: string; + openshellPath: string; + env: NodeJS.ProcessEnv; + manifest: CuaRuntimeManifest; + candidateCommit: string; + candidateEnvironment: CuaQualificationEnvironment; + rewriteManifest: (mutate: (manifest: Record) => void) => void; + cleanup: () => void; +} + +export function createCuaRuntimeTestFixture( + input: { + openshellContents?: string; + targetAdapterContents?: string; + taskAdapterContents?: string; + securityAdapterContents?: string; + } = {}, +): CuaRuntimeTestFixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-runtime-")); + const payload = { + openshell: writePayload(root, "openshell.sh", input.openshellContents ?? "#!/bin/sh\nexit 0\n"), + manifest: writePayload(root, "manifest.yaml", agentManifest()), + dockerfile: writePayload( + root, + "Dockerfile", + "ARG BASE_IMAGE\nFROM ${BASE_IMAGE}\nCOPY agents/nemocua/nemocua-cli.tar.gz /tmp/nemocua-cli.tar.gz\n", + ), + baseDockerfile: writePayload( + root, + "Dockerfile.base", + "ARG NEMOCUA_RUNTIME_IMAGE\nFROM ${NEMOCUA_RUNTIME_IMAGE}\n", + ), + policy: writePayload(root, "policy-additions.yaml", "version: 1\nnetwork_policies: {}\n"), + hostCli: writePayload(root, "nemocua-cli.tar.gz", "host-cli-archive"), + targetServices: writePayload(root, "target-services.tar.gz", "target-services-archive"), + target: writePayload( + root, + "target-adapter.sh", + input.targetAdapterContents ?? "#!/bin/sh\nexit 0\n", + ), + task: writePayload(root, "task-adapter.sh", input.taskAdapterContents ?? "#!/bin/sh\nexit 0\n"), + security: writePayload( + root, + "security-adapter.sh", + input.securityAdapterContents ?? "#!/bin/sh\nexit 0\n", + ), + }; + + const manifest: CuaRuntimeManifest = { + schemaVersion: "1.0.0", + kind: "cua-runtime-manifest", + agent: { + name: "nemocua", + manifest: payload.manifest, + dockerfile: payload.dockerfile, + baseDockerfile: payload.baseDockerfile, + policy: payload.policy, + }, + compatibility: { + status: "candidate", + issue: 7755, + candidateSourceRevision: CANDIDATE_COMMIT, + }, + bundleReceipt: { + schema: "cua.release.bundle/v1", + releaseId: "release-1", + producerCommit: CANDIDATE_COMMIT, + sha256: BUNDLE_SHA256, + }, + artifacts: { + hostCli: { name: "nemocua-runtime", version: "1.0.0", ...payload.hostCli }, + sandboxImage: { + name: "nemocua-sandbox", + version: "1.0.0", + platform: "linux/amd64", + digest: SANDBOX_IMAGE_DIGEST, + }, + targetImage: { + name: "nemocua-target", + version: "1.0.0", + platform: "linux/amd64", + digest: TARGET_IMAGE_DIGEST, + }, + targetServices: { + name: "nemocua-services", + version: "1.0.0", + ...payload.targetServices, + }, + adapters: { + target: { name: "target-adapter", version: "1.0.0", ...payload.target }, + task: { name: "task-adapter", version: "1.0.0", ...payload.task }, + security: { name: "security-adapter", version: "1.0.0", ...payload.security }, + }, + }, + qualificationEvidence: null, + }; + + const manifestPath = path.join(root, "runtime-manifest.json"); + const environmentPath = path.join(root, "cua-qualification-environment.json"); + const openshellPath = path.join(root, payload.openshell.filename); + const env: NodeJS.ProcessEnv = { + NEMOCLAW_CUA_ENABLED: "1", + NEMOCLAW_CUA_RUNTIME_MANIFEST: manifestPath, + NEMOCLAW_CUA_RUNTIME_MANIFEST_SHA256: "", + NEMOCLAW_CUA_SANDBOX_IMAGE_REF: `registry.invalid/nemocua@${SANDBOX_IMAGE_DIGEST}`, + NEMOCLAW_CUA_QUALIFICATION_ENVIRONMENT: environmentPath, + NEMOCLAW_OPENSHELL_BIN: openshellPath, + }; + + const writeManifest = (): string => { + const raw = JSON.stringify(manifest); + const temporaryManifestPath = path.join(root, ".runtime-manifest.json.tmp"); + fs.writeFileSync(temporaryManifestPath, raw, { flag: "wx", mode: 0o444 }); + try { + fs.renameSync(temporaryManifestPath, manifestPath); + } catch (error) { + fs.rmSync(temporaryManifestPath, { force: true }); + throw error; + } + const sha256 = digest(raw); + env.NEMOCLAW_CUA_RUNTIME_MANIFEST_SHA256 = sha256; + return sha256; + }; + const manifestSha256 = writeManifest(); + const candidateEnvironment: CuaQualificationEnvironment = { + schemaVersion: "1.0.0", + kind: "cua-candidate-environment", + nemoclawCommit: CANDIDATE_COMMIT, + bundleReceiptSha256: BUNDLE_SHA256, + runtimeManifestSha256: manifestSha256, + }; + fs.writeFileSync(environmentPath, JSON.stringify(candidateEnvironment), { mode: 0o444 }); + + return { + root, + manifestPath, + environmentPath, + openshellPath, + env, + manifest, + candidateCommit: CANDIDATE_COMMIT, + candidateEnvironment, + rewriteManifest: (mutate) => { + mutate(manifest as unknown as Record); + writeManifest(); + }, + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} diff --git a/src/lib/cua/schema.test.ts b/src/lib/cua/schema.test.ts new file mode 100644 index 00000000000..3cb9ea096a6 --- /dev/null +++ b/src/lib/cua/schema.test.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { CUA_LIFECYCLE_SCHEMA_VERSION } from "./contract"; +import { parseCuaTargetManifest } from "./schema"; + +const digest = (value: string): string => `sha256:${value.repeat(64).slice(0, 64)}`; + +function targetManifest(): Record { + return { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-manifest", + identityDigest: digest("1"), + platform: "fixture-linux-amd64", + image: { + name: "fixture-image", + version: "1.0.0", + digest: digest("2"), + owner: "fixture", + }, + serviceBundle: { + name: "fixture-services", + version: "1.0.0", + digest: digest("3"), + owner: "fixture", + }, + capabilities: [ + { id: "browser", protocolVersion: "1.0.0" }, + { id: "computer", protocolVersion: "1.0.0" }, + { id: "terminal", protocolVersion: "1.0.0" }, + ], + }; +} + +describe("CUA target manifest schema (#7751)", () => { + it("accepts only immutable target and capability identities", () => { + expect(parseCuaTargetManifest(targetManifest())).toEqual(targetManifest()); + }); + + it("rejects credential-shaped or transport fields", () => { + expect(() => + parseCuaTargetManifest({ ...targetManifest(), serviceToken: "not-public" }), + ).toThrow("does not match its schema"); + expect(() => + parseCuaTargetManifest({ ...targetManifest(), endpoint: "https://target.invalid" }), + ).toThrow("does not match its schema"); + + const unsafePlatform = targetManifest(); + unsafePlatform.platform = "target.invalid"; + expect(() => parseCuaTargetManifest(unsafePlatform)).toThrow(/coordinate- and credential-free/); + + const unsafeComponent = targetManifest(); + (unsafeComponent.serviceBundle as Record).owner = "operator@target.invalid"; + expect(() => parseCuaTargetManifest(unsafeComponent)).toThrow( + /coordinate- and credential-free/, + ); + }); + + it("requires browser, computer, and terminal exactly once", () => { + const duplicate = targetManifest(); + duplicate.capabilities = [ + { id: "browser", protocolVersion: "1.0.0" }, + { id: "browser", protocolVersion: "1.0.0" }, + { id: "terminal", protocolVersion: "1.0.0" }, + ]; + expect(() => parseCuaTargetManifest(duplicate)).toThrow( + "must declare browser, computer, and terminal once", + ); + }); +}); diff --git a/src/lib/cua/schema.ts b/src/lib/cua/schema.ts new file mode 100644 index 00000000000..305cd0550d5 --- /dev/null +++ b/src/lib/cua/schema.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Ajv2020, { type AnySchema, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; +import cuaLifecycleSchema from "../../../schemas/cua-lifecycle.schema.json"; +import cuaTargetManifestSchema from "../../../schemas/cua-target-manifest.schema.json"; +import { + CUA_CAPABILITIES, + type CuaCapabilityIdentity, + type CuaComponentIdentity, + type CuaRuntimeReadiness, + getCuaComponentIdentityErrors, + getCuaCoordinateFreeSelectorErrors, + getCuaLifecycleSemanticErrors, +} from "./contract"; + +export interface CuaTargetManifest { + schemaVersion: string; + kind: "target-manifest"; + identityDigest: string; + platform: string; + image: CuaComponentIdentity; + serviceBundle: CuaComponentIdentity; + capabilities: readonly CuaCapabilityIdentity[]; +} + +const ajv = new Ajv2020({ allErrors: true, strict: true }); +const validateLifecycle = ajv.compile(cuaLifecycleSchema as AnySchema); +const validateTargetManifest = ajv.compile(cuaTargetManifestSchema as AnySchema); + +function schemaErrorPaths(errors: ErrorObject[] | null | undefined): string { + const paths = (errors ?? []).map((error) => error.instancePath || "$"); + return [...new Set(paths)].sort().join(", ") || "$"; +} + +function parseWithSchema(value: unknown, validate: ValidateFunction, label: string): T { + if (!validate(value)) { + throw new Error(`${label} does not match its schema at ${schemaErrorPaths(validate.errors)}`); + } + return structuredClone(value) as T; +} + +export function parseCuaLifecycleRecord(value: unknown): CuaRuntimeReadiness { + const record = parseWithSchema( + value, + validateLifecycle, + "CUA lifecycle record", + ); + const semanticErrors = getCuaLifecycleSemanticErrors(record); + if (semanticErrors.length > 0) { + throw new Error(`CUA lifecycle record violates its contract: ${semanticErrors.join("; ")}`); + } + return record; +} + +export function parseCuaRuntimeReadiness(value: unknown): CuaRuntimeReadiness { + return parseCuaLifecycleRecord(value); +} + +export function parseCuaTargetManifest(value: unknown): CuaTargetManifest { + const manifest = parseWithSchema( + value, + validateTargetManifest, + "CUA target manifest", + ); + const capabilityIds = manifest.capabilities.map((capability) => capability.id); + const expected = new Set(CUA_CAPABILITIES); + if ( + new Set(capabilityIds).size !== CUA_CAPABILITIES.length || + capabilityIds.some((capability) => !expected.has(capability)) + ) { + throw new Error("CUA target manifest must declare browser, computer, and terminal once"); + } + const identityErrors = [ + ...getCuaCoordinateFreeSelectorErrors(manifest.platform, "platform"), + ...getCuaComponentIdentityErrors(manifest.image, "image"), + ...getCuaComponentIdentityErrors(manifest.serviceBundle, "serviceBundle"), + ]; + if (identityErrors.length > 0) { + throw new Error(`CUA target manifest violates its contract: ${identityErrors.join("; ")}`); + } + return manifest; +} diff --git a/src/lib/cua/shared-primitives.test.ts b/src/lib/cua/shared-primitives.test.ts new file mode 100644 index 00000000000..0956aaca872 --- /dev/null +++ b/src/lib/cua/shared-primitives.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + CUA_DOMAIN_COORDINATE, + CUA_HOST_COORDINATE, + canonicalizeCuaJson, + canonicalJsonSha256, +} from "./shared-primitives"; + +describe("CUA shared public-value primitives", () => { + it("rejects IPv6 and arbitrary-domain host coordinates (#7755)", () => { + expect(CUA_HOST_COORDINATE.test("2001:db8::1")).toBe(true); + expect(CUA_DOMAIN_COORDINATE.test("provider.example.xyz")).toBe(true); + expect(CUA_HOST_COORDINATE.test("agents-nemocua.yaml")).toBe(false); + expect(CUA_HOST_COORDINATE.test("nvidia-provider")).toBe(false); + }); + + it("serializes numeric property names in code-unit order (#7755)", () => { + const value = { "2": "two", "10": "ten", nested: { z: 1, a: 2 } }; + + expect(canonicalizeCuaJson(value)).toBe('{"10":"ten","2":"two","nested":{"a":2,"z":1}}'); + expect(canonicalJsonSha256(value)).toBe( + "83906dec6494e0c5b8791aaf0b84a3aa9d718c74154ccde98c48ca49c96d398e", + ); + }); + + it("rejects circular values and accepts repeated acyclic references (#7755)", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + expect(() => canonicalizeCuaJson(circular)).toThrow( + new TypeError("CUA canonical JSON value contains a circular reference"), + ); + + const shared = { value: 1 }; + expect(canonicalizeCuaJson({ left: shared, right: shared })).toBe( + '{"left":{"value":1},"right":{"value":1}}', + ); + }); +}); diff --git a/src/lib/cua/shared-primitives.ts b/src/lib/cua/shared-primitives.ts new file mode 100644 index 00000000000..1563d59ccc6 --- /dev/null +++ b/src/lib/cua/shared-primitives.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; + +export const CUA_SENSITIVE_VALUE = + /(?:auth|bearer|credential|password|secret|token)|(?:^|[/._-])(?:ghp_|sk-)/i; + +export const CUA_HOST_COORDINATE = + /(?:[a-z][a-z0-9+.-]*:\/\/|@|[?#\\]|\b(?:\d{1,3}\.){3}\d{1,3}\b|\[[0-9a-f:]+\]|(?:^|[^0-9a-f:])(?:(?:[0-9a-f]{1,4}:){2,7}[0-9a-f:]{0,4}|::[0-9a-f]{1,4})(?=$|[^0-9a-f:])|\b(?:localhost|ip6-localhost)(?:\.[a-z0-9-]+)*\b|\b[a-z0-9-]+\.(?:com|net|org|io|ai|dev|cloud|internal|local|invalid)\b)/i; + +export const CUA_DOMAIN_COORDINATE = + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function canonicalizeCuaJsonValue(value: unknown, active: WeakSet): string | undefined { + if (typeof value !== "object" || value === null) return JSON.stringify(value); + if (active.has(value)) + throw new TypeError("CUA canonical JSON value contains a circular reference"); + active.add(value); + try { + if (Array.isArray(value)) { + return `[${Array.from(value, (child) => canonicalizeCuaJsonValue(child, active) ?? "null").join(",")}]`; + } + const entries = Object.entries(value) + .sort(([left], [right]) => compareCodeUnits(left, right)) + .flatMap(([key, child]) => { + const serialized = canonicalizeCuaJsonValue(child, active); + return serialized === undefined ? [] : [`${JSON.stringify(key)}:${serialized}`]; + }); + return `{${entries.join(",")}}`; + } finally { + active.delete(value); + } +} + +export function canonicalizeCuaJson(value: unknown): string | undefined { + return canonicalizeCuaJsonValue(value, new WeakSet()); +} + +export function canonicalJsonSha256(value: unknown): string { + const canonical = canonicalizeCuaJson(value); + if (canonical === undefined) throw new TypeError("CUA canonical JSON value is not serializable"); + return crypto.createHash("sha256").update(canonical).digest("hex"); +} diff --git a/src/lib/cua/state.test.ts b/src/lib/cua/state.test.ts new file mode 100644 index 00000000000..8a06157e0e9 --- /dev/null +++ b/src/lib/cua/state.test.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { CuaRuntimeReadiness } from "./contract"; +import { getObservedValidatedCuaState } from "./state"; + +const digest = (character: string): string => `sha256:${character.repeat(64)}`; + +function readiness(): CuaRuntimeReadiness { + const component = (name: string, character: string) => ({ + name, + version: "1.0.0", + digest: digest(character), + owner: "NVIDIA", + }); + return { + schemaVersion: "1.0.0", + kind: "runtime-readiness", + agent: "nemocua", + mode: "standalone", + status: "candidate", + sourceRevision: "a".repeat(40), + sourceClean: true, + runtimeManifestDigest: digest("b"), + providerAuthorityDigest: digest("c"), + qualification: { + state: "candidate", + environmentDigest: digest("d"), + bundleReceiptDigest: digest("e"), + }, + components: { + openshell: component("openshell", "1"), + runtime: component("runtime", "2"), + sandboxImage: component("sandbox-image", "3"), + targetAdapter: component("target-adapter", "4"), + policy: component("policy", "5"), + taskProtocol: component("task-protocol", "6"), + securityVerifier: component("security-verifier", "7"), + }, + inference: { provider: "nvidia", model: "nvidia/model", routeDigest: digest("8") }, + appliedPolicy: { revision: 2, digest: digest("9") }, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: ["browser", "computer", "terminal"], + targetOperations: [], + securityOperations: [], + taskOperations: [], + }; +} + +describe("CUA candidate readiness projection", () => { + it.each([ + ["feature disabled", {}], + ["qualification disabled", { NEMOCLAW_CUA_ENABLED: "1" }], + ])("stays opaque with %s", (_label, env) => { + const observeLiveInference = vi.fn(); + const observeLiveAppliedPolicy = vi.fn(); + const result = getObservedValidatedCuaState( + { name: "alpha", agent: "nemocua", cuaRuntimeReadiness: readiness() }, + env, + { observeLiveInference, observeLiveAppliedPolicy }, + ); + + expect(result).toEqual({ observation: "not-applicable", readiness: null }); + expect(observeLiveInference).not.toHaveBeenCalled(); + expect(observeLiveAppliedPolicy).not.toHaveBeenCalled(); + }); + + it("projects only validated candidate readiness when both exact gates are enabled", () => { + const value = readiness(); + const validateRuntimeReadiness = vi.fn(() => value); + const result = getObservedValidatedCuaState( + { name: "alpha", agent: "nemocua", cuaRuntimeReadiness: value }, + { NEMOCLAW_CUA_ENABLED: "1", NEMOCLAW_CUA_QUALIFICATION: "1" }, + { + observeLiveInference: () => ({ + provider: "nvidia", + model: "nvidia/model", + providerAuthorityDigest: digest("c"), + }), + observeLiveAppliedPolicy: () => ({ revision: 2, digest: digest("9") }), + validation: { validateRuntimeReadiness }, + }, + ); + + expect(result).toEqual({ observation: "verified", readiness: value }); + expect(validateRuntimeReadiness).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/cua/state.ts b/src/lib/cua/state.ts new file mode 100644 index 00000000000..26ee251e345 --- /dev/null +++ b/src/lib/cua/state.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxEntry } from "../state/registry/types"; +import type { CuaAppliedPolicyIdentity, CuaRuntimeReadiness } from "./contract"; +import { isCuaQualificationEnabled } from "./feature"; +import { observeCuaLiveAppliedPolicy, observeCuaLiveInference } from "./lifecycle-readiness"; +import { + type CuaRuntimeReadinessContext, + validateCurrentCuaRuntimeReadiness, +} from "./runtime-readiness"; + +export interface ValidatedCuaState { + readiness: CuaRuntimeReadiness | null; +} +export interface ObservedCuaInferenceRoute { + provider: string | null; + model: string | null; + providerAuthorityDigest?: string; + openshellDigest?: string; +} + +export interface CuaStateValidationDeps { + validateRuntimeReadiness?: typeof validateCurrentCuaRuntimeReadiness; + liveAppliedPolicy?: CuaAppliedPolicyIdentity | null; +} + +export type CuaStateObservation = "not-applicable" | "failed" | "verified"; + +export interface ObservedValidatedCuaState extends ValidatedCuaState { + observation: CuaStateObservation; + failure?: "inference" | "policy"; +} + +export interface CuaStateObservationDeps { + observeLiveInference?: (entry: SandboxEntry) => ObservedCuaInferenceRoute; + observeLiveAppliedPolicy?: (entry: SandboxEntry) => CuaAppliedPolicyIdentity; + validation?: CuaStateValidationDeps; +} + +/** Keep status and doctor behind the exact, default-off qualification boundary. */ +export function isCuaPublicStateEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return isCuaQualificationEnabled(env); +} + +export function buildCuaRuntimeReadinessValidationContext( + entry: SandboxEntry, + env: NodeJS.ProcessEnv, + liveInference: ObservedCuaInferenceRoute | null, + liveAppliedPolicy: CuaAppliedPolicyIdentity | null, +): CuaRuntimeReadinessContext { + return { + agentName: entry.agent, + recordedInference: entry, + ...(liveInference + ? { + liveInference: { + ...entry, + provider: liveInference.provider, + model: liveInference.model, + }, + liveProviderAuthorityDigest: liveInference.providerAuthorityDigest, + ...(liveInference.openshellDigest + ? { expectedOpenshellDigest: liveInference.openshellDigest } + : {}), + } + : {}), + ...(liveAppliedPolicy ? { liveAppliedPolicy } : {}), + acceptance: "candidate-qualification", + env, + }; +} + +/** Validate only candidate install readiness; no lifecycle authority is projected. */ +export function getValidatedCuaState( + entry: SandboxEntry | null | undefined, + env: NodeJS.ProcessEnv = process.env, + liveInference: ObservedCuaInferenceRoute | null = null, + liveAppliedPolicy: CuaAppliedPolicyIdentity | null = null, + deps: CuaStateValidationDeps = {}, +): ValidatedCuaState { + if ( + !entry || + !isCuaQualificationEnabled(env) || + entry.agent !== "nemocua" || + !entry.cuaRuntimeReadiness + ) { + return { readiness: null }; + } + try { + const readiness = (deps.validateRuntimeReadiness ?? validateCurrentCuaRuntimeReadiness)( + entry.cuaRuntimeReadiness, + buildCuaRuntimeReadinessValidationContext(entry, env, liveInference, liveAppliedPolicy), + ); + return { readiness: readiness.status === "candidate" ? readiness : null }; + } catch { + return { readiness: null }; + } +} + +/** Re-observe provider and policy authority before projecting candidate readiness. */ +export function getObservedValidatedCuaState( + entry: SandboxEntry | null | undefined, + env: NodeJS.ProcessEnv = process.env, + deps: CuaStateObservationDeps = {}, +): ObservedValidatedCuaState { + if ( + !entry || + !isCuaQualificationEnabled(env) || + entry.agent !== "nemocua" || + !entry.cuaRuntimeReadiness + ) { + return { observation: "not-applicable", readiness: null }; + } + + let liveInference: ObservedCuaInferenceRoute; + try { + liveInference = deps.observeLiveInference + ? deps.observeLiveInference(entry) + : observeCuaLiveInference(entry, { env }); + } catch { + return { observation: "failed", failure: "inference", readiness: null }; + } + + let liveAppliedPolicy: CuaAppliedPolicyIdentity; + try { + liveAppliedPolicy = deps.observeLiveAppliedPolicy + ? deps.observeLiveAppliedPolicy(entry) + : (deps.validation?.liveAppliedPolicy ?? observeCuaLiveAppliedPolicy(entry, { env })); + } catch { + return { observation: "failed", failure: "policy", readiness: null }; + } + + return { + observation: "verified", + ...getValidatedCuaState(entry, env, liveInference, liveAppliedPolicy, deps.validation), + }; +} diff --git a/src/lib/gateway-runtime-action.ts b/src/lib/gateway-runtime-action.ts index 453973d9997..3e7af1c1ba5 100644 --- a/src/lib/gateway-runtime-action.ts +++ b/src/lib/gateway-runtime-action.ts @@ -8,7 +8,13 @@ import { OPENSHELL_PROBE_TIMEOUT_MS, } from "./adapters/openshell/timeouts"; import { GATEWAY_PORT } from "./core/ports"; -import { resolveGatewayName, resolveGatewayPortFromName } from "./onboard/gateway-binding"; +import { + resolveGatewayName, + resolveGatewayPortFromName, + resolveSandboxGatewayName, +} from "./onboard/gateway-binding"; + +export { resolveGatewayName, resolveSandboxGatewayName }; type StartGatewayForRecoveryOptions = { gatewayName?: string; diff --git a/src/lib/inference/gateway-route-compatibility.ts b/src/lib/inference/gateway-route-compatibility.ts index 58c689a069c..86086533caa 100644 --- a/src/lib/inference/gateway-route-compatibility.ts +++ b/src/lib/inference/gateway-route-compatibility.ts @@ -5,6 +5,9 @@ import { canonicalEndpoint, type EndpointFlavor } from "../core/url-utils"; import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import type { SandboxEntry } from "../state/registry"; +/** Resolve the canonical gateway name used by live inference route checks. */ +export const resolveLiveInferenceGatewayName = resolveSandboxGatewayName; + export type GatewayInferenceRoute = Pick< SandboxEntry, "provider" | "model" | "endpointUrl" | "preferredInferenceApi" | "credentialEnv" diff --git a/src/lib/inference/live.ts b/src/lib/inference/live.ts index 5cb3e51b961..eee10edae1c 100644 --- a/src/lib/inference/live.ts +++ b/src/lib/inference/live.ts @@ -3,7 +3,13 @@ import type { CaptureOpenshellResult } from "../adapters/openshell/client"; import { stripAnsi } from "../adapters/openshell/client"; -import { parseGatewayInference, type GatewayInference } from "./config"; +import { captureOpenshell, captureResolvedOpenshell } from "../adapters/openshell/runtime"; +import { type GatewayInference, parseGatewayInference } from "./config"; + +export type { GatewayInference }; +// Keep live gateway-output consumers on this observation boundary instead of +// coupling each caller to the broad inference configuration module. +export { captureOpenshell, captureResolvedOpenshell, parseGatewayInference, stripAnsi }; type CaptureLiveInference = ( args: string[], diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 83370b936d9..2e8ebabbc5d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4424,6 +4424,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { recordStepComplete, recordStepFailed, skippedStepMessage, + cuaRegistry: registry, }), ensureAgentDashboardForward: (name, selectedAgent) => selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : 0, @@ -4515,7 +4516,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { log: (message) => console.log(message), }, }); - const finalFlowResult = await runFinalOnboardFlowSlice({ context: finalFlowContext, runtime: onboardRuntimeBoundary.getRuntime(), diff --git a/src/lib/state/registry-cua-deep-off.test.ts b/src/lib/state/registry-cua-deep-off.test.ts new file mode 100644 index 00000000000..d83fb56bd21 --- /dev/null +++ b/src/lib/state/registry-cua-deep-off.test.ts @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const parseCuaRuntimeReadiness = vi.hoisted(() => vi.fn()); + +vi.mock("../cua/schema", async (importOriginal) => ({ + ...(await importOriginal()), + parseCuaRuntimeReadiness, +})); + +const originalHome = process.env.HOME; +const originalCuaEnabled = process.env.NEMOCLAW_CUA_ENABLED; +const originalCuaQualification = process.env.NEMOCLAW_CUA_QUALIFICATION; +const temporaryHomes: string[] = []; + +async function loadRegistryWithOpaqueReadiness(options: { frameworkOnly?: boolean } = {}) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-registry-cua-deep-off-")); + temporaryHomes.push(home); + const configDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "alpha", + sandboxes: { + alpha: { + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/model", + cuaRuntimeReadiness: { untrusted: "opaque-candidate-record" }, + }, + beta: { + name: "beta", + agent: "openclaw", + model: "preserved", + gatewayName: "nemoclaw-8081", + gatewayPort: 8081, + }, + }, + }), + { mode: 0o600 }, + ); + process.env.HOME = home; + options.frameworkOnly + ? (process.env.NEMOCLAW_CUA_ENABLED = "1") + : delete process.env.NEMOCLAW_CUA_ENABLED; + delete process.env.NEMOCLAW_CUA_QUALIFICATION; + vi.resetModules(); + return { + home, + registry: await import("./registry"), + }; +} + +afterEach(() => { + process.env.HOME = originalHome; + originalCuaEnabled === undefined + ? delete process.env.NEMOCLAW_CUA_ENABLED + : (process.env.NEMOCLAW_CUA_ENABLED = originalCuaEnabled); + originalCuaQualification === undefined + ? delete process.env.NEMOCLAW_CUA_QUALIFICATION + : (process.env.NEMOCLAW_CUA_QUALIFICATION = originalCuaQualification); + parseCuaRuntimeReadiness.mockReset(); + vi.resetModules(); + for (const home of temporaryHomes.splice(0)) { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +describe("CUA registry deep-off boundary (#7755)", () => { + it("does not parse or expose CUA readiness and preserves it across unrelated writes", async () => { + const { home, registry } = await loadRegistryWithOpaqueReadiness(); + const persistence = await import("./registry/persistence"); + + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect( + registry.recordCuaRuntimeReadiness("alpha", {} as never, registry.getSandbox("alpha")!), + ).toBe(false); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + const publicSerialization = JSON.stringify(persistence.load()); + expect(publicSerialization).not.toContain("cuaRuntimeReadiness"); + expect(publicSerialization).not.toContain("opaque-candidate-record"); + + expect(registry.updateSandbox("alpha", { dashboardPort: 18080 })).toBe(true); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + const persisted = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), + ) as { + sandboxes: Record>; + }; + expect(persisted.sandboxes.alpha?.cuaRuntimeReadiness).toEqual({ + untrusted: "opaque-candidate-record", + }); + expect(persisted.sandboxes.beta).toMatchObject({ model: "preserved" }); + }); + + it("keeps readiness opaque when only the framework gate is enabled (#7755)", async () => { + const { home, registry } = await loadRegistryWithOpaqueReadiness({ frameworkOnly: true }); + const persistence = await import("./registry/persistence"); + + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect( + registry.recordCuaRuntimeReadiness("alpha", {} as never, registry.getSandbox("alpha")!), + ).toBe(false); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + expect(JSON.stringify(persistence.load())).not.toContain("opaque-candidate-record"); + + expect(registry.updateSandbox("alpha", { dashboardPort: 18080 })).toBe(true); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + const persisted = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), + ) as { sandboxes: Record> }; + expect(persisted.sandboxes.alpha?.cuaRuntimeReadiness).toEqual({ + untrusted: "opaque-candidate-record", + }); + }); + + it("revokes opaque readiness when the recorded inference route changes", async () => { + const { home, registry } = await loadRegistryWithOpaqueReadiness(); + + expect(registry.updateSandbox("alpha", { model: "nvidia/other-model" })).toBe(true); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + const persisted = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), + ) as { + sandboxes: Record>; + }; + expect(persisted.sandboxes.alpha?.cuaRuntimeReadiness).toBeUndefined(); + expect(persisted.sandboxes.alpha?.model).toBe("nvidia/other-model"); + }); + + it("revokes opaque readiness without parsing it when policy authority changes", async () => { + const { home, registry } = await loadRegistryWithOpaqueReadiness(); + + expect(registry.updateSandbox("alpha", { policies: ["managed-inference"] })).toBe(true); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + const persisted = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), + ) as { + sandboxes: Record>; + }; + expect(persisted.sandboxes.alpha?.cuaRuntimeReadiness).toBeUndefined(); + expect(persisted.sandboxes.alpha?.policies).toEqual(["managed-inference"]); + }); + + it("revokes opaque readiness before an agent can move away and back", async () => { + const { home, registry } = await loadRegistryWithOpaqueReadiness(); + + expect(registry.updateSandbox("alpha", { agent: "openclaw" })).toBe(true); + expect(registry.updateSandbox("alpha", { agent: "nemocua" })).toBe(true); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + const persisted = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), + ) as { + sandboxes: Record>; + }; + expect(persisted.sandboxes.alpha?.agent).toBe("nemocua"); + expect(persisted.sandboxes.alpha?.cuaRuntimeReadiness).toBeUndefined(); + }); + + it("revokes opaque readiness in the direct rebuild route transaction", async () => { + const { home } = await loadRegistryWithOpaqueReadiness(); + const { commitRebuildRoutePreflight } = await import( + "../actions/sandbox/rebuild-preflight-guards" + ); + + expect( + commitRebuildRoutePreflight({ + sandboxName: "alpha", + gatewayName: "nemoclaw", + targetUpdate: { + provider: "nvidia", + model: "nvidia/model", + endpointUrl: null, + preferredInferenceApi: null, + credentialEnv: null, + }, + }), + ).toMatchObject({ ok: true }); + expect(parseCuaRuntimeReadiness).not.toHaveBeenCalled(); + + const persisted = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "sandboxes.json"), "utf8"), + ) as { + sandboxes: Record>; + }; + expect(persisted.sandboxes.alpha?.cuaRuntimeReadiness).toBeUndefined(); + }); +}); diff --git a/src/lib/state/registry-cua-readiness.test.ts b/src/lib/state/registry-cua-readiness.test.ts new file mode 100644 index 00000000000..eba9bf0df08 --- /dev/null +++ b/src/lib/state/registry-cua-readiness.test.ts @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CuaRuntimeReadiness } from "../cua/contract"; + +const originalHome = process.env.HOME; +const originalCuaEnabled = process.env.NEMOCLAW_CUA_ENABLED; +const originalCuaQualification = process.env.NEMOCLAW_CUA_QUALIFICATION; +const temporaryHomes: string[] = []; +const digest = (character: string): string => `sha256:${character.repeat(64)}`; + +function readiness(): CuaRuntimeReadiness { + const component = (name: string, character: string) => ({ + name, + version: "1.0.0", + digest: digest(character), + owner: "NVIDIA", + }); + return { + schemaVersion: "1.0.0", + kind: "runtime-readiness", + agent: "nemocua", + mode: "standalone", + status: "candidate", + sourceRevision: "a".repeat(40), + sourceClean: true, + runtimeManifestDigest: digest("b"), + providerAuthorityDigest: digest("c"), + qualification: { + state: "candidate", + environmentDigest: digest("d"), + bundleReceiptDigest: digest("e"), + }, + components: { + openshell: component("openshell", "1"), + runtime: component("nemocua-runtime", "2"), + sandboxImage: component("nemocua-sandbox", "3"), + targetAdapter: component("target-adapter", "4"), + policy: component("nemocua-policy", "5"), + taskProtocol: component("task-protocol", "6"), + securityVerifier: component("security-verifier", "7"), + }, + inference: { + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + routeDigest: digest("8"), + }, + appliedPolicy: { revision: 2, digest: digest("9") }, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: ["browser", "computer", "terminal"], + targetOperations: [], + securityOperations: [], + taskOperations: [], + }; +} + +async function loadRegistry(document: unknown = { defaultSandbox: null, sandboxes: {} }) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-registry-cua-readiness-")); + temporaryHomes.push(home); + const configDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "sandboxes.json"), JSON.stringify(document), { + mode: 0o600, + }); + process.env.HOME = home; + process.env.NEMOCLAW_CUA_ENABLED = "1"; + process.env.NEMOCLAW_CUA_QUALIFICATION = "1"; + vi.resetModules(); + return import("./registry"); +} + +afterEach(() => { + process.env.HOME = originalHome; + originalCuaEnabled === undefined + ? delete process.env.NEMOCLAW_CUA_ENABLED + : (process.env.NEMOCLAW_CUA_ENABLED = originalCuaEnabled); + originalCuaQualification === undefined + ? delete process.env.NEMOCLAW_CUA_QUALIFICATION + : (process.env.NEMOCLAW_CUA_QUALIFICATION = originalCuaQualification); + vi.resetModules(); + for (const home of temporaryHomes.splice(0)) { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +describe("CUA candidate readiness persistence (#7755)", () => { + it("accepts readiness only through the whole-record onboarding write", async () => { + const registry = await loadRegistry(); + registry.registerSandbox({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + policies: ["managed-inference"], + cuaRuntimeReadiness: readiness(), + }); + registry.registerSandbox({ name: "beta", agent: "openclaw", model: "unchanged" }); + + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect(registry.updateSandbox("alpha", { cuaRuntimeReadiness: readiness() })).toBe(false); + expect( + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!), + ).toBe(true); + + expect(registry.getSandbox("alpha")).toMatchObject({ + name: "alpha", + agent: "nemocua", + policies: ["managed-inference"], + cuaRuntimeReadiness: readiness(), + }); + expect(registry.getSandbox("beta")).toMatchObject({ + name: "beta", + agent: "openclaw", + model: "unchanged", + }); + }); + + it.each([ + ["provider", "other-provider"], + ["model", "nvidia/other-model"], + ["endpointUrl", "https://inference.example.test/v1"], + ] as const)("invalidates readiness when inference %s changes", async (field, value) => { + const registry = await loadRegistry(); + registry.registerSandbox({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + }); + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + + expect(registry.updateSandbox("alpha", { [field]: value })).toBe(true); + + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect(registry.getSandbox("alpha")?.[field]).toBe(value); + }); + + it("preserves readiness for unrelated and normalized-equivalent updates", async () => { + const registry = await loadRegistry(); + registry.registerSandbox({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + }); + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + + expect( + registry.updateSandbox("alpha", { + provider: " nvidia ", + dashboardPort: 18080, + cuaRuntimeReadiness: undefined, + }), + ).toBe(true); + + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toEqual(readiness()); + expect(registry.getSandbox("alpha")?.dashboardPort).toBe(18080); + }); + + it("invalidates readiness on every durable policy-authority mutation (#7755)", async () => { + const registry = await loadRegistry(); + registry.registerSandbox({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + policies: ["managed-inference"], + }); + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + + expect(registry.updateSandbox("alpha", { policies: ["managed-inference"] })).toBe(true); + + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect(registry.getSandbox("alpha")?.policies).toEqual(["managed-inference"]); + + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + expect( + registry.addCustomPolicy("alpha", { + name: "unsafe-extra", + content: "network_policies:\n unsafe-extra: {}\n", + sourcePath: "/tmp/unsafe-extra.yaml", + }), + ).toBe(true); + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + expect(registry.removeCustomPolicyByName("alpha", "unsafe-extra")).toBe(true); + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + }); + + it.each([ + ["agent", "openclaw"], + ["imageTag", "replacement-image"], + ["fromDockerfile", "/tmp/replacement/Dockerfile"], + ["gatewayName", "replacement-gateway"], + ["gatewayPort", 19999], + ["openshellDriver", "replacement-driver"], + ["openshellVersion", "9.9.9"], + ["lifecycleGeneration", "generation-2"], + ["lifecycleLiveIdentityFingerprint", "replacement-fingerprint"], + ] as const)("invalidates readiness when runtime authority %s changes", async (field, value) => { + const registry = await loadRegistry(); + registry.registerSandbox({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + }); + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + + expect(registry.updateSandbox("alpha", { [field]: value })).toBe(true); + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect(registry.getSandbox("alpha")?.[field]).toBe(value); + }); + + it("does not establish readiness for an ordinary or pending row", async () => { + const registry = await loadRegistry(); + registry.registerSandbox({ name: "ordinary", agent: "openclaw" }); + expect( + registry.recordCuaRuntimeReadiness("ordinary", readiness(), registry.getSandbox("ordinary")!), + ).toBe(false); + + registry.registerSandbox({ name: "pending", agent: "nemocua" }); + registry.updateSandbox("pending", { pendingRouteReservation: true }); + expect( + registry.recordCuaRuntimeReadiness("pending", readiness(), registry.getSandbox("pending")!), + ).toBe(false); + }); + + it("rejects a stale same-row readiness writer while preserving another sandbox", async () => { + const registry = await loadRegistry(); + registry.registerSandbox({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + dashboardPort: 18080, + }); + registry.registerSandbox({ name: "beta", agent: "openclaw", dashboardPort: 28080 }); + const staleAlpha = registry.getSandbox("alpha")!; + + expect(registry.updateSandbox("beta", { dashboardPort: 28081 })).toBe(true); + expect(registry.updateSandbox("alpha", { dashboardPort: 18081 })).toBe(true); + expect(registry.recordCuaRuntimeReadiness("alpha", readiness(), staleAlpha)).toBe(false); + + expect(registry.getSandbox("alpha")?.dashboardPort).toBe(18081); + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect(registry.getSandbox("beta")?.dashboardPort).toBe(28081); + }); + + it("invalidates readiness whenever a new route reservation starts", async () => { + const registry = await loadRegistry(); + registry.registerSandbox({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + }); + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + + expect( + registry.reserveSandboxInferenceRoute("alpha", { + provider: "nvidia", + model: "nvidia/nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: "openai-completions", + gatewayName: "nemoclaw-alpha", + }), + ).toBe(true); + + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + }); + + it.each([ + ["legacy", { ...readiness(), sourceClean: undefined }], + ["malformed", { ...readiness(), repository: "https://private.invalid/source" }], + ])("drops only %s readiness while preserving unrelated rows", async (_label, invalid) => { + const registry = await loadRegistry({ + defaultSandbox: "alpha", + sandboxes: { + alpha: { + name: "alpha", + agent: "nemocua", + provider: "nvidia", + cuaRuntimeReadiness: invalid, + }, + beta: { name: "beta", agent: "openclaw", model: "preserved" }, + }, + }); + + expect(registry.getSandbox("alpha")).toMatchObject({ + name: "alpha", + agent: "nemocua", + provider: "nvidia", + }); + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + expect(registry.getSandbox("beta")).toEqual({ + name: "beta", + agent: "openclaw", + model: "preserved", + }); + }); + + it("does not restore readiness through generic recovery paths", async () => { + const registry = await loadRegistry(); + registry.restoreSandboxEntry({ name: "alpha", cuaRuntimeReadiness: readiness() }); + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + + registry.registerSandbox({ name: "beta", agent: "nemocua" }); + registry.recordCuaRuntimeReadiness("beta", readiness(), registry.getSandbox("beta")!); + const receipt = registry.removeSandboxWithReceipt("beta"); + expect(receipt).not.toBeNull(); + expect(registry.restoreSandboxEntryIfMissing(receipt!)).toBe(true); + expect(registry.getSandbox("beta")?.cuaRuntimeReadiness).toBeUndefined(); + }); + + it("clears readiness without erasing the sandbox row", async () => { + const registry = await loadRegistry(); + registry.registerSandbox({ name: "alpha", agent: "nemocua", dashboardPort: 18080 }); + registry.recordCuaRuntimeReadiness("alpha", readiness(), registry.getSandbox("alpha")!); + + expect(registry.clearCuaRuntimeReadiness("alpha")).toBe(true); + + expect(registry.getSandbox("alpha")).toMatchObject({ + name: "alpha", + agent: "nemocua", + dashboardPort: 18080, + }); + expect(registry.getSandbox("alpha")?.cuaRuntimeReadiness).toBeUndefined(); + }); +}); diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 0b34e46eb97..6f7849b3c29 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { isDeepStrictEqual } from "node:util"; +import { isCuaQualificationEnabled } from "../cua/feature"; +import { parseCuaRuntimeReadiness } from "../cua/schema"; import type { InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields, @@ -16,7 +18,12 @@ import { readExtraProviders, } from "./extra-providers"; import { withLock } from "./registry/lock"; -import { load, save } from "./registry/persistence"; +import { + discardOpaqueCuaRuntimeReadiness, + hasOpaqueCuaRuntimeReadiness, + load, + save, +} from "./registry/persistence"; import { cloneSandboxWorkloadReceipt } from "./registry/workload"; import { normalizeSandboxMcpState } from "./registry-mcp"; import { @@ -76,6 +83,8 @@ export type { SandboxWorkloadReceipt, } from "./registry/types"; export type { McpBridgeEntry, SandboxMcpState } from "./registry-mcp"; +export { normalizeCustomPolicyEntries }; + export { getConfiguredMessagingChannelsFromEntry, getDisabledMessagingChannelsFromEntry, @@ -83,13 +92,11 @@ export { getMessagingPlanFromEntry, type SandboxMessagingState, } from "./registry-messaging"; -export { normalizeCustomPolicyEntries }; export type SandboxRemovalReceipt = reversibleRemoval.RegistryRemovalReceipt; export function getSandbox(name: string): SandboxEntry | null { - const data = load(); - return data.sandboxes[name] || null; + return load().sandboxes[name] || null; } export function getDefault(): string | null { @@ -189,6 +196,9 @@ export function registerSandbox(entry: SandboxEntry): void { gatewayName: entry.gatewayName ?? undefined, gatewayPort: entry.gatewayPort ?? undefined, }; + // Registration establishes a new sandbox lifecycle and may not inherit a + // deep-off readiness record carried from a previous same-named row. + discardOpaqueCuaRuntimeReadiness(data, entry.name); save(reversibleRemoval.claimInitialDefaultInRegistry(data, entry.name)); }); } @@ -219,7 +229,7 @@ export function reserveSandboxInferenceRoute( const data = load(); const existing = data.sandboxes[name]; const normalized = normalizeInferenceSelection(route); - data.sandboxes[name] = { + const next: SandboxEntry = { ...(existing ?? { name, pendingRouteReservation: true as const }), pendingRouteReservation: true, reservationSessionId: route.reservationSessionId ?? existing?.reservationSessionId, @@ -232,6 +242,11 @@ export function reserveSandboxInferenceRoute( gatewayName: route.gatewayName, gatewayPort: undefined, }; + if (existing?.cuaRuntimeReadiness || hasOpaqueCuaRuntimeReadiness(data, name)) { + delete next.cuaRuntimeReadiness; + discardOpaqueCuaRuntimeReadiness(data, name); + } + data.sandboxes[name] = next; save(data); return true; }); @@ -265,11 +280,148 @@ export function isPendingReservationForSession( export function updateSandbox(name: string, updates: Partial): boolean { return withLock(() => { const data = load(); - if (!data.sandboxes[name]) return false; + const current = data.sandboxes[name]; + if (!current) return false; if (Object.prototype.hasOwnProperty.call(updates, "name") && updates.name !== name) { return false; } - Object.assign(data.sandboxes[name], updates); + // Readiness is a whole-record authority write owned by canonical CUA + // onboarding. Ignore an optional undefined property carried by a broad + // metadata shape, but reject every generic attempt to establish or replace + // a readiness record. + if ( + Object.prototype.hasOwnProperty.call(updates, "cuaRuntimeReadiness") && + updates.cuaRuntimeReadiness !== undefined + ) { + return false; + } + const { cuaRuntimeReadiness: _ignoredReadiness, ...ordinaryUpdates } = updates; + const next = { ...current, ...ordinaryUpdates }; + if ( + cuaInferenceSelectionChanged(current, next, hasOpaqueCuaRuntimeReadiness(data, name)) || + cuaPolicyAuthorityMutationRequested(ordinaryUpdates) || + cuaRuntimeAuthorityChanged(current, next, ordinaryUpdates) + ) { + delete next.cuaRuntimeReadiness; + discardOpaqueCuaRuntimeReadiness(data, name); + } + data.sandboxes[name] = next; + save(data); + return true; + }); +} + +/** Inference-route writes share the readiness invalidation boundary. */ +export function updateSandboxInferenceRoute(name: string, updates: Partial): boolean { + return updateSandbox(name, updates); +} + +const CUA_POLICY_AUTHORITY_FIELDS = new Set([ + "baselineExclusions", + "baselineExclusionTransition", + "customPolicies", + "policies", + "policyPresetsFinalized", + "policyTier", +]); + +const CUA_RUNTIME_AUTHORITY_FIELDS = new Set([ + "agent", + "agentVersion", + "fromDockerfile", + "gatewayName", + "gatewayPort", + "gpuEnabled", + "hostGpuDetected", + "imageTag", + "lifecycleGeneration", + "lifecycleLiveIdentityFingerprint", + "nemoclawVersion", + "openshellDriver", + "openshellVersion", + "pendingRouteReservation", + "reservationSessionId", + "sandboxGpuDevice", + "sandboxGpuEnabled", + "sandboxGpuMode", + "sandboxGpuProof", + "workload", +]); + +function cuaPolicyAuthorityMutationRequested(updates: Partial): boolean { + return [...CUA_POLICY_AUTHORITY_FIELDS].some((field) => + Object.prototype.hasOwnProperty.call(updates, field), + ); +} + +function cuaRuntimeAuthorityChanged( + current: SandboxEntry, + next: SandboxEntry, + updates: Partial, +): boolean { + return [...CUA_RUNTIME_AUTHORITY_FIELDS].some( + (field) => + Object.prototype.hasOwnProperty.call(updates, field) && + !isDeepStrictEqual(current[field], next[field]), + ); +} + +/** Revoke normal and feature-off opaque CUA authority inside an existing transaction. */ +export function invalidateCuaRuntimeReadinessInRegistry( + data: ReturnType, + name: string, +): void { + const sandbox = data.sandboxes[name]; + if (sandbox) delete sandbox.cuaRuntimeReadiness; + discardOpaqueCuaRuntimeReadiness(data, name); +} + +function cuaInferenceSelectionChanged( + current: SandboxEntry | null | undefined, + next: SandboxEntry, + hasOpaqueReadiness = false, +): boolean { + if (!current?.cuaRuntimeReadiness && !hasOpaqueReadiness) return false; + const before = normalizeInferenceSelection(current); + const after = normalizeInferenceSelection(next); + return !isDeepStrictEqual(before, after); +} + +/** Persist one complete, schema-valid readiness record without replacing unrelated row state. */ +export function recordCuaRuntimeReadiness( + name: string, + readiness: NonNullable, + expectedEntry: SandboxEntry, +): boolean { + if (!isCuaQualificationEnabled()) return false; + const parsed = parseCuaRuntimeReadiness(readiness); + return withLock(() => { + const data = load(); + const current = data.sandboxes[name]; + if ( + !current || + current.agent !== "nemocua" || + current.pendingRouteReservation === true || + !isDeepStrictEqual(current, expectedEntry) + ) { + return false; + } + discardOpaqueCuaRuntimeReadiness(data, name); + data.sandboxes[name] = { ...current, cuaRuntimeReadiness: parsed }; + save(data); + return true; + }); +} + +/** Remove readiness while preserving the rest of the sandbox row. */ +export function clearCuaRuntimeReadiness(name: string): boolean { + return withLock(() => { + const data = load(); + const current = data.sandboxes[name]; + if (!current) return false; + discardOpaqueCuaRuntimeReadiness(data, name); + const { cuaRuntimeReadiness: _cuaRuntimeReadiness, ...next } = current; + data.sandboxes[name] = next; save(data); return true; }); @@ -301,14 +453,29 @@ export function restoreSandboxEntry( } = {}, ): void { withLock(() => { - save(reversibleRemoval.restoreSandboxEntryInRegistry(load(), entry, options.defaultTransition)); + const data = load(); + discardOpaqueCuaRuntimeReadiness(data, entry.name); + const { cuaRuntimeReadiness: _cuaRuntimeReadiness, ...restoredEntry } = entry; + save( + reversibleRemoval.restoreSandboxEntryInRegistry( + data, + restoredEntry, + options.defaultTransition, + ), + ); }); } /** Restore a removed entry unless a recreate already registered its replacement. */ export function restoreSandboxEntryIfMissing(receipt: SandboxRemovalReceipt): boolean { return withLock(() => { - const result = reversibleRemoval.restoreSandboxIfMissingInRegistry(load(), receipt); + const data = load(); + discardOpaqueCuaRuntimeReadiness(data, receipt.entry.name); + const { cuaRuntimeReadiness: _cuaRuntimeReadiness, ...entry } = receipt.entry; + const result = reversibleRemoval.restoreSandboxIfMissingInRegistry(data, { + ...receipt, + entry, + }); if (!result.restored) return false; save(result.registry); return result.restored; @@ -376,6 +543,7 @@ export function addCustomPolicy(name: string, entry: CustomPolicyEntry): boolean const list = (sandbox.customPolicies ?? []).filter((p) => p.name !== entry.name); list.push({ ...entry, appliedAt: entry.appliedAt ?? new Date().toISOString() }); sandbox.customPolicies = list; + invalidateCuaRuntimeReadinessInRegistry(data, name); save(data); return true; }); @@ -391,6 +559,7 @@ export function removeCustomPolicyByName(name: string, presetName: string): bool const next = list.filter((p) => p.name !== presetName); if (next.length === list.length) return false; sandbox.customPolicies = next.length > 0 ? next : undefined; + invalidateCuaRuntimeReadinessInRegistry(data, name); save(data); return true; }); @@ -411,6 +580,7 @@ export function addBaselineExclusion(name: string, entry: BaselineExclusionEntry const list = (sandbox.baselineExclusions ?? []).filter((e) => e.key !== entry.key); list.push({ ...entry, acknowledgedAt: entry.acknowledgedAt ?? new Date().toISOString() }); sandbox.baselineExclusions = list; + invalidateCuaRuntimeReadinessInRegistry(data, name); save(data); return true; }); @@ -426,6 +596,7 @@ export function removeBaselineExclusion(name: string, key: string): boolean { const next = list.filter((e) => e.key !== key); if (next.length === list.length) return false; sandbox.baselineExclusions = next.length > 0 ? next : undefined; + invalidateCuaRuntimeReadinessInRegistry(data, name); save(data); return true; }); @@ -450,6 +621,7 @@ export function beginBaselineExclusionTransition( const sandbox = data.sandboxes[name]; if (!sandbox || sandbox.baselineExclusionTransition) return false; sandbox.baselineExclusionTransition = normalizeBaselineExclusionTransition(transition); + invalidateCuaRuntimeReadinessInRegistry(data, name); save(data); return true; }); @@ -484,6 +656,7 @@ export function commitBaselineExclusionTransition(name: string, id: string): boo sandbox.baselineExclusions = next.length > 0 ? next : undefined; } sandbox.baselineExclusionTransition = undefined; + invalidateCuaRuntimeReadinessInRegistry(data, name); save(data); return true; }); @@ -496,6 +669,7 @@ export function clearBaselineExclusionTransition(name: string, id: string): bool const sandbox = data.sandboxes[name]; if (!sandbox || sandbox.baselineExclusionTransition?.id !== id) return false; sandbox.baselineExclusionTransition = undefined; + invalidateCuaRuntimeReadinessInRegistry(data, name); save(data); return true; }); diff --git a/src/lib/state/registry/persistence.ts b/src/lib/state/registry/persistence.ts index c09dd736c96..2766977cb46 100644 --- a/src/lib/state/registry/persistence.ts +++ b/src/lib/state/registry/persistence.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { isObjectRecord } from "../../core/json-types"; import { GATEWAY_PORT } from "../../core/ports"; +import { isCuaQualificationEnabled } from "../../cua/feature"; +import { parseCuaRuntimeReadiness } from "../../cua/schema"; import { parseServingProfileProvenance } from "../../inference/serving/profile-provenance"; import { readConfigFile, writeConfigFile } from "../config-io"; import { normalizeExtraProviders } from "../extra-providers"; @@ -24,6 +26,26 @@ import { nemoclawStateRoot } from "../state-root"; import type { SandboxEntry, SandboxRegistry } from "./types"; import { cloneSandboxWorkloadReceipt } from "./workload"; +const OPAQUE_CUA_RUNTIME_READINESS = Symbol("opaqueCuaRuntimeReadiness"); + +type RegistryWithOpaqueCuaState = SandboxRegistry & { + [OPAQUE_CUA_RUNTIME_READINESS]?: Map; +}; + +function opaqueCuaRuntimeReadiness(data: SandboxRegistry): Map | undefined { + return (data as RegistryWithOpaqueCuaState)[OPAQUE_CUA_RUNTIME_READINESS]; +} + +/** True when deep-off persistence is carrying an unread CUA record for this row. */ +export function hasOpaqueCuaRuntimeReadiness(data: SandboxRegistry, name: string): boolean { + return opaqueCuaRuntimeReadiness(data)?.has(name) === true; +} + +/** Revoke a deep-off opaque CUA record before an authority-changing write. */ +export function discardOpaqueCuaRuntimeReadiness(data: SandboxRegistry, name: string): void { + opaqueCuaRuntimeReadiness(data)?.delete(name); +} + function cloneSandboxWorkloadReceiptOrThrow( value: SandboxEntry["workload"], operation: "load" | "save", @@ -46,6 +68,19 @@ function cloneServingProfileProvenanceOrThrow( return provenance ?? undefined; } +function normalizeCuaRuntimeReadiness( + value: SandboxEntry["cuaRuntimeReadiness"], +): SandboxEntry["cuaRuntimeReadiness"] { + if (value === undefined) return undefined; + try { + return parseCuaRuntimeReadiness(value); + } catch { + // A legacy or malformed optional CUA record must fail closed without + // making unrelated sandbox rows or commands unloadable. + return undefined; + } +} + export const REGISTRY_FILE = path.join( nemoclawStateRoot(process.env.HOME || "/tmp", GATEWAY_PORT), "sandboxes.json", @@ -63,11 +98,20 @@ export function save(data: SandboxRegistry): void { function normalizeRegistry(value: unknown): SandboxRegistry { const data = isObjectRecord(value) ? value : {}; const extraProviders = normalizeExtraProviders(data.extraProviders); + const cuaQualificationEnabled = isCuaQualificationEnabled(); + const opaqueReadiness = new Map(); const sandboxes = Object.fromEntries( - parseSandboxRegistryEntries(data.sandboxes).map(([name, entry]) => [ - name, - normalizeSandboxEntryForRuntime(entry), - ]), + parseSandboxRegistryEntries(data.sandboxes).map(([name, entry]) => { + if ( + !cuaQualificationEnabled && + Object.prototype.hasOwnProperty.call(entry, "cuaRuntimeReadiness") + ) { + // Preserve the raw JSON value only as private persistence metadata. It + // is neither parsed nor returned to runtime callers while CUA is off. + opaqueReadiness.set(name, entry.cuaRuntimeReadiness); + } + return [name, normalizeSandboxEntryForRuntime(entry, cuaQualificationEnabled)]; + }), ); const base: SandboxRegistry = { // Preserve a stale string pointer at read time so diagnostics can explain @@ -79,16 +123,28 @@ function normalizeRegistry(value: unknown): SandboxRegistry { sandboxes, }; if (extraProviders) base.extraProviders = extraProviders; + if (opaqueReadiness.size > 0) { + // Enumerable symbols survive the registry's immutable object spreads, but + // JSON serialization and public entry iteration cannot expose this map. + (base as RegistryWithOpaqueCuaState)[OPAQUE_CUA_RUNTIME_READINESS] = opaqueReadiness; + } return base; } function serializeRegistryForDisk(data: SandboxRegistry): SandboxRegistry { const extraProviders = normalizeExtraProviders(data.extraProviders); + const cuaQualificationEnabled = isCuaQualificationEnabled(); + const opaqueReadiness = opaqueCuaRuntimeReadiness(data); const sandboxes = Object.fromEntries( - Object.entries(data.sandboxes).map(([name, entry]) => [ - name, - serializeSandboxEntryForDisk(entry), - ]), + Object.entries(data.sandboxes).map(([name, entry]) => { + const serialized = serializeSandboxEntryForDisk(entry, cuaQualificationEnabled); + if (!cuaQualificationEnabled && opaqueReadiness?.has(name)) { + serialized.cuaRuntimeReadiness = opaqueReadiness.get(name) as + | SandboxEntry["cuaRuntimeReadiness"] + | undefined; + } + return [name, serialized]; + }), ); const defaultSandbox = retainedDefaultSandbox(data.defaultSandbox, sandboxes); const currentDefaultSelectionRevision = reversibleRemoval.normalizeDefaultSelectionRevision( @@ -106,7 +162,10 @@ function serializeRegistryForDisk(data: SandboxRegistry): SandboxRegistry { return base; } -function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { +function normalizeSandboxEntryForRuntime( + entry: SandboxEntry, + cuaQualificationEnabled: boolean, +): SandboxEntry { const messaging = cloneSandboxMessagingState(entry.messaging); const workload = cloneSandboxWorkloadReceiptOrThrow(entry.workload, "load"); const servingProfileProvenance = cloneServingProfileProvenanceOrThrow( @@ -119,6 +178,9 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { entry.baselineExclusionTransition, ); const customPolicies = normalizeCustomPolicyEntries(entry.customPolicies); + const cuaRuntimeReadiness = cuaQualificationEnabled + ? normalizeCuaRuntimeReadiness(entry.cuaRuntimeReadiness) + : undefined; const { messaging: _messaging, workload: _workload, @@ -127,6 +189,7 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { baselineExclusions: _baselineExclusions, baselineExclusionTransition: _baselineExclusionTransition, customPolicies: _customPolicies, + cuaRuntimeReadiness: _cuaRuntimeReadiness, ...rest } = entry; return { @@ -138,6 +201,7 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { ...(baselineExclusions ? { baselineExclusions } : {}), ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), ...(customPolicies ? { customPolicies } : {}), + ...(cuaRuntimeReadiness ? { cuaRuntimeReadiness } : {}), }; } @@ -147,7 +211,10 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { * markers plus legacy provider credential hashes that must never reach * sandboxes.json. */ -function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { +function serializeSandboxEntryForDisk( + entry: SandboxEntry, + cuaQualificationEnabled: boolean, +): SandboxEntry { // Defensively drop non-durable recovery markers and legacy // providerCredentialHashes so they can never reach sandboxes.json even if a // caller force-passed them through updateSandbox(). @@ -173,6 +240,9 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { durable.baselineExclusionTransition, ); const customPolicies = normalizeCustomPolicyEntries(durable.customPolicies); + const cuaRuntimeReadiness = cuaQualificationEnabled + ? normalizeCuaRuntimeReadiness(durable.cuaRuntimeReadiness) + : undefined; const { messaging: _messaging, workload: _workload, @@ -181,6 +251,7 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { baselineExclusions: _baselineExclusions, baselineExclusionTransition: _baselineExclusionTransition, customPolicies: _customPolicies, + cuaRuntimeReadiness: _cuaRuntimeReadiness, ...rest } = durable; return { @@ -193,5 +264,6 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { ...(baselineExclusions ? { baselineExclusions } : {}), ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), ...(customPolicies ? { customPolicies } : {}), + ...(cuaRuntimeReadiness ? { cuaRuntimeReadiness } : {}), }; } diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index c85c1daf997..db609839db0 100644 --- a/src/lib/state/registry/types.ts +++ b/src/lib/state/registry/types.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { CuaRuntimeReadiness } from "../../cua/contract"; import type { InferenceSelection } from "../../inference/selection"; import type { ServingProfileProvenance } from "../../inference/serving/types"; import type { WebSearchProvider } from "../../inference/web-search"; @@ -114,6 +115,8 @@ export interface SandboxEntry extends Partial { webSearchProvider?: WebSearchProvider | null; agent?: string | null; agentVersion?: string | null; + /** Candidate runtime authority recorded only by canonical CUA onboarding. */ + cuaRuntimeReadiness?: CuaRuntimeReadiness; /** Plugin install baseline captured before state is restored into a fresh OpenClaw image. */ openclawImagePluginInstalls?: OpenClawImagePluginInstall[]; // NemoClaw build fingerprint (the NemoClaw CLI/build version) stamped only on diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index f8b1a58be33..b91f5a57a0c 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -86,6 +86,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini export function withMockedDocker( run: (deps: { ensureAgentBaseImage: AgentOnboardModule["ensureAgentBaseImage"]; + createAgentSandbox: AgentOnboardModule["createAgentSandbox"]; bindLocalAgentBaseImageToPinnedProvenance: AgentOnboardModule["bindLocalAgentBaseImageToPinnedProvenance"]; pinTrustedAgentBaseImageOverrideForOperation: AgentOnboardModule["pinTrustedAgentBaseImageOverrideForOperation"]; pinAgentSandboxBaseImageRef: AgentOnboardModule["pinAgentSandboxBaseImageRef"]; @@ -162,6 +163,7 @@ export function withMockedDocker( const agentOnboardModule = requireSource("./onboard.js") as AgentOnboardModule; return run({ ensureAgentBaseImage: agentOnboardModule.ensureAgentBaseImage, + createAgentSandbox: agentOnboardModule.createAgentSandbox, bindLocalAgentBaseImageToPinnedProvenance: agentOnboardModule.bindLocalAgentBaseImageToPinnedProvenance, pinTrustedAgentBaseImageOverrideForOperation: