mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
committed by
GitHub
parent
d86593fa28
commit
b07c6b2b8b
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user