fix(computer-use): macOS live-proof rig deadlocks on operator device approval (#124536)

* fix(computer-use): unblock the macOS live-rig proof flow

The rig ran its operator CLI and its proof runner from one state dir, so both
shared one device identity. A paired operator device is pinned to the scopes of
its first connect, and `nodes list` connects first for `node.pair.list`
(operator.pairing); the proof runner then needs operator.write, which is a scope
upgrade the gateway never approves silently and which no rig client can approve
for itself. The proof runner is a GATEWAY_CLIENT/BACKEND client, so on a
loopback auth-none gateway it is admitted unpaired with the scopes it asks for:
giving the CLI its own `cli-state` identity is enough, and `agent-state` now
never accumulates a pairing row.

`nodes list` also read `node.list` through the plain CLI client while
`nodes status`/`describe` used the diagnostics ladder. On any gateway where the
CLI must pair, the unfiltered list silently dropped connected/commands/
computerUse and `--connected` failed outright, so the documented rig gate could
not confirm the node. Both call sites now use `callNodeDiagnosticsGatewayCli`.

Docs drop the `devices approve <requestId>` instruction, which was circular:
that invocation is its own new device identity.

* test(cli): share the runtime-log formatter across nodes CLI e2e files

The extracted diagnostics-auth file stringified captured log arguments directly, which the type-aware core lint stripe rejects (no-base-to-string). Move the existing formatter into the shared node test helpers instead of duplicating it.
This commit is contained in:
Peter Steinberger
2026-08-16 04:25:03 -07:00
committed by GitHub
parent d86593fa28
commit b07c6b2b8b
6 changed files with 314 additions and 220 deletions
+4 -213
View File
@@ -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 {