mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-29 03:13:37 +00:00
fix(nodes): surface pending reapproval diagnostics (#92547)
* fix(nodes): surface pending reapproval diagnostics * fix(nodes): harden reapproval diagnostics * fix(nodes): scope pending diagnostics * fix(nodes): request pairing diagnostics in cli * fix(nodes): reuse stored auth for diagnostics * fix(nodes): preserve selected diagnostics credentials * fix(nodes): prefer approved diagnostics auth * fix(nodes): narrow diagnostics fallbacks * fix(nodes): recover from stale diagnostics auth * fix(gateway): preserve connect error narrowing * fix(nodes): isolate privileged diagnostics auth * fix(nodes): constrain privileged diagnostics auth * fix(nodes): close diagnostics review gaps * fix(nodes): guard reapproval cleanup races * fix(nodes): defer stale pairing cleanup * fix(nodes): preserve reapproval on hello failure * test(nodes): await post-handshake reapproval cleanup * test(nodes): avoid unbound websocket send capture * fix(nodes): allow local auth-none diagnostics * fix(nodes): preserve overlapping reapproval * fix(nodes): preserve pending node metadata * fix(nodes): keep connection age with status * fix(nodes): preserve reapproval during reconnect races * fix(nodes): serialize reapproval cleanup * fix(nodes): bound reapproval reconnect races * test(nodes): satisfy cleanup claim lint --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -14,6 +14,9 @@ type GatewayCallRequest = {
|
||||
mode?: string;
|
||||
params?: unknown;
|
||||
scopes?: unknown;
|
||||
useStoredDeviceAuth?: boolean;
|
||||
requiredStoredDeviceAuthScopes?: unknown;
|
||||
requireLocalBackendSharedAuth?: boolean;
|
||||
};
|
||||
|
||||
function formatRuntimeLogCallArg(value: unknown): string {
|
||||
@@ -410,6 +413,48 @@ describe("cli program (nodes basics)", () => {
|
||||
"canvas",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "pending first node approval",
|
||||
node: {
|
||||
nodeId: "pending-node",
|
||||
displayName: "Pending Node",
|
||||
caps: [],
|
||||
commands: [],
|
||||
approvalState: "pending-approval",
|
||||
pendingRequestId: "request-approval",
|
||||
pendingDeclaredCaps: ["system"],
|
||||
pendingDeclaredCommands: ["system.run"],
|
||||
paired: true,
|
||||
connected: true,
|
||||
},
|
||||
expectedOutput: [
|
||||
"Pending Node",
|
||||
"approval pending",
|
||||
"Approval pending for Pending Node",
|
||||
"openclaw nodes approve request-approval",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "pending node reapproval",
|
||||
node: {
|
||||
nodeId: "pending-reapproval-node",
|
||||
displayName: "Pending Reapproval Node",
|
||||
caps: ["camera"],
|
||||
commands: ["camera.snap"],
|
||||
approvalState: "pending-reapproval",
|
||||
pendingRequestId: "request-reapproval",
|
||||
pendingDeclaredCaps: ["camera", "system"],
|
||||
pendingDeclaredCommands: ["camera.snap", "system.run"],
|
||||
paired: true,
|
||||
connected: true,
|
||||
},
|
||||
expectedOutput: [
|
||||
"Pending Reapproval Node",
|
||||
"reapproval pending",
|
||||
"Reapproval pending for Pending Reapproval Node",
|
||||
"openclaw nodes approve request-reapproval",
|
||||
],
|
||||
},
|
||||
])("runs nodes status and renders $label", async ({ node, expectedOutput }) => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
@@ -423,6 +468,30 @@ describe("cli program (nodes basics)", () => {
|
||||
for (const expected of expectedOutput) {
|
||||
expect(output).toContain(expected);
|
||||
}
|
||||
expect(
|
||||
gatewayRequests().find((request) => request.method === "node.list")?.useStoredDeviceAuth,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps connection age adjacent to connection status before pending approval", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "pending-reapproval-node",
|
||||
displayName: "Pending Reapproval Node",
|
||||
approvalState: "pending-reapproval",
|
||||
pendingRequestId: "request-reapproval",
|
||||
paired: true,
|
||||
connected: true,
|
||||
connectedAtMs: Date.now() - 60_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runProgram(["nodes", "status"]);
|
||||
|
||||
expect(getRuntimeOutput()).toMatch(/connected \([^)]* ago\) · reapproval pending/);
|
||||
});
|
||||
|
||||
it("runs nodes describe and calls node.describe", async () => {
|
||||
@@ -430,8 +499,13 @@ describe("cli program (nodes basics)", () => {
|
||||
ts: Date.now(),
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
caps: ["canvas", "camera"],
|
||||
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
|
||||
caps: ["camera"],
|
||||
commands: ["camera.snap"],
|
||||
approvalState: "pending-reapproval",
|
||||
pendingRequestId: "request-approval",
|
||||
pendingDeclaredCaps: ["camera", "canvas"],
|
||||
pendingDeclaredCommands: ["camera.snap", "canvas.eval\u001b[2K", "canvas.snapshot"],
|
||||
pendingDeclaredPermissions: { camera: true },
|
||||
connected: true,
|
||||
});
|
||||
|
||||
@@ -444,10 +518,345 @@ describe("cli program (nodes basics)", () => {
|
||||
);
|
||||
expect(describeRequest?.clientName).toBe("cli");
|
||||
expect(describeRequest?.mode).toBe("cli");
|
||||
expect(describeRequest?.useStoredDeviceAuth).toBe(true);
|
||||
|
||||
const out = getRuntimeOutput();
|
||||
expect(out).toContain("Commands");
|
||||
expect(out).toContain("camera.snap");
|
||||
expect(out).toContain("Approval");
|
||||
expect(out).toContain("reapproval pending");
|
||||
expect(out).toContain("Pending request");
|
||||
expect(out).toContain("request-approval");
|
||||
expect(out).toContain("Pending caps");
|
||||
expect(out).toContain("canvas");
|
||||
expect(out).toContain("Pending commands");
|
||||
expect(out).toContain("canvas.eval");
|
||||
expect(out).toContain("openclaw nodes approve request-approval");
|
||||
expect(out).not.toContain("\u001b");
|
||||
expect(out).not.toContain("[2K");
|
||||
});
|
||||
|
||||
it("keeps explicit gateway options in node reapproval guidance without leaking auth", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "pending-node",
|
||||
displayName: "Pending Node",
|
||||
approvalState: "pending-reapproval",
|
||||
pendingRequestId: "request-reapproval",
|
||||
paired: true,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runProgram([
|
||||
"nodes",
|
||||
"status",
|
||||
"--url",
|
||||
"ws://gateway-user:url-secret@gateway.example:18789/openclaw?cluster=qa",
|
||||
"--timeout",
|
||||
"3000",
|
||||
"--token",
|
||||
"secret-token",
|
||||
]);
|
||||
|
||||
const output = getRuntimeOutput();
|
||||
expect(output).toContain("openclaw nodes approve request-reapproval --timeout 3000");
|
||||
expect(output).toContain("Reuse the same --url/--token options when rerunning.");
|
||||
expect(output).not.toContain("gateway-user");
|
||||
expect(output).not.toContain("url-secret");
|
||||
expect(output).not.toContain("gateway.example");
|
||||
expect(output).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("falls back to read-only node status when pairing diagnostics are unavailable", async () => {
|
||||
callGateway.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 () => {
|
||||
callGateway.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 () => {
|
||||
callGateway.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 () => {
|
||||
callGateway.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 () => {
|
||||
callGateway.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("missing scope: operator.read"), {
|
||||
name: "GatewayClientRequestError",
|
||||
gatewayCode: "INVALID_REQUEST",
|
||||
});
|
||||
}
|
||||
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 () => {
|
||||
callGateway.mockImplementation(async (...args: unknown[]) => {
|
||||
const opts = (args[0] ?? {}) as {
|
||||
method?: string;
|
||||
params?: { nodeId?: string };
|
||||
useStoredDeviceAuth?: boolean;
|
||||
};
|
||||
if (opts.method === "node.list") {
|
||||
return opts.useStoredDeviceAuth
|
||||
? {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "pending-only-node",
|
||||
displayName: "Pending Only Node",
|
||||
approvalState: "pending-approval",
|
||||
pendingRequestId: "pending-only-request",
|
||||
paired: false,
|
||||
connected: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
: { nodes: [] };
|
||||
}
|
||||
if (opts.method === "node.describe" && opts.params?.nodeId === "pending-only-node") {
|
||||
return {
|
||||
nodeId: "pending-only-node",
|
||||
displayName: "Pending Only Node",
|
||||
approvalState: "pending-approval",
|
||||
pendingRequestId: "pending-only-request",
|
||||
paired: false,
|
||||
connected: false,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await runProgram(["nodes", "describe", "--node", "pending-only-node"]);
|
||||
|
||||
const describeRequest = gatewayRequests().find((request) => request.method === "node.describe");
|
||||
expect(describeRequest?.params).toEqual({ nodeId: "pending-only-node" });
|
||||
expect(describeRequest?.useStoredDeviceAuth).toBe(true);
|
||||
expect(getRuntimeOutput()).toContain("pending-only-request");
|
||||
});
|
||||
|
||||
it("describes nodes through the paired-node fallback on older gateways", async () => {
|
||||
callGateway.mockImplementation(async (...args: unknown[]) => {
|
||||
const opts = (args[0] ?? {}) as {
|
||||
method?: string;
|
||||
params?: { nodeId?: string };
|
||||
};
|
||||
if (opts.method === "node.list") {
|
||||
throw Object.assign(new Error("unknown method: node.list"), {
|
||||
name: "GatewayClientRequestError",
|
||||
gatewayCode: "INVALID_REQUEST",
|
||||
});
|
||||
}
|
||||
if (opts.method === "node.pair.list") {
|
||||
return {
|
||||
pending: [],
|
||||
paired: [{ nodeId: "legacy-node", displayName: "Legacy Node" }],
|
||||
};
|
||||
}
|
||||
if (opts.method === "node.describe" && opts.params?.nodeId === "legacy-node") {
|
||||
return {
|
||||
nodeId: "legacy-node",
|
||||
displayName: "Legacy Node",
|
||||
paired: true,
|
||||
connected: false,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await runProgram(["nodes", "describe", "--node", "legacy-node"]);
|
||||
|
||||
expectGatewayRequest("node.pair.list", {});
|
||||
expectGatewayRequest("node.describe", { nodeId: "legacy-node" });
|
||||
expect(getRuntimeOutput()).toContain("Legacy Node");
|
||||
});
|
||||
|
||||
it("does not recommend approval from a stale pending request id alone", async () => {
|
||||
mockGatewayWithIosNodeListAnd("node.describe", {
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS Node",
|
||||
approvalState: "approved",
|
||||
pendingRequestId: "stale-request",
|
||||
connected: true,
|
||||
});
|
||||
|
||||
await runProgram(["nodes", "describe", "--node", "ios-node", "--token", "secret-token"]);
|
||||
|
||||
const output = getRuntimeOutput();
|
||||
expect(output).toContain("stale-request");
|
||||
expect(output).not.toContain("openclaw nodes approve stale-request");
|
||||
expect(output).not.toContain("Reuse the same --token option when rerunning.");
|
||||
expect(output).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("runs nodes approve with the pending request approval scopes", async () => {
|
||||
|
||||
Reference in New Issue
Block a user