diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index b1cb940c153..b0ab21f45b4 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -12,7 +12,7 @@ "src/lib/adapters/openshell/timeouts.ts": 36, "src/lib/agent/defs.ts": 32, "src/lib/cli/branding.ts": 85, - "src/lib/cli/nemoclaw-oclif-command.ts": 103, + "src/lib/cli/nemoclaw-oclif-command.ts": 109, "src/lib/cli/terminal-style.ts": 45, "src/lib/core/json-types.ts": 37, "src/lib/core/ports.ts": 86, diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 28d9c8f78fa..cf0f42197a3 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1521,6 +1521,141 @@ $$nemoclaw my-assistant doctor [--json] +## CUA target lifecycle + +These commands attach one CUA sandbox to one dedicated disposable desktop target. +The target must expose `browser`, `computer`, and `terminal` services. +The operator-owned adapter probes those services and returns their health in a lifecycle record. +NemoClaw validates that record, compares the immutable identities, and requires all three services to report healthy before it records the attachment. + +Every target command also requires canonical CUA runtime-readiness state for the sandbox. +Until canonical onboarding records an available runtime, the commands return `lifecycle_unavailable`. + +Target provisioning stays outside NemoClaw. +An operator-owned adapter controls the target and retains all cloud, host administration, SSH, VNC, and service credentials. +NemoClaw does not pass those credentials to the sandbox or store them in its registry. + +The adapter must be an absolute executable path. +NemoClaw starts it without a shell, writes one `target-adapter-request` JSON object to standard input, and accepts one record from `schemas/cua-lifecycle.schema.json` on standard output. +The adapter must return a `target-attachment` record after success or a `failure` record after failure. +NemoClaw does not copy adapter standard error into public output. + +Attachment also requires a secret-free JSON manifest that matches `schemas/cua-target-manifest.schema.json`. +The manifest contains immutable target, image, service-bundle, and protocol identities. +It must not contain endpoints, credentials, host names, instance IDs, transport handles, or administration data. +The manifest path must directly name a regular file no larger than 64 KiB; NemoClaw does not follow symbolic links. + +```json +{ + "schemaVersion": "1.0.0", + "kind": "target-manifest", + "identityDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "platform": "desktop-linux-amd64", + "image": { + "name": "desktop-image", + "version": "1.0.0", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "owner": "target-owner" + }, + "serviceBundle": { + "name": "desktop-services", + "version": "1.0.0", + "digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "owner": "target-owner" + }, + "capabilities": [ + { "id": "browser", "protocolVersion": "1.0.0" }, + { "id": "computer", "protocolVersion": "1.0.0" }, + { "id": "terminal", "protocolVersion": "1.0.0" } + ] +} +``` + +All commands support `--json`. +Successful commands exit `0`. +Validation failures exit `2`, target or task conflicts exit `3`, unavailable lifecycle components exit `4`, and target health or compatibility failures exit `5`. +Failure output uses the versioned `failure` record and does not include raw adapter diagnostics. + +### `$$nemoclaw cua target attach` + +Attach one target after its manifest, image, service bundle, and three capability checks match. +A worker that already has a target returns `target_conflict` without invoking the adapter. + +```bash +$$nemoclaw my-cua cua target attach \ + --adapter /absolute/path/to/target-adapter \ + --target-manifest ./target-manifest.json \ + --json +``` + +### `$$nemoclaw cua target status` + +Read the recorded secret-free attachment projection without invoking the adapter. +The output includes bounded target identity, capability protocol and health, and active-task state. +It contains no endpoint or credential material. + +```bash +$$nemoclaw my-cua cua target status --json +``` + +The same bounded projection appears as `cuaTarget` in `$$nemoclaw status --json`. +`$$nemoclaw doctor` reports the recorded attachment state and capability health; it does not perform a live target probe. +Run `$$nemoclaw cua target health --adapter ` for fresh validation. + +### `$$nemoclaw cua target health` + +Recover fresh authority through the host adapter. +The command compares the observed target with the recorded identity and checks all three services. +It records `unreachable`, `incompatible`, or `replaced` without accepting the target when validation fails. + +```bash +$$nemoclaw my-cua cua target health \ + --adapter /absolute/path/to/target-adapter \ + --json +``` + +### `$$nemoclaw cua target reset` + +Ask the adapter to reconstruct the disposable target, browser profile, and fixture state. +NemoClaw accepts the reset target only after its declared components and all three services pass. +A reset can produce a new target identity. +The command rejects reset while a task is active. + +```bash +$$nemoclaw my-cua cua target reset \ + --adapter /absolute/path/to/target-adapter \ + --json +``` + +### `$$nemoclaw cua target detach` + +Ask the adapter to revoke target reachability. +NemoClaw clears the attachment projection only after the adapter returns a detached record. +The command rejects detach while a task is active. + +```bash +$$nemoclaw my-cua cua target detach \ + --adapter /absolute/path/to/target-adapter \ + --json +``` + +### `$$nemoclaw cua target destroy` + +Ask the adapter to destroy the disposable target. +NemoClaw clears the attachment projection only after the adapter confirms that the target is detached. +The command rejects destroy while a task is active. + +```bash +$$nemoclaw my-cua cua target destroy \ + --adapter /absolute/path/to/target-adapter \ + --json +``` + +Normal backups retain only the secret-free attachment projection. +They exclude the target, browser profile, mutable desktop state, adapter state, and administration material. +Recovery never reuses an attachment handle. +The host adapter obtains fresh authority and NemoClaw validates the immutable identities again. + ### `$$nemoclaw exec` Run a command non-interactively inside a running sandbox through the OpenShell exec endpoint. diff --git a/package.json b/package.json index 66c72608285..e3acf417213 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,8 @@ "nemoclaw/package.json", "nemoclaw-blueprint/", "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-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/commands/sandbox/cua/target/attach.ts b/src/commands/sandbox/cua/target/attach.ts new file mode 100644 index 00000000000..aceb8e913fb --- /dev/null +++ b/src/commands/sandbox/cua/target/attach.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../../lib/cli/nemoclaw-oclif-command"; +import { executeCuaTargetCommand, renderCuaTargetResult } from "../../../../lib/cua/target-command"; + +export default class SandboxCuaTargetAttachCommand extends NemoClawCommand { + static enableJsonFlag = true; + static id = "sandbox:cua:target:attach"; + static strict = true; + static summary = "Attach and verify one disposable CUA desktop target"; + static description = + "Use a host-side adapter to attach one target after immutable identity and browser, computer, and terminal health checks pass."; + static examples = [ + "<%= config.bin %> sandbox cua target attach alpha --adapter /opt/cua-target-adapter --target-manifest ./target.json", + ]; + static usage = [" --adapter --target-manifest [--json]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + adapter: Flags.string({ + description: "Absolute path to the operator-owned CUA target adapter", + required: true, + }), + "target-manifest": Flags.string({ + description: "Secret-free JSON manifest containing expected target identities", + required: true, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxCuaTargetAttachCommand); + const rendered = renderCuaTargetResult( + "target.attach", + executeCuaTargetCommand({ + operation: "target.attach", + sandboxName: args.sandboxName, + adapterPath: flags.adapter, + manifestPath: flags["target-manifest"], + }), + this.jsonEnabled(), + ); + this.setExitCode(rendered.exitCode); + if (rendered.error) console.error(rendered.error); + if (rendered.message) this.log(rendered.message); + return rendered.output; + } +} diff --git a/src/commands/sandbox/cua/target/destroy.ts b/src/commands/sandbox/cua/target/destroy.ts new file mode 100644 index 00000000000..ade651ee578 --- /dev/null +++ b/src/commands/sandbox/cua/target/destroy.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../../lib/cli/nemoclaw-oclif-command"; +import { executeCuaTargetCommand, renderCuaTargetResult } from "../../../../lib/cua/target-command"; + +export default class SandboxCuaTargetDestroyCommand extends NemoClawCommand { + static enableJsonFlag = true; + static id = "sandbox:cua:target:destroy"; + static strict = true; + static summary = "Destroy the disposable CUA target and clear attachment state"; + static description = + "Ask the host-side adapter to destroy the target before NemoClaw clears its secret-free attachment projection."; + static examples = [ + "<%= config.bin %> sandbox cua target destroy alpha --adapter /opt/cua-target-adapter", + ]; + static usage = [" --adapter [--json]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + adapter: Flags.string({ + description: "Absolute path to the operator-owned CUA target adapter", + required: true, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxCuaTargetDestroyCommand); + const rendered = renderCuaTargetResult( + "target.destroy", + executeCuaTargetCommand({ + operation: "target.destroy", + sandboxName: args.sandboxName, + adapterPath: flags.adapter, + }), + this.jsonEnabled(), + ); + this.setExitCode(rendered.exitCode); + if (rendered.error) console.error(rendered.error); + if (rendered.message) this.log(rendered.message); + return rendered.output; + } +} diff --git a/src/commands/sandbox/cua/target/detach.ts b/src/commands/sandbox/cua/target/detach.ts new file mode 100644 index 00000000000..ab5fc9c2fdd --- /dev/null +++ b/src/commands/sandbox/cua/target/detach.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../../lib/cli/nemoclaw-oclif-command"; +import { executeCuaTargetCommand, renderCuaTargetResult } from "../../../../lib/cua/target-command"; + +export default class SandboxCuaTargetDetachCommand extends NemoClawCommand { + static enableJsonFlag = true; + static id = "sandbox:cua:target:detach"; + static strict = true; + static summary = "Revoke CUA target reachability and clear attachment state"; + static description = + "Ask the host-side adapter to revoke target reachability before NemoClaw clears the secret-free attachment projection."; + static examples = [ + "<%= config.bin %> sandbox cua target detach alpha --adapter /opt/cua-target-adapter", + ]; + static usage = [" --adapter [--json]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + adapter: Flags.string({ + description: "Absolute path to the operator-owned CUA target adapter", + required: true, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxCuaTargetDetachCommand); + const rendered = renderCuaTargetResult( + "target.detach", + executeCuaTargetCommand({ + operation: "target.detach", + sandboxName: args.sandboxName, + adapterPath: flags.adapter, + }), + this.jsonEnabled(), + ); + this.setExitCode(rendered.exitCode); + if (rendered.error) console.error(rendered.error); + if (rendered.message) this.log(rendered.message); + return rendered.output; + } +} diff --git a/src/commands/sandbox/cua/target/health.ts b/src/commands/sandbox/cua/target/health.ts new file mode 100644 index 00000000000..7d29e4d9578 --- /dev/null +++ b/src/commands/sandbox/cua/target/health.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../../lib/cli/nemoclaw-oclif-command"; +import { executeCuaTargetCommand, renderCuaTargetResult } from "../../../../lib/cua/target-command"; + +export default class SandboxCuaTargetHealthCommand extends NemoClawCommand { + static enableJsonFlag = true; + static id = "sandbox:cua:target:health"; + static strict = true; + static summary = "Verify CUA target identity and capability health"; + static description = + "Recover fresh host-side authority, verify immutable target identity, and check browser, computer, and terminal separately."; + static examples = [ + "<%= config.bin %> sandbox cua target health alpha --adapter /opt/cua-target-adapter", + ]; + static usage = [" --adapter [--json]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + adapter: Flags.string({ + description: "Absolute path to the operator-owned CUA target adapter", + required: true, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxCuaTargetHealthCommand); + const rendered = renderCuaTargetResult( + "target.health", + executeCuaTargetCommand({ + operation: "target.health", + sandboxName: args.sandboxName, + adapterPath: flags.adapter, + }), + this.jsonEnabled(), + ); + this.setExitCode(rendered.exitCode); + if (rendered.error) console.error(rendered.error); + if (rendered.message) this.log(rendered.message); + return rendered.output; + } +} diff --git a/src/commands/sandbox/cua/target/reset.ts b/src/commands/sandbox/cua/target/reset.ts new file mode 100644 index 00000000000..4db2fc85ce9 --- /dev/null +++ b/src/commands/sandbox/cua/target/reset.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; +import { NemoClawCommand } from "../../../../lib/cli/nemoclaw-oclif-command"; +import { executeCuaTargetCommand, renderCuaTargetResult } from "../../../../lib/cua/target-command"; + +export default class SandboxCuaTargetResetCommand extends NemoClawCommand { + static enableJsonFlag = true; + static id = "sandbox:cua:target:reset"; + static strict = true; + static summary = "Reset mutable CUA desktop, browser, and fixture state"; + static description = + "Ask the host-side adapter to reconstruct mutable desktop, browser, and fixture state, then verify all target identities and services."; + static examples = [ + "<%= config.bin %> sandbox cua target reset alpha --adapter /opt/cua-target-adapter", + ]; + static usage = [" --adapter [--json]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + adapter: Flags.string({ + description: "Absolute path to the operator-owned CUA target adapter", + required: true, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxCuaTargetResetCommand); + const rendered = renderCuaTargetResult( + "target.reset", + executeCuaTargetCommand({ + operation: "target.reset", + sandboxName: args.sandboxName, + adapterPath: flags.adapter, + }), + this.jsonEnabled(), + ); + this.setExitCode(rendered.exitCode); + if (rendered.error) console.error(rendered.error); + if (rendered.message) this.log(rendered.message); + return rendered.output; + } +} diff --git a/src/commands/sandbox/cua/target/status.ts b/src/commands/sandbox/cua/target/status.ts new file mode 100644 index 00000000000..84314489940 --- /dev/null +++ b/src/commands/sandbox/cua/target/status.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args } from "@oclif/core"; +import { NemoClawCommand } from "../../../../lib/cli/nemoclaw-oclif-command"; +import { executeCuaTargetCommand, renderCuaTargetResult } from "../../../../lib/cua/target-command"; + +export default class SandboxCuaTargetStatusCommand extends NemoClawCommand { + static enableJsonFlag = true; + static id = "sandbox:cua:target:status"; + static strict = true; + static summary = "Show the secret-free CUA target attachment state"; + static description = + "Read the recorded target identity, capability health, and active-task projection without invoking the target adapter."; + static examples = ["<%= config.bin %> sandbox cua target status alpha --json"]; + static usage = [" [--json]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = {}; + + public async run(): Promise { + const { args } = await this.parse(SandboxCuaTargetStatusCommand); + const rendered = renderCuaTargetResult( + "target.status", + executeCuaTargetCommand({ + operation: "target.status", + sandboxName: args.sandboxName, + }), + this.jsonEnabled(), + ); + this.setExitCode(rendered.exitCode); + if (rendered.error) console.error(rendered.error); + if (rendered.message) this.log(rendered.message); + return rendered.output; + } +} diff --git a/src/lib/actions/sandbox/cua-target-status.test.ts b/src/lib/actions/sandbox/cua-target-status.test.ts new file mode 100644 index 00000000000..6918c7f5096 --- /dev/null +++ b/src/lib/actions/sandbox/cua-target-status.test.ts @@ -0,0 +1,93 @@ +// 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, + type CuaRuntimeReadiness, + type CuaTargetAttachment, +} from "../../cua/contract"; +import type { SandboxEntry } from "../../state/registry"; +import { buildCuaTargetDoctorCheck } from "./doctor"; +import { getSandboxStatusReport } from "./status"; + +const digest = (value: string): string => `sha256:${value.repeat(64).slice(0, 64)}`; +const attachment: CuaTargetAttachment = { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-attachment", + status: "attached", + target: { + identityDigest: digest("1"), + platform: "fixture-linux-amd64", + image: { name: "fixture-image", version: "1", digest: digest("2"), owner: "fixture" }, + serviceBundle: { + name: "fixture-services", + version: "1", + digest: digest("3"), + owner: "fixture", + }, + capabilities: [ + { id: "browser", protocolVersion: "1", health: "healthy" }, + { id: "computer", protocolVersion: "1", health: "healthy" }, + { id: "terminal", protocolVersion: "1", health: "healthy" }, + ], + }, + activeTask: null, +}; + +const readiness = { kind: "runtime-readiness" } as CuaRuntimeReadiness; + +describe("CUA target status and doctor projection (#7751)", () => { + it("adds only the secret-free target projection to sandbox status JSON", async () => { + const sandbox = { + name: "alpha", + agent: "openclaw", + cuaTarget: attachment, + } as SandboxEntry; + + const report = await getSandboxStatusReport("alpha", { + getSandbox: () => sandbox, + reconcile: async () => ({ state: "missing", output: "not found" }), + }); + + expect(report.cuaTarget).toEqual(attachment); + expect(JSON.stringify(report.cuaTarget)).not.toMatch( + /credential|password|secret|token|endpoint|hostname|ssh|vnc/i, + ); + }); + + it("reports an attached target and its three capability health states", () => { + const check = buildCuaTargetDoctorCheck("alpha", { + name: "alpha", + cuaRuntimeReadiness: readiness, + cuaTarget: attachment, + }); + + expect(check).toMatchObject({ + group: "Sandbox", + label: "CUA target", + status: "ok", + detail: expect.stringContaining("browser=healthy"), + }); + expect(check?.detail).toContain("computer=healthy"); + expect(check?.detail).toContain("terminal=healthy"); + expect(check?.detail).not.toMatch(/endpoint|hostname|credential/i); + }); + + it("fails doctor for replaced target state and reports detached state as informational", () => { + expect( + buildCuaTargetDoctorCheck("alpha", { + name: "alpha", + cuaRuntimeReadiness: readiness, + cuaTarget: { ...attachment, status: "replaced" }, + }), + ).toMatchObject({ status: "fail", detail: expect.stringContaining("replaced") }); + + expect( + buildCuaTargetDoctorCheck("alpha", { + name: "alpha", + cuaRuntimeReadiness: readiness, + }), + ).toMatchObject({ status: "info", detail: "no target attached" }); + }); +}); diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 8c07a63f1bb..e14f1c6a4e2 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -445,6 +445,36 @@ function baselineExclusionDoctorChecks(sandboxName: string): DoctorCheck[] { return checks; } +export function buildCuaTargetDoctorCheck( + sandboxName: string, + sb: SandboxEntry, +): DoctorCheck | null { + if (!sb.cuaRuntimeReadiness) return null; + const attachment = sb.cuaTarget; + if (!attachment || attachment.status === "detached" || !attachment.target) { + return { + group: "Sandbox", + label: "CUA target", + status: "info", + detail: "no target attached", + hint: `run \`${CLI_NAME} ${sandboxName} cua target attach\` with an operator-owned adapter`, + }; + } + const capabilities = attachment.target.capabilities + .map((capability) => `${capability.id}=${capability.health}`) + .join(", "); + return { + group: "Sandbox", + label: "CUA target", + status: attachment.status === "attached" ? "ok" : "fail", + detail: `${attachment.status}; ${attachment.target.identityDigest}; ${capabilities}`, + hint: + attachment.status === "attached" + ? undefined + : `run \`${CLI_NAME} ${sandboxName} cua target health\` with the operator-owned adapter`, + }; +} + function collectRegisteredSandboxChecks( sandboxName: string, sb: SandboxEntry | null | undefined, @@ -453,6 +483,8 @@ function collectRegisteredSandboxChecks( ): DoctorCheck[] { if (!sb) return []; const checks = [agentVersionDoctorCheck(sandboxName), shieldsDoctorCheck(sandboxName)]; + const cuaTargetCheck = buildCuaTargetDoctorCheck(sandboxName, sb); + if (cuaTargetCheck) checks.push(cuaTargetCheck); let dashboardPortRequired = true; try { dashboardPortRequired = shouldManageDashboardForAgent(loadAgent(sb.agent || "openclaw")); diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index 6363e0dd99d..59116fe3240 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -167,6 +167,8 @@ export interface SandboxStatusReport { openshellDriver: string; openshellVersion: string; policies: string[]; + /** Secret-free CUA target attachment and capability-health projection. */ + cuaTarget: registry.SandboxEntry["cuaTarget"] | null; /** Baseline network policy keys the operator has excluded, replayed on rebuild. */ baselineExclusions: string[]; /** Observed enforcement state for each recorded baseline exclusion. */ @@ -523,6 +525,7 @@ async function buildSandboxStatusReport( openshellDriver: (sb && sb.openshellDriver) || "unknown", openshellVersion: (sb && sb.openshellVersion) || "unknown", policies, + cuaTarget: sb?.cuaTarget ?? null, baselineExclusions, baselineExclusionStates, baselineExclusionTransition, diff --git a/src/lib/adapters/cua-target.test.ts b/src/lib/adapters/cua-target.test.ts new file mode 100644 index 00000000000..ecf7b1b05e7 --- /dev/null +++ b/src/lib/adapters/cua-target.test.ts @@ -0,0 +1,148 @@ +// 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 { CUA_LIFECYCLE_SCHEMA_VERSION, type CuaTargetAttachment } from "../cua/contract"; +import type { CuaTargetManifest } from "../cua/schema"; +import { detachedCuaTarget } from "../cua/target-lifecycle"; +import { + CuaTargetAdapterInvocationError, + type CuaTargetAdapterRequest, + ProcessCuaTargetAdapter, +} from "./cua-target"; + +const temporaryDirectories: string[] = []; +const digest = (value: string): string => `sha256:${value.repeat(64).slice(0, 64)}`; + +const manifest: CuaTargetManifest = { + 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" }, + ], +}; + +function request(): CuaTargetAdapterRequest { + return { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-adapter-request", + operation: "target.attach", + sandboxName: "alpha", + manifest, + current: detachedCuaTarget(), + }; +} + +function executable(source: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-target-adapter-")); + temporaryDirectories.push(directory); + const filePath = path.join(directory, "adapter.mjs"); + fs.writeFileSync(filePath, `#!/usr/bin/env node\n${source}`, { mode: 0o700 }); + return filePath; +} + +afterEach(() => { + vi.unstubAllEnvs(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("process CUA target adapter (#7751)", () => { + it("sends the bounded request on stdin and accepts one lifecycle record", () => { + const adapterPath = executable(` +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +const request = JSON.parse(Buffer.concat(chunks).toString("utf8")); +const manifest = request.manifest; +process.stdout.write(JSON.stringify({ + schemaVersion: request.schemaVersion, + kind: "target-attachment", + status: "attached", + target: { + identityDigest: manifest.identityDigest, + platform: manifest.platform, + image: manifest.image, + serviceBundle: manifest.serviceBundle, + capabilities: manifest.capabilities.map((capability) => ({ ...capability, health: "healthy" })), + }, + activeTask: null, +})); +`); + const adapter = new ProcessCuaTargetAdapter(adapterPath); + + const record = adapter.execute(request()) as CuaTargetAttachment; + + expect(record.kind).toBe("target-attachment"); + expect(record.target?.capabilities.map((capability) => capability.id).sort()).toEqual([ + "browser", + "computer", + "terminal", + ]); + }); + + it("does not copy target-private stderr into a validation error", () => { + const adapterPath = executable(` +process.stderr.write("private-adapter-diagnostic"); +process.stdout.write("not-json"); +`); + const adapter = new ProcessCuaTargetAdapter(adapterPath); + + expect(() => adapter.execute(request())).toThrowError(CuaTargetAdapterInvocationError); + try { + adapter.execute(request()); + } catch (error) { + expect(String(error)).not.toContain("private-adapter-diagnostic"); + } + }); + + it("rejects a relative executable before starting a process", () => { + const adapter = new ProcessCuaTargetAdapter("adapter"); + expect(() => adapter.execute(request())).toThrow("path must be absolute"); + }); + + it("does not forward unrelated host credential variables to the adapter", () => { + vi.stubEnv("CUA_TEST_AUTHORITY", "private-value"); + const adapterPath = executable(` +if (process.env.CUA_TEST_AUTHORITY) { + process.stdout.write("environment-leaked"); + process.exit(0); +} +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +const request = JSON.parse(Buffer.concat(chunks).toString("utf8")); +const manifest = request.manifest; +process.stdout.write(JSON.stringify({ + schemaVersion: request.schemaVersion, + kind: "target-attachment", + status: "attached", + target: { + identityDigest: manifest.identityDigest, + platform: manifest.platform, + image: manifest.image, + serviceBundle: manifest.serviceBundle, + capabilities: manifest.capabilities.map((capability) => ({ ...capability, health: "healthy" })), + }, + activeTask: null, +})); +`); + + expect(new ProcessCuaTargetAdapter(adapterPath).execute(request()).kind).toBe( + "target-attachment", + ); + }); +}); diff --git a/src/lib/adapters/cua-target.ts b/src/lib/adapters/cua-target.ts new file mode 100644 index 00000000000..4ff41ee8cf5 --- /dev/null +++ b/src/lib/adapters/cua-target.ts @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + CUA_LIFECYCLE_SCHEMA_VERSION, + type CuaFailure, + type CuaFailureFamily, + type CuaTargetAttachment, +} from "../cua/contract"; +import { type CuaTargetManifest, parseCuaLifecycleRecord } from "../cua/schema"; + +export type CuaTargetAdapterOperation = + | "target.attach" + | "target.health" + | "target.detach" + | "target.reset" + | "target.destroy"; + +export interface CuaTargetAdapterRequest { + schemaVersion: typeof CUA_LIFECYCLE_SCHEMA_VERSION; + kind: "target-adapter-request"; + operation: CuaTargetAdapterOperation; + sandboxName: string; + manifest: CuaTargetManifest | null; + current: CuaTargetAttachment; +} + +export type CuaTargetAdapterResult = CuaTargetAttachment | CuaFailure; + +export interface CuaTargetAdapter { + execute(request: CuaTargetAdapterRequest): CuaTargetAdapterResult; +} + +export class CuaTargetAdapterInvocationError extends Error { + constructor( + message: string, + readonly family: CuaFailureFamily, + readonly retryable: boolean, + ) { + super(message); + this.name = "CuaTargetAdapterInvocationError"; + } +} + +export interface ProcessCuaTargetAdapterOptions { + timeoutMs?: number; + maxOutputBytes?: number; +} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024; +const ADAPTER_ENV_KEYS = [ + "HOME", + "PATH", + "TMPDIR", + "TMP", + "TEMP", + "SystemRoot", + "ComSpec", + "PATHEXT", + "LANG", + "LC_ALL", +] as const; + +function adapterEnvironment(): NodeJS.ProcessEnv { + return Object.fromEntries( + ADAPTER_ENV_KEYS.flatMap((key) => { + const value = process.env[key]; + return value === undefined ? [] : [[key, value]]; + }), + ); +} + +function validateExecutable(executable: string): void { + if (!path.isAbsolute(executable)) { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter path must be absolute", + "validation_failed", + false, + ); + } + let stat: fs.Stats; + try { + stat = fs.statSync(executable); + fs.accessSync(executable, fs.constants.X_OK); + } catch { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter is unavailable", + "lifecycle_unavailable", + false, + ); + } + if (!stat.isFile()) { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter is unavailable", + "lifecycle_unavailable", + false, + ); + } +} + +function parseAdapterResult( + stdout: string, + operation: CuaTargetAdapterOperation, + processStatus: number | null, +): CuaTargetAdapterResult { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter returned invalid JSON", + "validation_failed", + false, + ); + } + let record; + try { + record = parseCuaLifecycleRecord(parsed); + } catch { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter returned an invalid lifecycle record", + "validation_failed", + false, + ); + } + if (record.kind !== "target-attachment" && record.kind !== "failure") { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter returned an unsupported record", + "validation_failed", + false, + ); + } + if (record.kind === "failure") { + if (record.operation !== operation) { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter returned a failure for another operation", + "validation_failed", + false, + ); + } + return record; + } + if (processStatus !== 0) { + throw new CuaTargetAdapterInvocationError( + "the CUA target adapter exited unsuccessfully without a failure record", + "target_unreachable", + true, + ); + } + return record; +} + +/** + * Invoke one explicit CUA target adapter without a shell. + * + * The adapter receives target requests on stdin and returns only checked-in + * lifecycle records on stdout. Adapter stderr is never copied into public + * output because it can contain target-private diagnostics. + */ +export class ProcessCuaTargetAdapter implements CuaTargetAdapter { + readonly timeoutMs: number; + readonly maxOutputBytes: number; + + constructor( + readonly executable: string, + options: ProcessCuaTargetAdapterOptions = {}, + ) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + } + + execute(request: CuaTargetAdapterRequest): CuaTargetAdapterResult { + validateExecutable(this.executable); + const result = spawnSync(this.executable, [], { + encoding: "utf8", + input: `${JSON.stringify(request)}\n`, + maxBuffer: this.maxOutputBytes, + env: adapterEnvironment(), + shell: false, + timeout: this.timeoutMs, + windowsHide: true, + }); + if (result.error) { + const timedOut = (result.error as NodeJS.ErrnoException).code === "ETIMEDOUT"; + throw new CuaTargetAdapterInvocationError( + timedOut ? "the CUA target adapter timed out" : "the CUA target adapter failed", + timedOut ? "target_unreachable" : "lifecycle_unavailable", + timedOut, + ); + } + return parseAdapterResult(result.stdout, request.operation, result.status); + } +} diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index cc50558897c..5259a3b37f8 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -244,6 +244,54 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { flags: "[--quiet|-q]", }, ], + "sandbox:cua:target:attach": [ + { + group: "Sandbox Management", + order: 6.1, + description: "Attach and verify one disposable CUA desktop target", + flags: "--adapter --target-manifest [--json]", + }, + ], + "sandbox:cua:target:status": [ + { + group: "Sandbox Management", + order: 6.2, + description: "Show the secret-free CUA target attachment state", + flags: "[--json]", + }, + ], + "sandbox:cua:target:health": [ + { + group: "Sandbox Management", + order: 6.3, + description: "Verify CUA target identity and capability health", + flags: "--adapter [--json]", + }, + ], + "sandbox:cua:target:reset": [ + { + group: "Sandbox Management", + order: 6.4, + description: "Reset and verify the disposable CUA target", + flags: "--adapter [--json]", + }, + ], + "sandbox:cua:target:detach": [ + { + group: "Sandbox Management", + order: 6.5, + description: "Revoke CUA target reachability and clear attachment state", + flags: "--adapter [--json]", + }, + ], + "sandbox:cua:target:destroy": [ + { + group: "Sandbox Management", + order: 6.6, + description: "Destroy the disposable CUA target and clear attachment state", + flags: "--adapter [--json]", + }, + ], "sandbox:destroy": [ { group: "Sandbox Management", diff --git a/src/lib/cua/schema.test.ts b/src/lib/cua/schema.test.ts new file mode 100644 index 00000000000..2224b775cb0 --- /dev/null +++ b/src/lib/cua/schema.test.ts @@ -0,0 +1,61 @@ +// 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"); + }); + + 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..50f13e0bace --- /dev/null +++ b/src/lib/cua/schema.ts @@ -0,0 +1,87 @@ +// 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 CuaLifecycleRecord, + type CuaRuntimeReadiness, + type CuaTargetAttachment, + 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): CuaLifecycleRecord { + 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 { + const record = parseCuaLifecycleRecord(value); + if (record.kind !== "runtime-readiness") { + throw new Error("CUA runtime state must be a runtime-readiness record"); + } + return record; +} + +export function parseCuaTargetAttachment(value: unknown): CuaTargetAttachment { + const record = parseCuaLifecycleRecord(value); + if (record.kind !== "target-attachment") { + throw new Error("CUA target state must be a target-attachment record"); + } + return record; +} + +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"); + } + return manifest; +} diff --git a/src/lib/cua/target-command.ts b/src/lib/cua/target-command.ts new file mode 100644 index 00000000000..8e0eebe5b52 --- /dev/null +++ b/src/lib/cua/target-command.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ProcessCuaTargetAdapter } from "../adapters/cua-target"; +import { + CUA_LIFECYCLE_SCHEMA_VERSION, + type CuaFailure, + type CuaTargetAttachment, +} from "./contract"; +import { + CUA_TARGET_EXIT_CODES, + type CuaTargetLifecycleOperation, + type CuaTargetLifecycleResult, + executeCuaTargetLifecycle, + readCuaTargetManifest, +} from "./target-lifecycle"; + +export interface CuaTargetCommandInput { + operation: CuaTargetLifecycleOperation; + sandboxName: string; + adapterPath?: string; + manifestPath?: string; +} + +function validationFailure(operation: CuaTargetLifecycleOperation): CuaTargetLifecycleResult { + const record: CuaFailure = { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "failure", + operation, + family: "validation_failed", + retryable: false, + component: "target", + }; + return { record, exitCode: CUA_TARGET_EXIT_CODES.validation }; +} + +export function executeCuaTargetCommand(input: CuaTargetCommandInput): CuaTargetLifecycleResult { + let manifest; + try { + manifest = input.manifestPath ? readCuaTargetManifest(input.manifestPath) : undefined; + } catch { + return validationFailure(input.operation); + } + const adapter = input.adapterPath ? new ProcessCuaTargetAdapter(input.adapterPath) : undefined; + return executeCuaTargetLifecycle({ + operation: input.operation, + sandboxName: input.sandboxName, + ...(adapter ? { adapter } : {}), + ...(manifest ? { manifest } : {}), + }); +} + +function successMessage( + operation: CuaTargetLifecycleOperation, + record: CuaTargetAttachment, +): string { + const action = operation.slice("target.".length); + if (record.status === "detached") return `CUA target ${action}: detached`; + return `CUA target ${action}: ${record.status} (${record.target?.identityDigest ?? "unknown"})`; +} + +export interface RenderedCuaTargetResult { + exitCode: number; + output?: CuaTargetAttachment | CuaFailure; + message?: string; + error?: string; +} + +export function renderCuaTargetResult( + operation: CuaTargetLifecycleOperation, + lifecycleResult: CuaTargetLifecycleResult, + jsonEnabled: boolean, +): RenderedCuaTargetResult { + if (jsonEnabled) { + return { exitCode: lifecycleResult.exitCode, output: lifecycleResult.record }; + } + if (lifecycleResult.record.kind === "failure") { + return { + exitCode: lifecycleResult.exitCode, + error: `CUA ${operation.replace(".", " ")} failed: ${lifecycleResult.record.family}`, + }; + } + return { + exitCode: lifecycleResult.exitCode, + message: successMessage(operation, lifecycleResult.record), + }; +} diff --git a/src/lib/cua/target-lifecycle.test.ts b/src/lib/cua/target-lifecycle.test.ts new file mode 100644 index 00000000000..02e9555b499 --- /dev/null +++ b/src/lib/cua/target-lifecycle.test.ts @@ -0,0 +1,358 @@ +// 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 { + CuaTargetAdapter, + CuaTargetAdapterRequest, + CuaTargetAdapterResult, +} from "../adapters/cua-target"; +import type { SandboxRegistry } from "../state/registry/types"; +import { + CUA_CAPABILITIES, + CUA_LIFECYCLE_SCHEMA_VERSION, + CUA_REQUIRED_TASK_OPERATIONS, + CUA_TARGET_OPERATIONS, + type CuaRuntimeReadiness, + type CuaTargetAttachment, +} from "./contract"; +import type { CuaTargetManifest } from "./schema"; +import { + type CuaTargetLifecycleDeps, + detachedCuaTarget, + executeCuaTargetLifecycle, + readCuaTargetManifest, +} from "./target-lifecycle"; + +const digest = (value: string): string => `sha256:${value.repeat(64).slice(0, 64)}`; + +const runtimeReadiness: CuaRuntimeReadiness = { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "runtime-readiness", + mode: "standalone", + status: "available", + components: { + runtime: { name: "cua-fixture", version: "1.0.0", digest: digest("1"), owner: "fixture" }, + sandboxImage: { + name: "cua-sandbox", + version: "1.0.0", + digest: digest("2"), + owner: "fixture", + }, + policy: { name: "cua-policy", version: "1.0.0", digest: digest("3"), owner: "fixture" }, + taskProtocol: { + name: "cua-task", + version: "1.0.0", + digest: digest("4"), + owner: "fixture", + }, + }, + inference: { provider: "fixture", model: "fixture-model" }, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: CUA_CAPABILITIES, + targetOperations: CUA_TARGET_OPERATIONS, + taskOperations: CUA_REQUIRED_TASK_OPERATIONS, +}; + +const manifest: CuaTargetManifest = { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-manifest", + identityDigest: digest("5"), + platform: "fixture-linux-amd64", + image: { name: "desktop-fixture", version: "1.0.0", digest: digest("6"), owner: "fixture" }, + serviceBundle: { + name: "desktop-services", + version: "1.0.0", + digest: digest("7"), + owner: "fixture", + }, + capabilities: [ + { id: "browser", protocolVersion: "1.0.0" }, + { id: "computer", protocolVersion: "1.0.0" }, + { id: "terminal", protocolVersion: "1.0.0" }, + ], +}; + +function attachedTarget( + overrides: Partial> = {}, +): CuaTargetAttachment { + return { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-attachment", + status: "attached", + target: { + identityDigest: manifest.identityDigest, + platform: manifest.platform, + image: manifest.image, + serviceBundle: manifest.serviceBundle, + capabilities: manifest.capabilities.map((capability) => ({ + ...capability, + health: "healthy" as const, + })), + ...overrides, + }, + activeTask: null, + }; +} + +function fakeAdapter( + implementation: (request: CuaTargetAdapterRequest) => CuaTargetAdapterResult, +): CuaTargetAdapter & { execute: ReturnType } { + return { execute: vi.fn(implementation) }; +} + +function harness(target?: CuaTargetAttachment): { + registry: SandboxRegistry; + deps: CuaTargetLifecycleDeps; +} { + const registry: SandboxRegistry = { + defaultSandbox: "alpha", + sandboxes: { + alpha: { + name: "alpha", + cuaRuntimeReadiness: structuredClone(runtimeReadiness), + ...(target ? { cuaTarget: structuredClone(target) } : {}), + }, + }, + }; + return { + registry, + deps: { + load: () => structuredClone(registry), + save: (next) => { + registry.defaultSandbox = next.defaultSandbox; + registry.sandboxes = structuredClone(next.sandboxes); + }, + withLock: (fn) => fn(), + }, + }; +} + +describe("CUA target lifecycle (#7751)", () => { + it("rejects a symlinked target manifest before parsing it", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-target-manifest-")); + const target = path.join(directory, "target.json"); + const link = path.join(directory, "manifest.json"); + fs.writeFileSync(target, JSON.stringify(manifest)); + fs.symlinkSync(target, link); + + expect(() => readCuaTargetManifest(link)).toThrow(); + + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("attaches only after immutable identity and all capability checks pass", () => { + const { registry, deps } = harness(); + const adapter = fakeAdapter(() => attachedTarget()); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.attach", sandboxName: "alpha", adapter, manifest }, + deps, + ); + + expect(outcome).toEqual({ record: attachedTarget(), exitCode: 0 }); + expect(registry.sandboxes.alpha?.cuaTarget).toEqual(attachedTarget()); + expect(adapter.execute).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "target.attach", + sandboxName: "alpha", + manifest, + current: detachedCuaTarget(), + }), + ); + }); + + it("rejects a second target before invoking the adapter", () => { + const current = attachedTarget(); + const { deps } = harness(current); + const adapter = fakeAdapter(() => current); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.attach", sandboxName: "alpha", adapter, manifest }, + deps, + ); + + expect(outcome.record).toMatchObject({ kind: "failure", family: "target_conflict" }); + expect(outcome.exitCode).toBe(3); + expect(adapter.execute).not.toHaveBeenCalled(); + }); + + it("rejects an observed target whose immutable identity does not match the manifest", () => { + const { registry, deps } = harness(); + const adapter = fakeAdapter(() => attachedTarget({ identityDigest: digest("8") })); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.attach", sandboxName: "alpha", adapter, manifest }, + deps, + ); + + expect(outcome.record).toMatchObject({ kind: "failure", family: "target_incompatible" }); + expect(registry.sandboxes.alpha?.cuaTarget).toBeUndefined(); + }); + + it("records a changed identity as replaced without granting fresh authority", () => { + const current = attachedTarget(); + const { registry, deps } = harness(current); + const adapter = fakeAdapter(() => attachedTarget({ identityDigest: digest("8") })); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.health", sandboxName: "alpha", adapter }, + deps, + ); + + expect(outcome.record).toMatchObject({ kind: "failure", family: "target_replaced" }); + expect(registry.sandboxes.alpha?.cuaTarget).toEqual({ ...current, status: "replaced" }); + }); + + it("records service-bundle drift as incompatible", () => { + const current = attachedTarget(); + const { registry, deps } = harness(current); + const adapter = fakeAdapter(() => + attachedTarget({ + serviceBundle: { ...manifest.serviceBundle, digest: digest("8") }, + }), + ); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.health", sandboxName: "alpha", adapter }, + deps, + ); + + expect(outcome.record).toMatchObject({ kind: "failure", family: "target_incompatible" }); + expect(registry.sandboxes.alpha?.cuaTarget?.status).toBe("incompatible"); + }); + + it("records an unreachable target without exposing adapter diagnostics", () => { + const current = attachedTarget(); + const { registry, deps } = harness(current); + const adapter = fakeAdapter((request) => ({ + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "failure", + operation: request.operation, + family: "target_unreachable", + retryable: true, + component: "target", + })); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.health", sandboxName: "alpha", adapter }, + deps, + ); + + expect(outcome.record).toMatchObject({ kind: "failure", family: "target_unreachable" }); + expect(registry.sandboxes.alpha?.cuaTarget?.status).toBe("unreachable"); + }); + + it("classifies one failed service check without disturbing other capability identities", () => { + const current = attachedTarget(); + const unhealthy: CuaTargetAttachment = { + ...current, + status: "unreachable", + target: { + ...current.target!, + capabilities: current.target!.capabilities.map((capability) => ({ + ...capability, + health: capability.id === "browser" ? "unhealthy" : "healthy", + })), + }, + }; + const { registry, deps } = harness(current); + const adapter = fakeAdapter(() => unhealthy); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.health", sandboxName: "alpha", adapter }, + deps, + ); + + expect(outcome.record).toMatchObject({ + kind: "failure", + family: "capability_unhealthy", + component: "browser", + }); + expect(registry.sandboxes.alpha?.cuaTarget).toMatchObject({ + status: "unreachable", + target: { + capabilities: expect.arrayContaining([ + expect.objectContaining({ id: "browser", health: "unhealthy" }), + ]), + }, + }); + }); + + it("rejects reset while the target has an active task", () => { + const current: CuaTargetAttachment = { + ...attachedTarget(), + activeTask: { taskId: "task-1", status: "running" }, + }; + const { deps } = harness(current); + const adapter = fakeAdapter(() => attachedTarget({ identityDigest: digest("8") })); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.reset", sandboxName: "alpha", adapter }, + deps, + ); + + expect(outcome.record).toMatchObject({ kind: "failure", family: "task_conflict" }); + expect(adapter.execute).not.toHaveBeenCalled(); + }); + + it("accepts a reset replacement only after component and capability checks pass", () => { + const current = attachedTarget(); + const replacement = attachedTarget({ identityDigest: digest("8") }); + const { registry, deps } = harness(current); + const adapter = fakeAdapter(() => replacement); + + const outcome = executeCuaTargetLifecycle( + { operation: "target.reset", sandboxName: "alpha", adapter }, + deps, + ); + + expect(outcome).toEqual({ record: replacement, exitCode: 0 }); + expect(registry.sandboxes.alpha?.cuaTarget).toEqual(replacement); + }); + + it.each([ + "target.detach", + "target.destroy", + ] as const)("%s clears attachment state after the adapter revokes reachability", (operation) => { + const { registry, deps } = harness(attachedTarget()); + const adapter = fakeAdapter(() => detachedCuaTarget()); + + const outcome = executeCuaTargetLifecycle({ operation, sandboxName: "alpha", adapter }, deps); + + expect(outcome).toEqual({ record: detachedCuaTarget(), exitCode: 0 }); + expect(registry.sandboxes.alpha?.cuaTarget).toEqual(detachedCuaTarget()); + }); + + it("reports the target lifecycle unavailable before canonical runtime registration", () => { + const { registry, deps } = harness(); + delete registry.sandboxes.alpha!.cuaRuntimeReadiness; + + const outcome = executeCuaTargetLifecycle( + { operation: "target.status", sandboxName: "alpha" }, + deps, + ); + + expect(outcome.record).toMatchObject({ kind: "failure", family: "lifecycle_unavailable" }); + expect(outcome.exitCode).toBe(4); + }); + + it("stores only the secret-free target projection", () => { + const { registry, deps } = harness(); + const adapter = fakeAdapter(() => attachedTarget()); + executeCuaTargetLifecycle( + { operation: "target.attach", sandboxName: "alpha", adapter, manifest }, + deps, + ); + + const persisted = JSON.stringify(registry); + expect(persisted).not.toMatch( + /credential|password|secret|token|endpoint|hostname|instance|ssh|vnc|path/i, + ); + }); +}); diff --git a/src/lib/cua/target-lifecycle.ts b/src/lib/cua/target-lifecycle.ts new file mode 100644 index 00000000000..b8eb10a9aa8 --- /dev/null +++ b/src/lib/cua/target-lifecycle.ts @@ -0,0 +1,353 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { isDeepStrictEqual } from "node:util"; +import type { + CuaTargetAdapter, + CuaTargetAdapterOperation, + CuaTargetAdapterResult, +} from "../adapters/cua-target"; +import { CuaTargetAdapterInvocationError } from "../adapters/cua-target"; +import { withLock } from "../state/registry/lock"; +import { load, save } from "../state/registry/persistence"; +import type { SandboxRegistry } from "../state/registry/types"; +import { + CUA_LIFECYCLE_SCHEMA_VERSION, + type CuaCapability, + type CuaFailure, + type CuaFailureFamily, + type CuaTargetAttachment, +} from "./contract"; +import { type CuaTargetManifest, parseCuaTargetManifest } from "./schema"; + +export type CuaTargetLifecycleOperation = CuaTargetAdapterOperation | "target.status"; + +export interface CuaTargetLifecycleInput { + operation: CuaTargetLifecycleOperation; + sandboxName: string; + adapter?: CuaTargetAdapter; + manifest?: CuaTargetManifest; +} + +export interface CuaTargetLifecycleResult { + record: CuaTargetAttachment | CuaFailure; + exitCode: number; +} + +export interface CuaTargetLifecycleDeps { + load: () => SandboxRegistry; + save: (registry: SandboxRegistry) => void; + withLock: (fn: () => T) => T; +} + +const defaultDeps: CuaTargetLifecycleDeps = { load, save, withLock }; + +const MAX_TARGET_MANIFEST_BYTES = 64 * 1024; + +export const CUA_TARGET_EXIT_CODES = { + success: 0, + validation: 2, + conflict: 3, + unavailable: 4, + target: 5, +} as const; + +export function detachedCuaTarget(): CuaTargetAttachment { + return { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-attachment", + status: "detached", + target: null, + activeTask: null, + }; +} + +function failure( + operation: CuaTargetLifecycleOperation, + family: CuaFailureFamily, + retryable: boolean, + component?: CuaCapability | "runtime" | "target", +): CuaFailure { + return { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "failure", + operation, + family, + retryable, + ...(component ? { component } : {}), + }; +} + +function exitCodeFor(family: CuaFailureFamily): number { + if (family === "validation_failed") return CUA_TARGET_EXIT_CODES.validation; + if (family === "target_conflict" || family === "task_conflict") { + return CUA_TARGET_EXIT_CODES.conflict; + } + if (family === "lifecycle_unavailable" || family === "runtime_unavailable") { + return CUA_TARGET_EXIT_CODES.unavailable; + } + return CUA_TARGET_EXIT_CODES.target; +} + +function result(record: CuaTargetAttachment | CuaFailure): CuaTargetLifecycleResult { + return { + record, + exitCode: + record.kind === "failure" ? exitCodeFor(record.family) : CUA_TARGET_EXIT_CODES.success, + }; +} + +function failed( + operation: CuaTargetLifecycleOperation, + family: CuaFailureFamily, + retryable: boolean, + component?: CuaCapability | "runtime" | "target", +): CuaTargetLifecycleResult { + return result(failure(operation, family, retryable, component)); +} + +function capabilityProtocols( + target: NonNullable, +): Array<{ id: CuaCapability; protocolVersion: string }> { + return target.capabilities + .map(({ id, protocolVersion }) => ({ id, protocolVersion })) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +function manifestProtocols( + manifest: CuaTargetManifest, +): Array<{ id: CuaCapability; protocolVersion: string }> { + return [...manifest.capabilities].sort((left, right) => left.id.localeCompare(right.id)); +} + +function targetMatchesManifest( + target: NonNullable, + manifest: CuaTargetManifest, +): boolean { + return ( + target.identityDigest === manifest.identityDigest && + target.platform === manifest.platform && + isDeepStrictEqual(target.image, manifest.image) && + isDeepStrictEqual(target.serviceBundle, manifest.serviceBundle) && + isDeepStrictEqual(capabilityProtocols(target), manifestProtocols(manifest)) + ); +} + +function targetComponentsMatch( + observed: NonNullable, + current: NonNullable, +): boolean { + return ( + observed.platform === current.platform && + isDeepStrictEqual(observed.image, current.image) && + isDeepStrictEqual(observed.serviceBundle, current.serviceBundle) && + isDeepStrictEqual(capabilityProtocols(observed), capabilityProtocols(current)) + ); +} + +function firstUnhealthyCapability( + target: NonNullable, +): CuaCapability | undefined { + return target.capabilities.find((capability) => capability.health !== "healthy")?.id; +} + +function persistFailureState( + registry: SandboxRegistry, + sandboxName: string, + current: CuaTargetAttachment, + failureRecord: CuaFailure, +): boolean { + const status = + failureRecord.family === "target_replaced" + ? "replaced" + : failureRecord.family === "target_incompatible" + ? "incompatible" + : failureRecord.family === "target_unreachable" || + failureRecord.family === "capability_unhealthy" + ? "unreachable" + : null; + if (!status || !current.target) return false; + const sandbox = registry.sandboxes[sandboxName]; + if (!sandbox) return false; + sandbox.cuaTarget = { ...current, status }; + return true; +} + +function validateAdapterTarget( + operation: CuaTargetAdapterOperation, + adapterResult: CuaTargetAdapterResult, +): CuaTargetAttachment | CuaFailure { + if (adapterResult.kind === "failure") return adapterResult; + const expectsDetached = operation === "target.detach" || operation === "target.destroy"; + if (expectsDetached) { + if ( + adapterResult.status !== "detached" || + adapterResult.target !== null || + adapterResult.activeTask !== null + ) { + return failure(operation, "validation_failed", false, "target"); + } + return adapterResult; + } + if ( + adapterResult.target === null || + (operation !== "target.health" && adapterResult.status !== "attached") || + (operation === "target.health" && adapterResult.status === "detached") + ) { + return failure(operation, "validation_failed", false, "target"); + } + return adapterResult; +} + +function invokeAdapter( + input: CuaTargetLifecycleInput, + current: CuaTargetAttachment, +): CuaTargetAdapterResult { + if (input.operation === "target.status" || !input.adapter) { + return failure(input.operation, "lifecycle_unavailable", false, "target"); + } + try { + return input.adapter.execute({ + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-adapter-request", + operation: input.operation, + sandboxName: input.sandboxName, + manifest: input.manifest ?? null, + current, + }); + } catch (error) { + if (error instanceof CuaTargetAdapterInvocationError) { + return failure(input.operation, error.family, error.retryable, "target"); + } + return failure(input.operation, "lifecycle_unavailable", false, "target"); + } +} + +function executeLocked( + input: CuaTargetLifecycleInput, + deps: CuaTargetLifecycleDeps, +): CuaTargetLifecycleResult { + const registry = deps.load(); + const sandbox = registry.sandboxes[input.sandboxName]; + if (!sandbox) return failed(input.operation, "validation_failed", false, "target"); + + const readiness = sandbox.cuaRuntimeReadiness; + if (!readiness) return failed(input.operation, "lifecycle_unavailable", false, "runtime"); + if (readiness.status === "incompatible") { + return failed(input.operation, "runtime_incompatible", false, "runtime"); + } + if (readiness.status !== "available") { + return failed(input.operation, "runtime_unavailable", true, "runtime"); + } + + const current = sandbox.cuaTarget ?? detachedCuaTarget(); + if (input.operation === "target.status") return result(current); + + if (!input.adapter) { + return failed(input.operation, "lifecycle_unavailable", false, "target"); + } + + if (input.operation === "target.attach") { + if (current.status !== "detached" || current.target !== null) { + return failed(input.operation, "target_conflict", false, "target"); + } + if (!input.manifest) return failed(input.operation, "validation_failed", false, "target"); + } else if (current.status === "detached" || current.target === null) { + if (input.operation === "target.detach" || input.operation === "target.destroy") { + return result(current); + } + return failed(input.operation, "target_unreachable", false, "target"); + } + + if ( + current.activeTask && + (input.operation === "target.reset" || + input.operation === "target.detach" || + input.operation === "target.destroy") + ) { + return failed(input.operation, "task_conflict", false, "target"); + } + + const checked = validateAdapterTarget(input.operation, invokeAdapter(input, current)); + if (checked.kind === "failure") { + if (persistFailureState(registry, input.sandboxName, current, checked)) { + deps.save(registry); + } + return result(checked); + } + + if (input.operation === "target.detach" || input.operation === "target.destroy") { + sandbox.cuaTarget = detachedCuaTarget(); + deps.save(registry); + return result(sandbox.cuaTarget); + } + + const observed = checked.target; + if (!observed) return failed(input.operation, "validation_failed", false, "target"); + + if (input.operation === "target.attach") { + if (!input.manifest || !targetMatchesManifest(observed, input.manifest)) { + return failed(input.operation, "target_incompatible", false, "target"); + } + } else if (current.target) { + if (!targetComponentsMatch(observed, current.target)) { + sandbox.cuaTarget = { ...current, status: "incompatible" }; + deps.save(registry); + return failed(input.operation, "target_incompatible", false, "target"); + } + if ( + input.operation === "target.health" && + observed.identityDigest !== current.target.identityDigest + ) { + sandbox.cuaTarget = { ...current, status: "replaced" }; + deps.save(registry); + return failed(input.operation, "target_replaced", false, "target"); + } + } + + const unhealthy = firstUnhealthyCapability(observed); + if (unhealthy) { + if (input.operation !== "target.attach") { + sandbox.cuaTarget = { + ...current, + status: "unreachable", + target: observed, + }; + deps.save(registry); + } + return failed(input.operation, "capability_unhealthy", true, unhealthy); + } + + sandbox.cuaTarget = { + ...checked, + status: "attached", + activeTask: current.activeTask, + }; + deps.save(registry); + return result(sandbox.cuaTarget); +} + +export function executeCuaTargetLifecycle( + input: CuaTargetLifecycleInput, + deps: CuaTargetLifecycleDeps = defaultDeps, +): CuaTargetLifecycleResult { + return deps.withLock(() => executeLocked(input, deps)); +} + +export function readCuaTargetManifest(filePath: string): CuaTargetManifest { + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.size > MAX_TARGET_MANIFEST_BYTES) { + throw new Error("CUA target manifest must be a JSON file no larger than 64 KiB"); + } + const contents = fs.readFileSync(descriptor); + if (contents.byteLength > MAX_TARGET_MANIFEST_BYTES) { + throw new Error("CUA target manifest must be a JSON file no larger than 64 KiB"); + } + return parseCuaTargetManifest(JSON.parse(contents.toString("utf8"))); + } finally { + fs.closeSync(descriptor); + } +} diff --git a/src/lib/state/registry-cua.test.ts b/src/lib/state/registry-cua.test.ts new file mode 100644 index 00000000000..c0d80ef07e7 --- /dev/null +++ b/src/lib/state/registry-cua.test.ts @@ -0,0 +1,104 @@ +// 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 { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { + CUA_CAPABILITIES, + CUA_LIFECYCLE_SCHEMA_VERSION, + CUA_REQUIRED_TASK_OPERATIONS, + CUA_TARGET_OPERATIONS, + type CuaRuntimeReadiness, + type CuaTargetAttachment, +} from "../cua/contract"; + +const testHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-registry-cua-")); +process.env.HOME = testHome; +const registry = await import("./registry"); + +const digest = (value: string): string => `sha256:${value.repeat(64).slice(0, 64)}`; +const component = (name: string, value: string) => ({ + name, + version: "1.0.0", + digest: digest(value), + owner: "fixture", +}); + +const readiness: CuaRuntimeReadiness = { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "runtime-readiness", + mode: "standalone", + status: "available", + components: { + runtime: component("cua-fixture", "1"), + sandboxImage: component("sandbox-fixture", "2"), + policy: component("policy-fixture", "3"), + taskProtocol: component("task-fixture", "4"), + }, + inference: { provider: "fixture", model: "fixture-model" }, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: CUA_CAPABILITIES, + targetOperations: CUA_TARGET_OPERATIONS, + taskOperations: CUA_REQUIRED_TASK_OPERATIONS, +}; + +const attachment: CuaTargetAttachment = { + schemaVersion: CUA_LIFECYCLE_SCHEMA_VERSION, + kind: "target-attachment", + status: "attached", + target: { + identityDigest: digest("5"), + platform: "fixture-linux-amd64", + image: component("desktop-fixture", "6"), + serviceBundle: component("service-fixture", "7"), + capabilities: CUA_CAPABILITIES.map((id) => ({ + id, + protocolVersion: "1.0.0", + health: "healthy" as const, + })), + }, + activeTask: null, +}; + +beforeEach(() => { + registry.clearAll(); +}); + +afterAll(() => { + fs.rmSync(testHome, { recursive: true, force: true }); +}); + +describe("CUA canonical registry state (#7751)", () => { + it("round-trips only versioned runtime and target projections", () => { + registry.registerSandbox({ + name: "alpha", + cuaRuntimeReadiness: readiness, + cuaTarget: attachment, + }); + + expect(registry.getSandbox("alpha")).toMatchObject({ + cuaRuntimeReadiness: readiness, + cuaTarget: attachment, + }); + const disk = JSON.parse(fs.readFileSync(registry.REGISTRY_FILE, "utf8")); + expect(JSON.stringify(disk.sandboxes.alpha.cuaTarget)).not.toMatch( + /credential|password|secret|token|endpoint|hostName|ssh|vnc/i, + ); + }); + + it("fails closed when persisted target health does not match the schema", () => { + registry.registerSandbox({ + name: "alpha", + cuaRuntimeReadiness: readiness, + cuaTarget: attachment, + }); + const disk = JSON.parse(fs.readFileSync(registry.REGISTRY_FILE, "utf8")); + disk.sandboxes.alpha.cuaTarget.target.capabilities[0].health = "unchecked"; + fs.writeFileSync(registry.REGISTRY_FILE, JSON.stringify(disk)); + + expect(() => registry.load()).toThrow("CUA lifecycle record does not match its schema"); + }); +}); diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index f7809ad28ee..34edce9a47e 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -149,6 +149,10 @@ export function registerSandbox(entry: SandboxEntry): void { // cannot inherit a stale finalized marker. See #4621. agent: entry.agent || null, agentVersion: entry.agentVersion || null, + cuaRuntimeReadiness: entry.cuaRuntimeReadiness + ? structuredClone(entry.cuaRuntimeReadiness) + : undefined, + cuaTarget: entry.cuaTarget ? structuredClone(entry.cuaTarget) : undefined, openclawImagePluginInstalls: Array.isArray(entry.openclawImagePluginInstalls) ? entry.openclawImagePluginInstalls.map((install) => ({ ...install, diff --git a/src/lib/state/registry/persistence.ts b/src/lib/state/registry/persistence.ts index 7566520f816..be37a4e3f28 100644 --- a/src/lib/state/registry/persistence.ts +++ b/src/lib/state/registry/persistence.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { isObjectRecord } from "../../core/json-types"; import { GATEWAY_PORT } from "../../core/ports"; +import { parseCuaRuntimeReadiness, parseCuaTargetAttachment } from "../../cua/schema"; import { readConfigFile, writeConfigFile } from "../config-io"; import { normalizeExtraProviders } from "../extra-providers"; import { normalizeSandboxMcpState, serializeSandboxMcpStateForDisk } from "../registry-mcp"; @@ -88,11 +89,19 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { const baselineExclusionTransition = normalizeBaselineExclusionTransition( entry.baselineExclusionTransition, ); + const cuaRuntimeReadiness = + entry.cuaRuntimeReadiness === undefined + ? undefined + : parseCuaRuntimeReadiness(entry.cuaRuntimeReadiness); + const cuaTarget = + entry.cuaTarget === undefined ? undefined : parseCuaTargetAttachment(entry.cuaTarget); const { messaging: _messaging, mcp: _mcp, baselineExclusions: _baselineExclusions, baselineExclusionTransition: _baselineExclusionTransition, + cuaRuntimeReadiness: _cuaRuntimeReadiness, + cuaTarget: _cuaTarget, ...rest } = entry; return { @@ -101,6 +110,8 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { ...(mcp ? { mcp } : {}), ...(baselineExclusions ? { baselineExclusions } : {}), ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), + ...(cuaRuntimeReadiness ? { cuaRuntimeReadiness } : {}), + ...(cuaTarget ? { cuaTarget } : {}), }; } @@ -130,11 +141,19 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { const baselineExclusionTransition = normalizeBaselineExclusionTransition( durable.baselineExclusionTransition, ); + const cuaRuntimeReadiness = + durable.cuaRuntimeReadiness === undefined + ? undefined + : parseCuaRuntimeReadiness(durable.cuaRuntimeReadiness); + const cuaTarget = + durable.cuaTarget === undefined ? undefined : parseCuaTargetAttachment(durable.cuaTarget); const { messaging: _messaging, mcp: _mcp, baselineExclusions: _baselineExclusions, baselineExclusionTransition: _baselineExclusionTransition, + cuaRuntimeReadiness: _cuaRuntimeReadiness, + cuaTarget: _cuaTarget, ...rest } = durable; return { @@ -144,5 +163,7 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { ...(mcp ? { mcp } : {}), ...(baselineExclusions ? { baselineExclusions } : {}), ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), + ...(cuaRuntimeReadiness ? { cuaRuntimeReadiness } : {}), + ...(cuaTarget ? { cuaTarget } : {}), }; } diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index 97b35871987..ba0734952dd 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, CuaTargetAttachment } from "../../cua/contract"; import type { InferenceSelection } from "../../inference/selection"; import type { WebSearchProvider } from "../../inference/web-search"; import type { DcodeAutoApprovalMode } from "../../onboard/dcode-auto-approval"; @@ -107,6 +108,10 @@ export interface SandboxEntry extends Partial { webSearchProvider?: WebSearchProvider | null; agent?: string | null; agentVersion?: string | null; + /** Verified CUA runtime contract recorded by canonical onboarding. */ + cuaRuntimeReadiness?: CuaRuntimeReadiness; + /** Secret-free projection of the one attached disposable desktop target. */ + cuaTarget?: CuaTargetAttachment; /** 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/cua-target-cli.test.ts b/test/cua-target-cli.test.ts new file mode 100644 index 00000000000..77c3d5e0ae7 --- /dev/null +++ b/test/cua-target-cli.test.ts @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const CLI = path.join(ROOT, "bin", "nemoclaw.js"); +const temporaryDirectories: string[] = []; +const digest = (value: string): string => `sha256:${value.repeat(64).slice(0, 64)}`; +const component = (name: string, value: string) => ({ + name, + version: "1.0.0", + digest: digest(value), + owner: "fixture", +}); + +function fixture(): { + home: string; + adapterPath: string; + manifestPath: string; + registryPath: string; +} { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cua-target-cli-")); + temporaryDirectories.push(home); + const stateDirectory = path.join(home, ".nemoclaw"); + fs.mkdirSync(stateDirectory, { recursive: true, mode: 0o700 }); + const registryPath = path.join(stateDirectory, "sandboxes.json"); + const runtimeReadiness = { + schemaVersion: "1.0.0", + kind: "runtime-readiness", + mode: "standalone", + status: "available", + components: { + runtime: component("cua-fixture", "1"), + sandboxImage: component("sandbox-fixture", "2"), + policy: component("policy-fixture", "3"), + taskProtocol: component("task-fixture", "4"), + }, + inference: { provider: "fixture", model: "fixture-model" }, + commands: { interactive: true, headless: true, version: true, smoke: true }, + limits: { targetsPerWorker: 1, activeTasksPerTarget: 1 }, + requiredCapabilities: ["browser", "computer", "terminal"], + targetOperations: [ + "target.attach", + "target.status", + "target.health", + "target.detach", + "target.reset", + "target.destroy", + ], + taskOperations: [ + "task.start", + "task.status", + "task.result", + "task.events", + "task.logs", + "task.plans", + "task.cancel", + ], + }; + fs.writeFileSync( + registryPath, + JSON.stringify({ + defaultSandbox: "alpha", + sandboxes: { alpha: { name: "alpha", cuaRuntimeReadiness: runtimeReadiness } }, + }), + { mode: 0o600 }, + ); + + const manifest = { + schemaVersion: "1.0.0", + kind: "target-manifest", + identityDigest: digest("5"), + platform: "fixture-linux-amd64", + image: component("desktop-fixture", "6"), + serviceBundle: component("service-fixture", "7"), + capabilities: [ + { id: "browser", protocolVersion: "1.0.0" }, + { id: "computer", protocolVersion: "1.0.0" }, + { id: "terminal", protocolVersion: "1.0.0" }, + ], + }; + const manifestPath = path.join(home, "target-manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); + + const adapterPath = path.join(home, "target-adapter.mjs"); + fs.writeFileSync( + adapterPath, + `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +const request = JSON.parse(Buffer.concat(chunks).toString("utf8")); +const targetStatePath = path.join(process.env.HOME, ".cua-target-fixture-state.json"); +const detached = { + schemaVersion: "1.0.0", + kind: "target-attachment", + status: "detached", + target: null, + activeTask: null, +}; +if (request.operation === "target.detach" || request.operation === "target.destroy") { + if (request.operation === "target.destroy") { + fs.rmSync(targetStatePath, { force: true }); + } else { + fs.writeFileSync(targetStatePath, JSON.stringify({ reachable: false })); + } + process.stdout.write(JSON.stringify(detached)); + process.exit(0); +} +const source = request.manifest ?? request.current.target; +if (request.operation === "target.attach" || request.operation === "target.reset") { + fs.writeFileSync(targetStatePath, JSON.stringify({ + reachable: true, + browserProfile: "clean", + fixtureState: "seeded", + })); +} +const identityDigest = + request.operation === "target.reset" + ? "${digest("8")}" + : source.identityDigest; +process.stdout.write(JSON.stringify({ + schemaVersion: "1.0.0", + kind: "target-attachment", + status: "attached", + target: { + identityDigest, + platform: source.platform, + image: source.image, + serviceBundle: source.serviceBundle, + capabilities: source.capabilities.map((capability) => ({ + id: capability.id, + protocolVersion: capability.protocolVersion, + health: "healthy", + })), + }, + activeTask: null, +})); +`, + { mode: 0o700 }, + ); + return { home, adapterPath, manifestPath, registryPath }; +} + +function run(home: string, args: string[]) { + return spawnSync(process.execPath, [CLI, ...args], { + cwd: ROOT, + encoding: "utf8", + env: { ...process.env, HOME: home }, + }); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("public CUA target commands (#7751)", () => { + it("attach, inspect, reset, and detach through one synthetic host adapter", () => { + const { home, adapterPath, manifestPath, registryPath } = fixture(); + const attach = run(home, [ + "sandbox", + "cua", + "target", + "attach", + "alpha", + "--adapter", + adapterPath, + "--target-manifest", + manifestPath, + "--json", + ]); + expect(attach.status, attach.stderr).toBe(0); + expect(JSON.parse(attach.stdout)).toMatchObject({ + kind: "target-attachment", + status: "attached", + target: { identityDigest: digest("5") }, + }); + + const status = run(home, ["sandbox", "cua", "target", "status", "alpha", "--json"]); + expect(status.status, status.stderr).toBe(0); + expect(JSON.parse(status.stdout)).toEqual(JSON.parse(attach.stdout)); + + const conflict = run(home, [ + "sandbox", + "cua", + "target", + "attach", + "alpha", + "--adapter", + adapterPath, + "--target-manifest", + manifestPath, + "--json", + ]); + expect(conflict.status).toBe(3); + expect(JSON.parse(conflict.stdout)).toMatchObject({ + kind: "failure", + family: "target_conflict", + }); + + fs.writeFileSync( + path.join(home, ".cua-target-fixture-state.json"), + JSON.stringify({ + reachable: true, + browserProfile: "mutated", + fixtureState: "changed", + }), + ); + const reset = run(home, [ + "sandbox", + "cua", + "target", + "reset", + "alpha", + "--adapter", + adapterPath, + "--json", + ]); + expect(reset.status, reset.stderr).toBe(0); + expect(JSON.parse(reset.stdout).target.identityDigest).toBe(digest("8")); + expect( + JSON.parse(fs.readFileSync(path.join(home, ".cua-target-fixture-state.json"), "utf8")), + ).toEqual({ + reachable: true, + browserProfile: "clean", + fixtureState: "seeded", + }); + + const detach = run(home, [ + "sandbox", + "cua", + "target", + "detach", + "alpha", + "--adapter", + adapterPath, + "--json", + ]); + expect(detach.status, detach.stderr).toBe(0); + expect(JSON.parse(detach.stdout)).toMatchObject({ status: "detached", target: null }); + expect( + JSON.parse(fs.readFileSync(path.join(home, ".cua-target-fixture-state.json"), "utf8")), + ).toEqual({ reachable: false }); + + const persisted = fs.readFileSync(registryPath, "utf8"); + expect(persisted).not.toContain(adapterPath); + expect(persisted).not.toContain(manifestPath); + }); +}); diff --git a/test/package-contract/cli/command-registry.test.ts b/test/package-contract/cli/command-registry.test.ts index a1f19adff2c..18a5849612b 100644 --- a/test/package-contract/cli/command-registry.test.ts +++ b/test/package-contract/cli/command-registry.test.ts @@ -56,17 +56,18 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 60 entries", () => { - // 54 visible + 8 hidden (shields×3 + config get/set/rotate-token + + it("returns exactly 68 entries", () => { + // 60 visible + 8 hidden (shields×3 + config get/set/rotate-token + // inference get/set). - // 54 visible includes the sessions group (root + list + reset + delete + + // 60 visible includes the sessions group (root + list + reset + delete + // export), the agents quartet (add + apply + delete + list), the // singular `agent` passthrough that forwards to `openclaw agent`, the // download + upload host-side openshell wrappers, the stop + start // container lifecycle pair (#6026), the policy baseline exclude + restore // pair, plus five MCP bridge display entries under the `mcp` parent and - // the gateway restart command under the `gateway` parent. - expect(sandboxCommands()).toHaveLength(62); + // the gateway restart command under the `gateway` parent, and six CUA + // target lifecycle commands. + expect(sandboxCommands()).toHaveLength(68); }); it("every entry has scope sandbox", () => { @@ -226,14 +227,15 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 31 unique action tokens including empty string", () => { + it("returns exactly 32 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(31); + expect(tokens).toHaveLength(32); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "agent", "agents", "connect", + "cua", "dashboard-url", "download", "exec",