diff --git a/docs/nodes/computer-use.md b/docs/nodes/computer-use.md index def9961cd258..81fc8014b4f1 100644 --- a/docs/nodes/computer-use.md +++ b/docs/nodes/computer-use.md @@ -90,7 +90,9 @@ scripts/dev/computer-use-macos-live-rig.sh prepare \ Run the emitted `gateway` and `app` commands in separate terminals. The split config is intentional: the externally launched daemon reads a scratch config with `gateway.mode: "local"`, while the app profile reads `gateway.mode: "remote"`, direct transport, and the daemon's loopback URL. If the app reads local mode, its Port Guardian owns the route instead of joining the external daemon. The rig keeps its validated launch fields in non-executable `rig.json`; later commands reject unknown fields or paths that do not match the scratch/profile layout. It also seeds a dedicated `node` identity, completed onboarding, unpaused state, Computer Control, and the checkout path used to start the debug node worker. There is no separate node-mode toggle. -In a third terminal, run the emitted `nodes` command. A fresh CLI identity first returns a device-approval request; approve that request from the isolated app's Devices settings or with `openclaw --profile cu-live-proof devices approve `, then rerun `nodes` until the paired entry is connected and advertises `computer.act` plus a `computerUse` descriptor. +In a third terminal, rerun the emitted `nodes` command until the paired entry is connected and advertises `computer.act` plus a `computerUse` descriptor. No operator-device approval step is involved: the loopback gateway silently pairs the rig's CLI identity on its first connect, and the proof runner is admitted as a local backend client without pairing at all. The rig keeps those two identities in separate state directories (`cli-state` and `agent-state`) because a paired operator device is pinned to the scopes of its first connect, and a CLI pairing would otherwise cap the proof client below `operator.write`. + +If the node's command surface is still pending approval, take `.pending[0].requestId` from that `nodes` output and run `scripts/dev/computer-use-macos-live-rig.sh approve "$scratch" `. Place a harmless editable fixture window behind a different frontmost app, then run the vertical: diff --git a/scripts/dev/computer-use-macos-live-rig.sh b/scripts/dev/computer-use-macos-live-rig.sh index 95da11554169..af24476562a1 100755 --- a/scripts/dev/computer-use-macos-live-rig.sh +++ b/scripts/dev/computer-use-macos-live-rig.sh @@ -183,7 +183,7 @@ prepare() { fail "$defaults_domain already has saved settings; choose a fresh proof profile" fi [[ ! -e "$scratch/rig.json" ]] || fail "$scratch already contains a rig" - mkdir -p "$scratch" "$scratch/agent-state" + mkdir -p "$scratch" "$scratch/agent-state" "$scratch/cli-state" local app_config="$app_state/openclaw.json" local staged_app_config="$scratch/app.json" @@ -263,7 +263,7 @@ prepare_linux() { fail "runtime sources are staged but uncommitted; commit and rebuild first" [[ ! -e "$scratch/rig.json" ]] || fail "$scratch already contains a rig" - mkdir -p "$scratch/agent-state" "$scratch/gateway-state" "$scratch/node-state" + mkdir -p "$scratch/agent-state" "$scratch/cli-state" "$scratch/gateway-state" "$scratch/node-state" local gateway_config="$scratch/gateway.json" local node_config="$scratch/node.json" local gateway_token @@ -399,10 +399,15 @@ run_fixture() { run_nodes() { [[ $# -eq 1 ]] || { usage; exit 2; } - load_rig "$1" + local scratch="$1" + load_rig "$scratch" + # A paired operator device is pinned to the scopes of its first connect, and + # later widening needs an approval this rig cannot reach. The CLI therefore + # keeps its own identity here; the proof client stays in agent-state, where it + # is admitted unpaired as a local backend and keeps operator.write. exec env \ OPENCLAW_CONFIG_PATH="$OPENCLAW_CU_RIG_GATEWAY_CONFIG" \ - OPENCLAW_STATE_DIR="$OPENCLAW_CU_RIG_AGENT_STATE" \ + OPENCLAW_STATE_DIR="$scratch/cli-state" \ node "$repo_root/scripts/run-node.mjs" nodes list --json } diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index ba5ae5877ab6..b32ce5b7cffc 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -231,7 +231,7 @@ function mergePairedNodesWithEffectiveNodes( async function tryReadNodeList(opts: NodesRpcOpts): Promise { try { - return parseNodeList(await callNodesGatewayCli("node.list", opts, {})); + return parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})); } catch { return null; } @@ -527,7 +527,7 @@ export function registerNodesStatusCommands(nodes: Command) { // them under --connected printed "Pending: 0" while requests waited. const pendingRows = pending; const effectiveNodes = hasFilters - ? parseNodeList(await callNodesGatewayCli("node.list", opts, {})) + ? parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})) : await tryReadNodeList(opts); const effectivePairedRows = mergePairedNodesWithEffectiveNodes(paired, effectiveNodes); const filteredPaired = effectivePairedRows.filter((node) => { diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index f2e0f4c877fa..e33c672c1c85 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -1,7 +1,10 @@ // Program nodes basic e2e tests cover node command registration through the full CLI program. import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createIosNodeListResponse } from "./program.nodes-test-helpers.js"; +import { + createIosNodeListResponse, + formatRuntimeLogCallArg, +} from "./program.nodes-test-helpers.js"; import { programGatewayCallMock, runtime } from "./program.test-mocks.js"; let registerNodesCli: typeof import("./nodes-cli.js").registerNodesCli; @@ -17,23 +20,6 @@ type GatewayCallRequest = { requireLocalBackendSharedAuth?: boolean; }; -function formatRuntimeLogCallArg(value: unknown): string { - if (typeof value === "string") { - return value; - } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return String(value); - } - if (value == null) { - return ""; - } - try { - return JSON.stringify(value); - } catch { - return "[unserializable]"; - } -} - describe("cli program (nodes basics)", () => { let program: Command; @@ -644,201 +630,6 @@ describe("cli program (nodes basics)", () => { expect(output).not.toContain("secret-token"); }); - it("falls back to read-only node status when pairing diagnostics are unavailable", async () => { - programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { - const opts = (args[0] ?? {}) as { - method?: string; - scopes?: string[]; - useStoredDeviceAuth?: boolean; - }; - if (opts.method === "node.list" && opts.useStoredDeviceAuth) { - throw Object.assign(new Error("stored device auth unavailable"), { - name: "GatewayCredentialsRequiredError", - }); - } - if (opts.method === "node.list" && opts.scopes?.includes("operator.pairing")) { - throw Object.assign(new Error("unauthorized: pairing scope unavailable"), { - name: "GatewayClientRequestError", - gatewayCode: "INVALID_REQUEST", - details: { code: "AUTH_SCOPE_MISMATCH" }, - }); - } - if (opts.method === "node.list") { - return { - ts: Date.now(), - nodes: [ - { - nodeId: "read-only-node", - displayName: "Read Only Node", - approvalState: "approved", - paired: true, - connected: false, - }, - ], - }; - } - return { ok: true }; - }); - - await runProgram(["nodes", "status"]); - - const requests = gatewayRequests().filter((request) => request.method === "node.list"); - expect(requests).toHaveLength(3); - expect(requests[0]?.useStoredDeviceAuth).toBe(true); - expect(requests[0]?.requiredStoredDeviceAuthScopes).toEqual([ - "operator.read", - "operator.pairing", - ]); - expect(requests[1]?.scopes).toEqual(["operator.read", "operator.pairing"]); - expect(requests[1]?.clientName).toBe("gateway-client"); - expect(requests[1]?.mode).toBe("backend"); - expect(requests[1]?.requireLocalBackendSharedAuth).toBe(true); - expect(requests[2]?.useStoredDeviceAuth).toBeUndefined(); - expect(requests[2]?.scopes).toBeUndefined(); - expect(getRuntimeOutput()).toContain("Read Only Node"); - }); - - it("keeps remote explicit diagnostic credentials on the read-only path", async () => { - programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { - const opts = (args[0] ?? {}) as { - method?: string; - requireLocalBackendSharedAuth?: boolean; - useStoredDeviceAuth?: boolean; - }; - if (opts.method === "node.list" && opts.useStoredDeviceAuth) { - throw Object.assign(new Error("stored device auth disabled for explicit credentials"), { - name: "GatewayStoredDeviceAuthUnavailableError", - }); - } - if (opts.method === "node.list" && opts.requireLocalBackendSharedAuth) { - throw Object.assign(new Error("local backend shared auth unavailable for remote target"), { - name: "GatewayLocalBackendSharedAuthUnavailableError", - }); - } - return { - nodes: [ - { - nodeId: "remote-read-only-node", - displayName: "Remote Read Only Node", - paired: true, - connected: false, - }, - ], - }; - }); - - await runProgram([ - "nodes", - "status", - "--url", - "wss://gateway.example.test", - "--token", - "explicit-token", - ]); - - const requests = gatewayRequests().filter((request) => request.method === "node.list"); - expect(requests).toHaveLength(3); - expect(requests[0]?.useStoredDeviceAuth).toBe(true); - expect(requests[0]?.requiredStoredDeviceAuthScopes).toEqual([ - "operator.read", - "operator.pairing", - ]); - expect(requests[1]?.scopes).toEqual(["operator.read", "operator.pairing"]); - expect(requests[1]?.clientName).toBe("gateway-client"); - expect(requests[1]?.mode).toBe("backend"); - expect(requests[1]?.requireLocalBackendSharedAuth).toBe(true); - expect(requests[2]?.scopes).toBeUndefined(); - expect(getRuntimeOutput()).toContain("Remote Read Only Node"); - }); - - it("does not retry node diagnostics after a transport failure", async () => { - programGatewayCallMock.mockRejectedValue(new Error("gateway timed out")); - - await expect(runProgram(["nodes", "status"])).rejects.toThrow("exit"); - - const requests = gatewayRequests().filter((request) => request.method === "node.list"); - expect(requests).toHaveLength(1); - expect(requests[0]?.useStoredDeviceAuth).toBe(true); - }); - - it("falls back to configured auth after stored device auth is rejected", async () => { - programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { - const opts = (args[0] ?? {}) as { method?: string; useStoredDeviceAuth?: boolean }; - if (opts.method === "node.list" && opts.useStoredDeviceAuth) { - throw Object.assign(new Error("unauthorized: device token mismatch"), { - name: "GatewayClientRequestError", - gatewayCode: "INVALID_REQUEST", - details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" }, - }); - } - if (opts.method === "node.list") { - return { - nodes: [ - { - nodeId: "configured-auth-node", - displayName: "Configured Auth Node", - paired: true, - connected: false, - }, - ], - }; - } - return { ok: true }; - }); - - await runProgram(["nodes", "status"]); - - const requests = gatewayRequests().filter((request) => request.method === "node.list"); - expect(requests).toHaveLength(2); - expect(requests[0]?.useStoredDeviceAuth).toBe(true); - expect(requests[1]?.useStoredDeviceAuth).toBeUndefined(); - expect(getRuntimeOutput()).toContain("Configured Auth Node"); - }); - - it("falls back to configured auth when stored device auth lacks read scope", async () => { - programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { - const opts = (args[0] ?? {}) as { - method?: string; - scopes?: string[]; - useStoredDeviceAuth?: boolean; - }; - if (opts.method === "node.list" && opts.useStoredDeviceAuth) { - throw Object.assign(new Error("permission denied"), { - name: "GatewayClientRequestError", - gatewayCode: "FORBIDDEN", - details: { - code: "MISSING_SCOPE", - missingScope: "operator.read", - requiredScopes: ["operator.read"], - }, - }); - } - if (opts.method === "node.list" && opts.scopes?.includes("operator.pairing")) { - return { - nodes: [ - { - nodeId: "shared-auth-node", - displayName: "Shared Auth Node", - paired: true, - connected: false, - }, - ], - }; - } - return { nodes: [] }; - }); - - await runProgram(["nodes", "status"]); - - const requests = gatewayRequests().filter((request) => request.method === "node.list"); - expect(requests).toHaveLength(2); - expect(requests[1]?.scopes).toEqual(["operator.read", "operator.pairing"]); - expect(requests[1]?.clientName).toBe("gateway-client"); - expect(requests[1]?.mode).toBe("backend"); - expect(requests[1]?.requireLocalBackendSharedAuth).toBe(true); - expect(getRuntimeOutput()).toContain("Shared Auth Node"); - }); - it("describes pending-only nodes through the pairing diagnostics view", async () => { programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { const opts = (args[0] ?? {}) as { diff --git a/src/cli/program.nodes-diagnostics-auth.e2e.test.ts b/src/cli/program.nodes-diagnostics-auth.e2e.test.ts new file mode 100644 index 000000000000..478c0df71c7d --- /dev/null +++ b/src/cli/program.nodes-diagnostics-auth.e2e.test.ts @@ -0,0 +1,278 @@ +// Program nodes diagnostics-auth e2e tests cover how node reads authenticate through the CLI program. +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { formatRuntimeLogCallArg } from "./program.nodes-test-helpers.js"; +import { programGatewayCallMock, runtime } from "./program.test-mocks.js"; + +let registerNodesCli: typeof import("./nodes-cli.js").registerNodesCli; + +type GatewayCallRequest = { + clientName?: string; + method?: string; + mode?: string; + scopes?: unknown; + useStoredDeviceAuth?: boolean; + requiredStoredDeviceAuthScopes?: unknown; + requireLocalBackendSharedAuth?: boolean; +}; + +describe("cli program (nodes diagnostics auth)", () => { + let program: Command; + + async function runProgram(argv: string[]) { + runtime.log.mockClear(); + await program.parseAsync(argv, { from: "user" }); + } + + function getRuntimeOutput() { + return runtime.log.mock.calls.map((call) => formatRuntimeLogCallArg(call[0])).join("\n"); + } + + function gatewayRequests(): GatewayCallRequest[] { + return programGatewayCallMock.mock.calls.map(([request]) => request as GatewayCallRequest); + } + + beforeEach(async () => { + vi.clearAllMocks(); + ({ registerNodesCli } = await import("./nodes-cli.js")); + program = new Command(); + program.exitOverride(); + await registerNodesCli(program); + }); + + it("falls back to read-only node status when pairing diagnostics are unavailable", async () => { + programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { + method?: string; + scopes?: string[]; + useStoredDeviceAuth?: boolean; + }; + if (opts.method === "node.list" && opts.useStoredDeviceAuth) { + throw Object.assign(new Error("stored device auth unavailable"), { + name: "GatewayCredentialsRequiredError", + }); + } + if (opts.method === "node.list" && opts.scopes?.includes("operator.pairing")) { + throw Object.assign(new Error("unauthorized: pairing scope unavailable"), { + name: "GatewayClientRequestError", + gatewayCode: "INVALID_REQUEST", + details: { code: "AUTH_SCOPE_MISMATCH" }, + }); + } + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "read-only-node", + displayName: "Read Only Node", + approvalState: "approved", + paired: true, + connected: false, + }, + ], + }; + } + return { ok: true }; + }); + + await runProgram(["nodes", "status"]); + + const requests = gatewayRequests().filter((request) => request.method === "node.list"); + expect(requests).toHaveLength(3); + expect(requests[0]?.useStoredDeviceAuth).toBe(true); + expect(requests[0]?.requiredStoredDeviceAuthScopes).toEqual([ + "operator.read", + "operator.pairing", + ]); + expect(requests[1]?.scopes).toEqual(["operator.read", "operator.pairing"]); + expect(requests[1]?.clientName).toBe("gateway-client"); + expect(requests[1]?.mode).toBe("backend"); + expect(requests[1]?.requireLocalBackendSharedAuth).toBe(true); + expect(requests[2]?.useStoredDeviceAuth).toBeUndefined(); + expect(requests[2]?.scopes).toBeUndefined(); + expect(getRuntimeOutput()).toContain("Read Only Node"); + }); + + it("keeps remote explicit diagnostic credentials on the read-only path", async () => { + programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { + method?: string; + requireLocalBackendSharedAuth?: boolean; + useStoredDeviceAuth?: boolean; + }; + if (opts.method === "node.list" && opts.useStoredDeviceAuth) { + throw Object.assign(new Error("stored device auth disabled for explicit credentials"), { + name: "GatewayStoredDeviceAuthUnavailableError", + }); + } + if (opts.method === "node.list" && opts.requireLocalBackendSharedAuth) { + throw Object.assign(new Error("local backend shared auth unavailable for remote target"), { + name: "GatewayLocalBackendSharedAuthUnavailableError", + }); + } + return { + nodes: [ + { + nodeId: "remote-read-only-node", + displayName: "Remote Read Only Node", + paired: true, + connected: false, + }, + ], + }; + }); + + await runProgram([ + "nodes", + "status", + "--url", + "wss://gateway.example.test", + "--token", + "explicit-token", + ]); + + const requests = gatewayRequests().filter((request) => request.method === "node.list"); + expect(requests).toHaveLength(3); + expect(requests[0]?.useStoredDeviceAuth).toBe(true); + expect(requests[0]?.requiredStoredDeviceAuthScopes).toEqual([ + "operator.read", + "operator.pairing", + ]); + expect(requests[1]?.scopes).toEqual(["operator.read", "operator.pairing"]); + expect(requests[1]?.clientName).toBe("gateway-client"); + expect(requests[1]?.mode).toBe("backend"); + expect(requests[1]?.requireLocalBackendSharedAuth).toBe(true); + expect(requests[2]?.scopes).toBeUndefined(); + expect(getRuntimeOutput()).toContain("Remote Read Only Node"); + }); + + it("does not retry node diagnostics after a transport failure", async () => { + programGatewayCallMock.mockRejectedValue(new Error("gateway timed out")); + + await expect(runProgram(["nodes", "status"])).rejects.toThrow("exit"); + + const requests = gatewayRequests().filter((request) => request.method === "node.list"); + expect(requests).toHaveLength(1); + expect(requests[0]?.useStoredDeviceAuth).toBe(true); + }); + + it("falls back to configured auth after stored device auth is rejected", async () => { + programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string; useStoredDeviceAuth?: boolean }; + if (opts.method === "node.list" && opts.useStoredDeviceAuth) { + throw Object.assign(new Error("unauthorized: device token mismatch"), { + name: "GatewayClientRequestError", + gatewayCode: "INVALID_REQUEST", + details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" }, + }); + } + if (opts.method === "node.list") { + return { + nodes: [ + { + nodeId: "configured-auth-node", + displayName: "Configured Auth Node", + paired: true, + connected: false, + }, + ], + }; + } + return { ok: true }; + }); + + await runProgram(["nodes", "status"]); + + const requests = gatewayRequests().filter((request) => request.method === "node.list"); + expect(requests).toHaveLength(2); + expect(requests[0]?.useStoredDeviceAuth).toBe(true); + expect(requests[1]?.useStoredDeviceAuth).toBeUndefined(); + expect(getRuntimeOutput()).toContain("Configured Auth Node"); + }); + + it("falls back to configured auth when stored device auth lacks read scope", async () => { + programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { + method?: string; + scopes?: string[]; + useStoredDeviceAuth?: boolean; + }; + if (opts.method === "node.list" && opts.useStoredDeviceAuth) { + throw Object.assign(new Error("permission denied"), { + name: "GatewayClientRequestError", + gatewayCode: "FORBIDDEN", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.read", + requiredScopes: ["operator.read"], + }, + }); + } + if (opts.method === "node.list" && opts.scopes?.includes("operator.pairing")) { + return { + nodes: [ + { + nodeId: "shared-auth-node", + displayName: "Shared Auth Node", + paired: true, + connected: false, + }, + ], + }; + } + return { nodes: [] }; + }); + + await runProgram(["nodes", "status"]); + + const requests = gatewayRequests().filter((request) => request.method === "node.list"); + expect(requests).toHaveLength(2); + expect(requests[1]?.scopes).toEqual(["operator.read", "operator.pairing"]); + expect(requests[1]?.clientName).toBe("gateway-client"); + expect(requests[1]?.mode).toBe("backend"); + expect(requests[1]?.requireLocalBackendSharedAuth).toBe(true); + expect(getRuntimeOutput()).toContain("Shared Auth Node"); + }); + + it.each([ + { label: "unfiltered", argv: ["nodes", "list"] }, + { label: "filtered", argv: ["nodes", "list", "--connected"] }, + ])("reads the $label nodes list through node diagnostics auth", async ({ argv }) => { + programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { + method?: string; + requireLocalBackendSharedAuth?: boolean; + useStoredDeviceAuth?: boolean; + }; + if (opts.method === "node.pair.list") { + return { pending: [], paired: [{ nodeId: "live-node", displayName: "Stale paired name" }] }; + } + if (opts.method === "node.list" && opts.useStoredDeviceAuth) { + throw Object.assign(new Error("stored device auth unavailable"), { + name: "GatewayCredentialsRequiredError", + }); + } + if (opts.method === "node.list" && opts.requireLocalBackendSharedAuth) { + return { nodes: [{ nodeId: "live-node", displayName: "Live Node", connected: true }] }; + } + if (opts.method === "node.list") { + // A plain CLI read is capped by the operator device pairing scopes. + throw Object.assign(new Error("scope upgrade pending approval"), { + name: "GatewayClientRequestError", + gatewayCode: "NOT_PAIRED", + details: { code: "PAIRING_REQUIRED" }, + }); + } + return { ok: true }; + }); + + await runProgram(argv); + + const listRequest = gatewayRequests().find( + (request) => request.method === "node.list" && request.requireLocalBackendSharedAuth === true, + ); + expect(listRequest?.scopes).toEqual(["operator.read", "operator.pairing"]); + expect(getRuntimeOutput()).toContain("Live Node"); + }); +}); diff --git a/src/cli/program.nodes-test-helpers.ts b/src/cli/program.nodes-test-helpers.ts index 24c96dda281e..5a7f3279ccd9 100644 --- a/src/cli/program.nodes-test-helpers.ts +++ b/src/cli/program.nodes-test-helpers.ts @@ -14,3 +14,21 @@ export function createIosNodeListResponse(ts: number = Date.now()) { nodes: [IOS_NODE], }; } + +/** Render one captured runtime.log argument without stringifying objects as [object Object]. */ +export function formatRuntimeLogCallArg(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + if (value == null) { + return ""; + } + try { + return JSON.stringify(value); + } catch { + return "[unserializable]"; + } +}