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:
Jason (Json)
2026-06-13 17:40:18 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 4c3c0ff5f9
commit 65b460f234
26 changed files with 3041 additions and 117 deletions
+139 -9
View File
@@ -11,14 +11,23 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { defaultRuntime } from "../../runtime.js";
import { shortenHomeInString } from "../../utils.js";
import { formatCliCommand } from "../command-format.js";
import { parseDurationMs } from "../parse-duration.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { renderPendingPairingRequestsTable } from "./pairing-render.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import {
callGatewayCli,
callNodeDiagnosticsGatewayCli,
nodesCallOpts,
resolveNodeDiagnosticsId,
} from "./rpc.js";
import type { NodeListNode, NodesRpcOpts, PairedNode } from "./types.js";
type PairedNodeListRow = PairedNode & Partial<NodeListNode>;
type NodeApprovalState = NonNullable<NodeListNode["approvalState"]>;
const DEFAULT_NODES_RPC_TIMEOUT_MS = 10_000;
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
@@ -98,6 +107,61 @@ function formatNodeTerminalLabel(node: { nodeId: string; displayName?: string })
return sanitizeTerminalText(label);
}
function formatNodeApprovalState(raw: unknown): NodeApprovalState | null {
return raw === "approved" ||
raw === "pending-approval" ||
raw === "pending-reapproval" ||
raw === "unapproved"
? raw
: null;
}
function formatApprovalStateLabel(state: NodeApprovalState): string {
if (state === "pending-approval") {
return "approval pending";
}
if (state === "pending-reapproval") {
return "reapproval pending";
}
return state;
}
function isPendingApprovalState(
state: NodeApprovalState | null,
): state is "pending-approval" | "pending-reapproval" {
return state === "pending-approval" || state === "pending-reapproval";
}
function quoteCliArg(value: string): string {
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}
function formatPendingApprovalCommand(raw: unknown, opts: NodesRpcOpts): string | null {
const requestId = normalizeOptionalString(raw);
if (!requestId) {
return null;
}
const args = ["openclaw", "nodes", "approve", requestId];
const timeout = normalizeOptionalString(opts.timeout);
if (timeout && timeout !== String(DEFAULT_NODES_RPC_TIMEOUT_MS)) {
args.push("--timeout", timeout);
}
return formatCliCommand(args.map(quoteCliArg).join(" "));
}
function formatConnectionFlagReminder(opts: NodesRpcOpts): string | null {
const flags = [
normalizeOptionalString(opts.url) ? "--url" : null,
normalizeOptionalString(opts.token) ? "--token" : null,
].filter((flag) => flag !== null);
return flags.length > 0
? `Reuse the same ${flags.join("/")} option${flags.length === 1 ? "" : "s"} when rerunning.`
: null;
}
function parseSinceMs(raw: unknown, label: string): number | undefined {
if (raw === undefined || raw === null) {
return undefined;
@@ -194,7 +258,7 @@ export function registerNodesStatusCommands(nodes: Command) {
await runNodesCommand("status", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = await callGatewayCli("node.list", opts, {});
const result = await callNodeDiagnosticsGatewayCli("node.list", opts, {});
const obj: Record<string, unknown> =
typeof result === "object" && result !== null ? result : {};
const { ok, warn, muted } = getNodesTheme();
@@ -268,6 +332,15 @@ export function registerNodesStatusCommands(nodes: Command) {
: "?";
const paired = n.paired ? ok("paired") : warn("unpaired");
const connected = n.connected ? ok("connected") : muted("disconnected");
const approvalState = formatNodeApprovalState(n.approvalState);
const approval =
approvalState === "approved"
? ok("approved")
: isPendingApprovalState(approvalState)
? warn(formatApprovalStateLabel(approvalState))
: approvalState === "unapproved"
? warn("unapproved")
: null;
const since =
typeof n.connectedAtMs === "number"
? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})`
@@ -278,7 +351,7 @@ export function registerNodesStatusCommands(nodes: Command) {
ID: sanitizeTerminalText(n.nodeId),
IP: sanitizeTerminalText(n.remoteIp ?? ""),
Detail: detailParts.join(" · "),
Status: `${paired} · ${connected}${since}`,
Status: `${paired} · ${connected}${since}${approval ? ` · ${approval}` : ""}`,
Caps: caps,
};
});
@@ -297,6 +370,22 @@ export function registerNodesStatusCommands(nodes: Command) {
rows,
}).trimEnd(),
);
for (const node of filtered) {
const approvalState = formatNodeApprovalState(node.approvalState);
const approveCommand = formatPendingApprovalCommand(node.pendingRequestId, opts);
if (isPendingApprovalState(approvalState) && approveCommand) {
const action = approvalState === "pending-reapproval" ? "Reapproval" : "Approval";
defaultRuntime.log(
warn(
`${action} pending for ${formatNodeTerminalLabel(node)}. Run ${sanitizeTerminalText(approveCommand)}`,
),
);
const connectionReminder = formatConnectionFlagReminder(opts);
if (connectionReminder) {
defaultRuntime.log(warn(connectionReminder));
}
}
}
});
}),
);
@@ -308,8 +397,8 @@ export function registerNodesStatusCommands(nodes: Command) {
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("describe", async () => {
const nodeId = await resolveNodeId(opts, opts.node ?? "");
const result = await callGatewayCli("node.describe", opts, {
const nodeId = await resolveNodeDiagnosticsId(opts, opts.node ?? "");
const result = await callNodeDiagnosticsGatewayCli("node.describe", opts, {
nodeId,
});
if (opts.json) {
@@ -329,6 +418,19 @@ export function registerNodesStatusCommands(nodes: Command) {
? obj.commands.map(String).filter(Boolean).toSorted()
: [];
const perms = formatPermissions(obj.permissions);
const approvalState = formatNodeApprovalState(obj.approvalState);
const pendingRequestId = normalizeOptionalString(obj.pendingRequestId);
const pendingCaps = Array.isArray(obj.pendingDeclaredCaps)
? obj.pendingDeclaredCaps.map(String).filter(Boolean).toSorted()
: null;
const pendingCommands = Array.isArray(obj.pendingDeclaredCommands)
? obj.pendingDeclaredCommands.map(String).filter(Boolean).toSorted()
: [];
const pendingPerms = formatPermissions(obj.pendingDeclaredPermissions);
const approveCommand = isPendingApprovalState(approvalState)
? formatPendingApprovalCommand(pendingRequestId, opts)
: null;
const connectionReminder = approveCommand ? formatConnectionFlagReminder(opts) : null;
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const client = formatClientLabel(obj as { clientId?: string; clientMode?: string });
@@ -359,6 +461,27 @@ export function registerNodesStatusCommands(nodes: Command) {
versions ? { Field: "Version", Value: sanitizeTerminalText(versions) } : null,
pathEnv ? { Field: "PATH", Value: sanitizeTerminalText(pathEnv) } : null,
{ Field: "Status", Value: status },
approvalState
? { Field: "Approval", Value: formatApprovalStateLabel(approvalState) }
: null,
pendingRequestId
? { Field: "Pending request", Value: sanitizeTerminalText(pendingRequestId) }
: null,
pendingCaps
? { Field: "Pending caps", Value: sanitizeTerminalText(pendingCaps.join(", ")) }
: null,
pendingPerms
? { Field: "Pending perms", Value: sanitizeTerminalText(pendingPerms) }
: null,
approveCommand
? {
Field: approvalState === "pending-reapproval" ? "Reapprove" : "Approve",
Value: sanitizeTerminalText(approveCommand),
}
: null,
approveCommand && connectionReminder
? { Field: "Connection reminder", Value: connectionReminder }
: null,
{ Field: "Caps", Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?" },
].filter(Boolean) as Array<{ Field: string; Value: string }>;
@@ -376,11 +499,18 @@ export function registerNodesStatusCommands(nodes: Command) {
defaultRuntime.log("");
defaultRuntime.log(heading("Commands"));
if (commands.length === 0) {
defaultRuntime.log(muted("- (none reported)"));
return;
defaultRuntime.log(muted("- (none effective)"));
} else {
for (const c of commands) {
defaultRuntime.log(`- ${c}`);
}
}
for (const c of commands) {
defaultRuntime.log(`- ${c}`);
if (pendingCommands.length > 0) {
defaultRuntime.log("");
defaultRuntime.log(heading("Pending commands"));
for (const command of pendingCommands) {
defaultRuntime.log(`- ${sanitizeTerminalText(command)}`);
}
}
});
}),
+17 -3
View File
@@ -20,7 +20,13 @@ export async function callGatewayCliRuntime(
method: string,
opts: NodesRpcOpts,
params?: unknown,
callOpts?: { transportTimeoutMs?: number },
callOpts?: {
scopes?: OperatorScope[];
transportTimeoutMs?: number;
useStoredDeviceAuth?: boolean;
requiredStoredDeviceAuthScopes?: OperatorScope[];
useLocalBackendSharedAuth?: boolean;
},
) {
// Progress is suppressed for JSON callers so stdout remains structured.
return await withProgress(
@@ -35,9 +41,17 @@ export async function callGatewayCliRuntime(
token: opts.token,
method,
params,
scopes: callOpts?.scopes,
useStoredDeviceAuth: callOpts?.useStoredDeviceAuth,
requiredStoredDeviceAuthScopes: callOpts?.requiredStoredDeviceAuthScopes,
requireLocalBackendSharedAuth: callOpts?.useLocalBackendSharedAuth,
timeoutMs: resolveNodesTransportTimeoutMs(opts, callOpts?.transportTimeoutMs),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
clientName: callOpts?.useLocalBackendSharedAuth
? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT
: GATEWAY_CLIENT_NAMES.CLI,
mode: callOpts?.useLocalBackendSharedAuth
? GATEWAY_CLIENT_MODES.BACKEND
: GATEWAY_CLIENT_MODES.CLI,
}),
);
}
+100 -1
View File
@@ -23,6 +23,57 @@ async function loadNodesCliRpcRuntime(): Promise<NodesCliRpcRuntimeModule> {
return nodesCliRpcRuntimeLoader.load();
}
const STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES = new Set([
"AUTH_REQUIRED",
"AUTH_UNAUTHORIZED",
"AUTH_TOKEN_MISMATCH",
"AUTH_DEVICE_TOKEN_MISMATCH",
"AUTH_SCOPE_MISMATCH",
"PAIRING_REQUIRED",
]);
function readGatewayClientRequestDetailCode(value: unknown): string | null {
if (!(value instanceof Error) || value.name !== "GatewayClientRequestError") {
return null;
}
const details = (value as Error & { details?: unknown }).details;
if (!details || typeof details !== "object") {
return null;
}
const code = (details as { code?: unknown }).code;
return typeof code === "string" ? code : null;
}
function isDiagnosticsAuthFallbackError(value: unknown): value is Error {
if (
value instanceof Error &&
(value.name === "GatewayCredentialsRequiredError" ||
value.name === "GatewayStoredDeviceAuthUnavailableError" ||
value.name === "GatewayLocalBackendSharedAuthUnavailableError")
) {
return true;
}
const detailCode = readGatewayClientRequestDetailCode(value);
if (detailCode !== null && STORED_DEVICE_AUTH_FALLBACK_DETAIL_CODES.has(detailCode)) {
return true;
}
return (
value instanceof Error &&
value.name === "GatewayClientRequestError" &&
(value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" &&
value.message.includes("missing scope: operator.read")
);
}
function isUnknownGatewayMethodError(value: unknown, method: string): value is Error {
return (
value instanceof Error &&
value.name === "GatewayClientRequestError" &&
(value as Error & { gatewayCode?: unknown }).gatewayCode === "INVALID_REQUEST" &&
value.message.includes(`unknown method: ${method}`)
);
}
/** Attach shared Gateway connection/json options to a node command. */
export const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd
@@ -36,12 +87,47 @@ export const callGatewayCli = async (
method: string,
opts: NodesRpcOpts,
params?: unknown,
callOpts?: { transportTimeoutMs?: number },
callOpts?: {
scopes?: OperatorScope[];
transportTimeoutMs?: number;
useStoredDeviceAuth?: boolean;
requiredStoredDeviceAuthScopes?: OperatorScope[];
useLocalBackendSharedAuth?: boolean;
},
) => {
const runtime = await loadNodesCliRpcRuntime();
return await runtime.callGatewayCliRuntime(method, opts, params, callOpts);
};
/** Read node diagnostics with pairing details when authorized, otherwise keep read-only access. */
export const callNodeDiagnosticsGatewayCli = async (
method: "node.list" | "node.describe",
opts: NodesRpcOpts,
params?: unknown,
) => {
try {
return await callGatewayCli(method, opts, params, {
useStoredDeviceAuth: true,
requiredStoredDeviceAuthScopes: ["operator.read", "operator.pairing"],
});
} catch (error) {
if (!isDiagnosticsAuthFallbackError(error)) {
throw error;
}
}
try {
return await callGatewayCli(method, opts, params, {
scopes: ["operator.read", "operator.pairing"],
useLocalBackendSharedAuth: true,
});
} catch (error) {
if (!isDiagnosticsAuthFallbackError(error)) {
throw error;
}
}
return await callGatewayCli(method, opts, params);
};
/** Call pairing approval methods with explicit operator scopes. */
export const callNodePairApprovalGatewayCli = async (
method: "node.pair.list" | "node.pair.approve",
@@ -155,6 +241,19 @@ export async function resolveNodeId(opts: NodesRpcOpts, query: string) {
return (await resolveNode(opts, query)).nodeId;
}
/** Resolve a node through the pairing-aware diagnostics view when available. */
export async function resolveNodeDiagnosticsId(opts: NodesRpcOpts, query: string) {
try {
const res = await callNodeDiagnosticsGatewayCli("node.list", opts, {});
return resolveNodeFromNodeList(parseNodeList(res), query).nodeId;
} catch (error) {
if (!isUnknownGatewayMethodError(error, "node.list")) {
throw error;
}
return await resolveNodeId(opts, query);
}
}
/** Resolve a node query to the best available node record. */
export async function resolveNode(opts: NodesRpcOpts, query: string): Promise<NodeListNode> {
let nodes: NodeListNode[];
+411 -2
View File
@@ -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 () => {
+8
View File
@@ -6,6 +6,7 @@ import {
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH,
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
buildRateLimitIdentityKey,
createAuthRateLimiter,
type AuthRateLimiter,
} from "./auth-rate-limit.js";
@@ -200,6 +201,13 @@ describe("auth rate limiter", () => {
expect(limiter.check("127.0.0.1").allowed).toBe(false);
});
it("does not exempt opaque identity keys", () => {
limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 });
const key = buildRateLimitIdentityKey("node", "node-1");
limiter.recordFailure(key);
expect(limiter.check(key).allowed).toBe(false);
});
// ---------- reset ----------
it("clears tracking state when reset is called", () => {
+14 -1
View File
@@ -43,6 +43,9 @@ export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token";
// The request path enters the node-pairing storage lock, so bursts must be
// throttled before they queue behind that lock and delay operator actions.
export const AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING = "node-pairing";
// Paired-node approval-surface changes use a dedicated limiter so reconnect
// storms cannot queue unbounded writes behind the shared pairing-state lock.
export const AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL = "node-reapproval";
// Per-IP gate for the pre-auth bootstrap-token verify path.
// `verifyDeviceBootstrapToken` is `withLock`-serialized in
// `device-bootstrap.ts` and runs fs read + fs write on every attempt;
@@ -52,6 +55,7 @@ export const AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING = "node-pairing";
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
const IDENTITY_RATE_LIMIT_KEY_PREFIX = "identity:";
interface RateLimitEntry {
/** Timestamps (epoch ms) of recent failed attempts inside the window. */
@@ -102,12 +106,21 @@ const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute
* share one representation (including IPv4-mapped IPv6 forms).
*/
export function normalizeRateLimitClientIp(ip: string | undefined): string {
if (typeof ip === "string" && ip.startsWith(BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX)) {
if (
typeof ip === "string" &&
(ip.startsWith(BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX) ||
ip.startsWith(IDENTITY_RATE_LIMIT_KEY_PREFIX))
) {
return ip;
}
return resolveClientIp({ remoteAddr: ip }) ?? "unknown";
}
/** Build an opaque limiter identity that is not subject to loopback IP exemptions. */
export function buildRateLimitIdentityKey(namespace: string, identity: string): string {
return `${IDENTITY_RATE_LIMIT_KEY_PREFIX}${namespace}:${identity}`;
}
function resolvePruneIntervalMs(value: number | undefined): number {
if (value === undefined) {
return PRUNE_INTERVAL_MS;
+232 -2
View File
@@ -94,6 +94,7 @@ let startCalls = 0;
let closeCode = 1006;
let closeReason = "";
let helloMethods: string[] | undefined = ["health", "secrets.resolve"];
let connectError: Error | null = null;
vi.mock("./client.js", () => ({
describeGatewayCloseCode: (code: number) => {
@@ -147,7 +148,7 @@ vi.mock("./client.js", () => ({
});
} else if (startMode === "connect-error") {
lastClientOptions?.onConnectError?.(
connectAssemblyErrorState.create("device private key invalid"),
connectError ?? connectAssemblyErrorState.create("device private key invalid"),
);
} else if (startMode === "close") {
lastClientOptions?.onClose?.(closeCode, closeReason);
@@ -223,7 +224,7 @@ class StubGatewayClient {
});
} else if (startMode === "connect-error") {
lastClientOptions?.onConnectError?.(
connectAssemblyErrorState.create("device private key invalid"),
connectError ?? connectAssemblyErrorState.create("device private key invalid"),
);
} else if (startMode === "close") {
lastClientOptions?.onClose?.(closeCode, closeReason);
@@ -254,6 +255,7 @@ function resetGatewayCallMocks() {
closeCode = 1006;
closeReason = "";
helloMethods = ["health", "secrets.resolve"];
connectError = null;
const loadConfigForTests = getRuntimeConfig as unknown as () => OpenClawConfig;
const resolveGatewayPortForTests = resolveGatewayPort as unknown as (
cfg?: OpenClawConfig,
@@ -746,6 +748,210 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.scopes).toStrictEqual([]);
});
it("reuses stored device auth without requesting stronger scopes", async () => {
setLocalLoopbackGatewayConfig();
loadDeviceAuthTokenMock.mockReturnValue({
token: "paired-device-token",
role: "operator",
scopes: ["operator.read", "operator.pairing"],
updatedAtMs: 123,
});
await callGatewayCli({ method: "node.list", useStoredDeviceAuth: true });
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBeUndefined();
expect(lastClientOptions?.scopes).toBeUndefined();
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
});
it("does not replace explicit credentials with stored device auth", async () => {
setLocalLoopbackGatewayConfig();
await expect(
callGatewayCli({
method: "node.list",
token: "explicit-token",
useStoredDeviceAuth: true,
}),
).rejects.toMatchObject({ name: "GatewayStoredDeviceAuthUnavailableError" });
expect(lastClientOptions).toBeNull();
});
it("prefers stored device auth over configured local credentials", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: "configured-token" },
},
});
setGatewayNetworkDefaults();
await callGatewayCli({ method: "node.list", useStoredDeviceAuth: true });
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.scopes).toBeUndefined();
});
it("does not resolve configured local SecretRefs when using stored device auth", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig);
setGatewayNetworkDefaults();
await callGatewayCli({ method: "node.list", useStoredDeviceAuth: true });
expect(lastClientOptions?.password).toBeUndefined();
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
});
it("rejects stored device auth that lacks caller-required scopes", async () => {
setLocalLoopbackGatewayConfig();
loadDeviceAuthTokenMock.mockReturnValue({
token: "paired-device-token",
role: "operator",
scopes: ["operator.read"],
updatedAtMs: 123,
});
await expect(
callGatewayCli({
method: "node.list",
useStoredDeviceAuth: true,
requiredStoredDeviceAuthScopes: ["operator.read", "operator.pairing"],
}),
).rejects.toMatchObject({ name: "GatewayStoredDeviceAuthUnavailableError" });
expect(lastClientOptions).toBeNull();
});
it("does not send stored device auth to configured remote gateways", async () => {
getRuntimeConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-password"));
setGatewayNetworkDefaults();
await expect(
callGatewayCli({ method: "node.list", useStoredDeviceAuth: true }),
).rejects.toMatchObject({ name: "GatewayStoredDeviceAuthUnavailableError" });
expect(lastClientOptions).toBeNull();
});
it("fails before connecting when stored device auth is unavailable", async () => {
getRuntimeConfig.mockReturnValue({
gateway: { mode: "local", bind: "loopback", auth: { mode: "none" } },
});
setGatewayNetworkDefaults();
loadDeviceAuthTokenMock.mockReturnValue(null);
await expect(
callGatewayCli({ method: "node.list", useStoredDeviceAuth: true }),
).rejects.toThrow("requires credentials before opening a websocket");
expect(lastClientOptions).toBeNull();
expect(startCalls).toBe(0);
});
it("uses local backend shared auth without a device identity when required", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({
method: "node.list",
token: "explicit-token",
scopes: ["operator.read", "operator.pairing"],
requireLocalBackendSharedAuth: true,
});
expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT);
expect(lastClientOptions?.mode).toBe(GATEWAY_CLIENT_MODES.BACKEND);
expect(lastClientOptions?.scopes).toEqual(["operator.read", "operator.pairing"]);
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("uses local backend auth-none without a device identity when required", async () => {
getRuntimeConfig.mockReturnValue({
gateway: { mode: "local", bind: "loopback", auth: { mode: "none" } },
});
setGatewayNetworkDefaults();
await callGateway({
method: "node.list",
scopes: ["operator.read", "operator.pairing"],
requireLocalBackendSharedAuth: true,
});
expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT);
expect(lastClientOptions?.mode).toBe(GATEWAY_CLIENT_MODES.BACKEND);
expect(lastClientOptions?.scopes).toEqual(["operator.read", "operator.pairing"]);
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBeUndefined();
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("rejects required local backend shared auth for remote targets", async () => {
await expect(
callGateway({
method: "node.list",
url: "wss://remote.example.test",
token: "explicit-token",
scopes: ["operator.read", "operator.pairing"],
requireLocalBackendSharedAuth: true,
}),
).rejects.toMatchObject({ name: "GatewayLocalBackendSharedAuthUnavailableError" });
expect(lastClientOptions).toBeNull();
});
it("rejects required local backend shared auth for loopback URL overrides", async () => {
await expect(
callGateway({
method: "node.list",
url: "ws://127.0.0.1:18789",
token: "explicit-token",
scopes: ["operator.read", "operator.pairing"],
requireLocalBackendSharedAuth: true,
}),
).rejects.toMatchObject({ name: "GatewayLocalBackendSharedAuthUnavailableError" });
expect(lastClientOptions).toBeNull();
});
it("rejects required local backend shared auth for remote-mode loopback tunnels", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "remote",
remote: {
url: "ws://127.0.0.1:18789",
token: "remote-token",
},
},
});
setGatewayNetworkDefaults();
await expect(
callGateway({
method: "node.list",
scopes: ["operator.read", "operator.pairing"],
requireLocalBackendSharedAuth: true,
}),
).rejects.toMatchObject({ name: "GatewayLocalBackendSharedAuthUnavailableError" });
expect(lastClientOptions).toBeNull();
});
it("uses backend client metadata for explicit scoped default calls", async () => {
setLocalLoopbackGatewayConfig();
@@ -1169,6 +1375,30 @@ describe("callGateway error details", () => {
expect(lastRequestOptions).toBeNull();
});
it("surfaces stored device auth handshake failures for credential fallback", async () => {
startMode = "connect-error";
connectError = Object.assign(new Error("unauthorized: device token mismatch"), {
name: "GatewayClientRequestError",
gatewayCode: "INVALID_REQUEST",
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
});
setLocalLoopbackGatewayConfig();
vi.useFakeTimers();
const promise = callGatewayCli({
method: "node.list",
timeoutMs: 5,
useStoredDeviceAuth: true,
});
const rejection = expect(promise).rejects.toMatchObject({
name: "GatewayClientRequestError",
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
});
await vi.advanceTimersByTimeAsync(5);
await rejection;
});
it("includes connection details on timeout", async () => {
startMode = "silent";
setLocalLoopbackGatewayConfig();
+109 -20
View File
@@ -24,6 +24,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadDeviceAuthToken } from "../infra/device-auth-store.js";
import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import type { DeviceAuthEntry } from "../shared/device-auth.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
import { VERSION } from "../version.js";
import { resolveGatewayAuth } from "./auth-resolve.js";
@@ -82,6 +84,9 @@ type CallGatewayBaseOptions = {
platform?: string;
mode?: GatewayClientMode;
approvalRuntimeToken?: string;
useStoredDeviceAuth?: boolean;
requiredStoredDeviceAuthScopes?: OperatorScope[];
requireLocalBackendSharedAuth?: boolean;
deviceIdentity?: DeviceIdentity | null;
instanceId?: string;
minProtocol?: number;
@@ -164,6 +169,20 @@ export class GatewayExplicitAuthRequiredError extends Error {
}
}
export class GatewayStoredDeviceAuthUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "GatewayStoredDeviceAuthUnavailableError";
}
}
export class GatewayLocalBackendSharedAuthUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "GatewayLocalBackendSharedAuthUnavailableError";
}
}
export type GatewayTransportErrorJson = {
ok: false;
error: {
@@ -414,14 +433,16 @@ function shouldOmitDeviceIdentityForGatewayCall(params: {
url: string;
token?: string;
password?: string;
allowAuthNone?: boolean;
}): boolean {
const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;
const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI;
const hasSharedAuth = Boolean(params.token || params.password);
const hasDirectLocalBackendAuth =
Boolean(params.token || params.password) || params.allowAuthNone === true;
return (
mode === GATEWAY_CLIENT_MODES.BACKEND &&
clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT &&
hasSharedAuth &&
hasDirectLocalBackendAuth &&
isLoopbackGatewayUrl(params.url)
);
}
@@ -444,23 +465,27 @@ function resolveDeviceIdentityForGatewayCall(params: {
}
}
function hasStoredOperatorDeviceAuthToken(deviceIdentity: DeviceIdentity | null): boolean {
function loadStoredOperatorDeviceAuthToken(
deviceIdentity: DeviceIdentity | null,
): DeviceAuthEntry | null {
if (!deviceIdentity) {
return false;
return null;
}
try {
return Boolean(
gatewayCallDeps.loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: "operator",
env: process.env,
})?.token,
);
return gatewayCallDeps.loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: "operator",
env: process.env,
});
} catch {
return false;
return null;
}
}
function hasStoredOperatorDeviceAuthToken(deviceIdentity: DeviceIdentity | null): boolean {
return Boolean(loadStoredOperatorDeviceAuthToken(deviceIdentity)?.token);
}
function resolveGatewayCallAuth(config: OpenClawConfig) {
return resolveGatewayAuth({
authConfig: config.gateway?.auth,
@@ -807,7 +832,7 @@ function ensureGatewaySupportsRequiredMethods(params: {
async function executeGatewayRequestWithScopes<T>(params: {
opts: CallGatewayBaseOptions;
scopes: OperatorScope[];
scopes: OperatorScope[] | undefined;
url: string;
token?: string;
password?: string;
@@ -817,6 +842,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
safeTimerTimeoutMs: number;
connectionDetails: GatewayConnectionDetails;
deviceIdentity: DeviceIdentity | null;
surfaceGatewayClientRequestErrors: boolean;
}): Promise<T> {
const {
opts,
@@ -829,6 +855,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
timeoutMs,
safeTimerTimeoutMs,
deviceIdentity,
surfaceGatewayClientRequestErrors,
} = params;
return await new Promise<T>((resolve, reject) => {
if (opts.signal?.aborted) {
@@ -910,7 +937,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
...(opts.approvalRuntimeToken ? { approvalRuntimeToken: opts.approvalRuntimeToken } : {}),
role: "operator",
scopes,
...(Array.isArray(scopes) ? { scopes } : {}),
deviceIdentity,
minProtocol: opts.minProtocol ?? MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
@@ -955,7 +982,11 @@ async function executeGatewayRequestWithScopes<T>(params: {
);
},
onConnectError: (err) => {
if (settled || !isGatewayConnectAssemblyError(err)) {
const isGatewayClientRequestError = err.name === "GatewayClientRequestError";
const shouldSurface =
isGatewayConnectAssemblyError(err) ||
(surfaceGatewayClientRequestErrors && isGatewayClientRequestError);
if (settled || !shouldSurface) {
return;
}
ignoreClose = true;
@@ -1001,14 +1032,31 @@ async function executeGatewayRequestWithScopes<T>(params: {
async function callGatewayWithScopes<T = Record<string, unknown>>(
opts: CallGatewayBaseOptions,
scopes: OperatorScope[],
scopes: OperatorScope[] | undefined,
): Promise<T> {
const context = await resolveGatewayCallContext(opts);
const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout(
opts.timeoutMs,
context.config.gateway?.handshakeTimeoutMs,
);
const resolvedCredentials = await resolveGatewayCredentials(context);
if (opts.requireLocalBackendSharedAuth && (context.urlOverride || context.isRemoteMode)) {
throw new GatewayLocalBackendSharedAuthUnavailableError(
"local backend shared auth is limited to the configured local gateway",
);
}
const useStoredDeviceAuth = opts.useStoredDeviceAuth === true;
if (
useStoredDeviceAuth &&
(context.urlOverride ||
context.explicitAuth.token ||
context.explicitAuth.password ||
context.isRemoteMode)
) {
throw new GatewayStoredDeviceAuthUnavailableError(
"stored device auth is limited to the configured local gateway",
);
}
const resolvedCredentials = useStoredDeviceAuth ? {} : await resolveGatewayCredentials(context);
ensureExplicitGatewayAuth({
urlOverride: context.urlOverride,
urlOverrideSource: context.urlOverrideSource,
@@ -1026,11 +1074,50 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
});
const url = connectionDetails.url;
const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url });
const { token, password } = resolvedCredentials;
const token = useStoredDeviceAuth ? undefined : resolvedCredentials.token;
const password = useStoredDeviceAuth ? undefined : resolvedCredentials.password;
const allowAuthNone =
opts.requireLocalBackendSharedAuth === true &&
resolveGatewayCallAuth(context.config).mode === "none";
const omitDeviceIdentity = shouldOmitDeviceIdentityForGatewayCall({
opts,
url,
token,
password,
allowAuthNone,
});
if (opts.requireLocalBackendSharedAuth && !omitDeviceIdentity) {
throw new GatewayLocalBackendSharedAuthUnavailableError(
"local backend shared auth requires a loopback gateway with token/password credentials or auth mode none",
);
}
const deviceIdentity =
opts.deviceIdentity === undefined
? resolveDeviceIdentityForGatewayCall({ opts, url, token, password })
? omitDeviceIdentity
? null
: resolveDeviceIdentityForGatewayCall({ opts, url, token, password })
: opts.deviceIdentity;
if (useStoredDeviceAuth) {
const storedAuth = loadStoredOperatorDeviceAuthToken(deviceIdentity);
if (!storedAuth?.token) {
throw new GatewayCredentialsRequiredError({
method: opts.method,
configPath: context.configPath,
});
}
if (
Array.isArray(opts.requiredStoredDeviceAuthScopes) &&
!roleScopesAllow({
role: "operator",
requestedScopes: opts.requiredStoredDeviceAuthScopes,
allowedScopes: storedAuth.scopes,
})
) {
throw new GatewayStoredDeviceAuthUnavailableError(
"stored device auth does not grant the required operator scopes",
);
}
}
ensureGatewayCallCanAuthenticate({
opts,
context,
@@ -1040,7 +1127,7 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
});
return await executeGatewayRequestWithScopes<T>({
opts,
scopes,
scopes: useStoredDeviceAuth ? undefined : scopes,
url,
token,
password,
@@ -1050,6 +1137,8 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
safeTimerTimeoutMs,
connectionDetails,
deviceIdentity,
surfaceGatewayClientRequestErrors:
useStoredDeviceAuth || opts.requireLocalBackendSharedAuth === true,
});
}
+188
View File
@@ -12,6 +12,7 @@ import {
type CatalogInput = Parameters<typeof createKnownNodeCatalog>[0];
type TestPairedDevice = CatalogInput["pairedDevices"][number];
type TestPairedNode = NonNullable<CatalogInput["pairedNodes"]>[number];
type TestPendingNode = NonNullable<CatalogInput["pendingNodes"]>[number];
function pairedDevice(overrides: Partial<TestPairedDevice> = {}): TestPairedDevice {
return {
@@ -49,6 +50,19 @@ function pairedNode(overrides: Partial<TestPairedNode> = {}): TestPairedNode {
};
}
function pendingNode(overrides: Partial<TestPendingNode> = {}): TestPendingNode {
return {
requestId: "request-1",
nodeId: "mac-1",
platform: "macos",
caps: ["camera", "screen"],
commands: ["screen.snapshot", "system.run"],
permissions: { camera: true, screen: true },
ts: 200,
...overrides,
};
}
describe("gateway/node-catalog", () => {
it("filters paired nodes by active node token instead of sticky historical roles", () => {
const catalog = createKnownNodeCatalog({
@@ -233,6 +247,180 @@ describe("gateway/node-catalog", () => {
expect(node?.connected).toBe(true);
});
it("reports pending first approval without making declarations effective", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [pairedDevice({ deviceId: "new-node" })],
pairedNodes: [],
pendingNodes: [pendingNode({ nodeId: "new-node", displayName: "Pending Mac" })],
connectedNodes: [],
});
const node = getKnownNode(catalog, "new-node");
expect(node?.displayName).toBe("Mac");
expect(node?.approvalState).toBe("pending-approval");
expect(node?.pendingRequestId).toBe("request-1");
expect(node?.pendingDeclaredCaps).toEqual(["camera", "screen"]);
expect(node?.pendingDeclaredCommands).toEqual(["screen.snapshot", "system.run"]);
expect(node?.pendingDeclaredPermissions).toEqual({ camera: true, screen: true });
expect(node?.caps).toEqual([]);
expect(node?.commands).toEqual([]);
expect(node?.permissions).toBeUndefined();
});
it("uses pending request metadata as the final fallback for pending-only nodes", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [],
pairedNodes: [],
pendingNodes: [
pendingNode({
nodeId: "new-node",
clientId: "openclaw-linux",
clientMode: "node",
displayName: "Pending Node",
platform: "linux",
version: "1.2.3",
coreVersion: "1.2.4",
uiVersion: "1.2.5",
deviceFamily: "desktop",
modelIdentifier: "x86_64",
remoteIp: "100.0.0.20",
}),
],
connectedNodes: [],
});
expect(getKnownNode(catalog, "new-node")).toMatchObject({
nodeId: "new-node",
clientId: "openclaw-linux",
clientMode: "node",
displayName: "Pending Node",
platform: "linux",
version: "1.2.3",
coreVersion: "1.2.4",
uiVersion: "1.2.5",
deviceFamily: "desktop",
modelIdentifier: "x86_64",
remoteIp: "100.0.0.20",
approvalState: "pending-approval",
pendingRequestId: "request-1",
caps: [],
commands: [],
paired: false,
connected: false,
});
expect(getKnownNode(catalog, "new-node")?.permissions).toBeUndefined();
});
it("preserves pending first approval when a metadata reconnect omits permissions", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [pairedDevice({ deviceId: "new-node" })],
pairedNodes: [],
pendingNodes: [pendingNode({ nodeId: "new-node" })],
connectedNodes: [
{
nodeId: "new-node",
connId: "conn-1",
client: {} as never,
displayName: "New Node",
platform: "macos",
declaredCaps: ["camera", "screen"],
caps: [],
declaredCommands: ["screen.snapshot", "system.run"],
commands: [],
connectedAtMs: 1,
},
],
});
const node = getKnownNode(catalog, "new-node");
expect(node?.approvalState).toBe("pending-approval");
expect(node?.pendingRequestId).toBe("request-1");
expect(node?.pendingDeclaredPermissions).toEqual({ camera: true, screen: true });
expect(node?.permissions).toBeUndefined();
});
it("reports pending reapproval without making declarations effective", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [pairedDevice()],
pairedNodes: [
pairedNode({
caps: ["camera"],
commands: ["screen.snapshot"],
permissions: { camera: true },
}),
],
pendingNodes: [pendingNode()],
connectedNodes: [
{
nodeId: "mac-1",
connId: "conn-1",
client: {} as never,
displayName: "Mac",
platform: "macos",
declaredCaps: ["camera", "screen"],
caps: ["camera"],
declaredCommands: ["screen.snapshot", "system.run"],
commands: ["screen.snapshot"],
declaredPermissions: { camera: true, screen: true },
permissions: { camera: true },
connectedAtMs: 1,
},
],
});
const node = getKnownNode(catalog, "mac-1");
expect(node?.approvalState).toBe("pending-reapproval");
expect(node?.pendingRequestId).toBe("request-1");
expect(node?.pendingDeclaredCaps).toEqual(["camera", "screen"]);
expect(node?.pendingDeclaredCommands).toEqual(["screen.snapshot", "system.run"]);
expect(node?.pendingDeclaredPermissions).toEqual({ camera: true, screen: true });
expect(node?.caps).toEqual(["camera"]);
expect(node?.commands).toEqual(["screen.snapshot"]);
expect(node?.permissions).toEqual({ camera: true });
});
it("ignores a pending reapproval that no longer matches the live declaration", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [pairedDevice()],
pairedNodes: [
pairedNode({
caps: ["camera"],
commands: ["screen.snapshot"],
permissions: { camera: true },
}),
],
pendingNodes: [pendingNode()],
connectedNodes: [
{
nodeId: "mac-1",
connId: "conn-1",
client: {} as never,
displayName: "Mac",
platform: "macos",
declaredCaps: ["camera"],
caps: ["camera"],
declaredCommands: ["screen.snapshot"],
commands: ["screen.snapshot"],
declaredPermissions: { camera: true },
permissions: { camera: true },
connectedAtMs: 1,
},
],
});
const entry = getKnownNodeEntry(catalog, "mac-1");
expect(entry?.pendingNodePairing).toBeUndefined();
const node = getKnownNode(catalog, "mac-1");
expect(node?.approvalState).toBe("approved");
expect(node?.pendingRequestId).toBeUndefined();
expect(node?.pendingDeclaredCaps).toBeUndefined();
expect(node?.pendingDeclaredCommands).toBeUndefined();
expect(node?.pendingDeclaredPermissions).toBeUndefined();
expect(node?.caps).toEqual(["camera"]);
expect(node?.commands).toEqual(["screen.snapshot"]);
expect(node?.permissions).toEqual({ camera: true });
});
it("ignores malformed node capability entries instead of throwing", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [],
+117 -12
View File
@@ -3,7 +3,11 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeSortedUniqueTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { hasEffectivePairedDeviceRole, type PairedDevice } from "../infra/device-pairing.js";
import type { NodePairingPairedNode } from "../infra/node-pairing.js";
import {
sameNodeApprovalSurfaceSet,
sameNodePermissionSurface,
} from "../infra/node-pairing-surface.js";
import type { NodePairingPairedNode, NodePairingPendingRequest } from "../infra/node-pairing.js";
import type { NodeListNode } from "../shared/node-list-types.js";
import type { NodeSession } from "./node-registry.js";
@@ -38,10 +42,29 @@ type KnownNodeApprovedSource = {
lastSeenReason?: string;
};
type KnownNodePendingSource = {
requestId: string;
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
clientId?: string;
clientMode?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps: string[];
commands: string[];
permissions?: Record<string, boolean>;
};
type KnownNodeEntry = {
nodeId: string;
devicePairing?: KnownNodeDevicePairingSource;
nodePairing?: KnownNodeApprovedSource;
pendingNodePairing?: KnownNodePendingSource;
live?: NodeSession;
effective: NodeListNode;
};
@@ -89,6 +112,46 @@ function buildApprovedNodeSource(entry: NodePairingPairedNode): KnownNodeApprove
};
}
function buildPendingNodeSource(entry: NodePairingPendingRequest): KnownNodePendingSource {
return {
requestId: entry.requestId,
nodeId: entry.nodeId,
displayName: entry.displayName,
platform: entry.platform,
version: entry.version,
coreVersion: entry.coreVersion,
uiVersion: entry.uiVersion,
clientId: entry.clientId,
clientMode: entry.clientMode,
remoteIp: entry.remoteIp,
deviceFamily: entry.deviceFamily,
modelIdentifier: entry.modelIdentifier,
caps: uniqueSortedStrings(entry.caps),
commands: uniqueSortedStrings(entry.commands),
permissions: entry.permissions,
};
}
function resolveCurrentPendingNodePairing(params: {
pending?: KnownNodePendingSource;
nodePairing?: KnownNodeApprovedSource;
live?: NodeSession;
}): KnownNodePendingSource | undefined {
const { pending, nodePairing, live } = params;
if (!pending || !live) {
return pending;
}
const declaredPermissions =
!nodePairing && live.declaredPermissions === undefined
? pending.permissions
: live.declaredPermissions;
return sameNodeApprovalSurfaceSet(pending.caps, live.declaredCaps) &&
sameNodeApprovalSurfaceSet(pending.commands, live.declaredCommands) &&
sameNodePermissionSurface(pending.permissions, declaredPermissions)
? pending
: undefined;
}
function resolveEffectiveLastSeen(params: {
live?: NodeSession;
devicePairing?: KnownNodeDevicePairingSource;
@@ -127,28 +190,54 @@ function buildEffectiveKnownNode(entry: {
nodeId: string;
devicePairing?: KnownNodeDevicePairingSource;
nodePairing?: KnownNodeApprovedSource;
pendingNodePairing?: KnownNodePendingSource;
live?: NodeSession;
}): NodeListNode {
const { nodeId, devicePairing, nodePairing, live } = entry;
const { nodeId, devicePairing, nodePairing, pendingNodePairing, live } = entry;
const lastSeen = resolveEffectiveLastSeen({ live, devicePairing, nodePairing });
return {
nodeId,
displayName: live?.displayName ?? nodePairing?.displayName ?? devicePairing?.displayName,
platform: live?.platform ?? nodePairing?.platform ?? devicePairing?.platform,
version: live?.version ?? nodePairing?.version,
coreVersion: live?.coreVersion ?? nodePairing?.coreVersion,
uiVersion: live?.uiVersion ?? nodePairing?.uiVersion,
clientId: live?.clientId ?? devicePairing?.clientId,
clientMode: live?.clientMode ?? devicePairing?.clientMode,
deviceFamily: live?.deviceFamily ?? nodePairing?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? nodePairing?.modelIdentifier,
remoteIp: live?.remoteIp ?? nodePairing?.remoteIp ?? devicePairing?.remoteIp,
displayName:
live?.displayName ??
nodePairing?.displayName ??
devicePairing?.displayName ??
pendingNodePairing?.displayName,
platform:
live?.platform ??
nodePairing?.platform ??
devicePairing?.platform ??
pendingNodePairing?.platform,
version: live?.version ?? nodePairing?.version ?? pendingNodePairing?.version,
coreVersion: live?.coreVersion ?? nodePairing?.coreVersion ?? pendingNodePairing?.coreVersion,
uiVersion: live?.uiVersion ?? nodePairing?.uiVersion ?? pendingNodePairing?.uiVersion,
clientId: live?.clientId ?? devicePairing?.clientId ?? pendingNodePairing?.clientId,
clientMode: live?.clientMode ?? devicePairing?.clientMode ?? pendingNodePairing?.clientMode,
deviceFamily:
live?.deviceFamily ?? nodePairing?.deviceFamily ?? pendingNodePairing?.deviceFamily,
modelIdentifier:
live?.modelIdentifier ?? nodePairing?.modelIdentifier ?? pendingNodePairing?.modelIdentifier,
remoteIp:
live?.remoteIp ??
nodePairing?.remoteIp ??
devicePairing?.remoteIp ??
pendingNodePairing?.remoteIp,
caps: live ? uniqueSortedStrings(live.caps) : uniqueSortedStrings(nodePairing?.caps),
commands: live
? uniqueSortedStrings(live.commands)
: uniqueSortedStrings(nodePairing?.commands),
pathEnv: live?.pathEnv,
permissions: live?.permissions ?? nodePairing?.permissions,
approvalState: pendingNodePairing
? nodePairing
? "pending-reapproval"
: "pending-approval"
: nodePairing
? "approved"
: "unapproved",
pendingRequestId: pendingNodePairing?.requestId,
pendingDeclaredCaps: pendingNodePairing?.caps,
pendingDeclaredCommands: pendingNodePairing?.commands,
pendingDeclaredPermissions: pendingNodePairing?.permissions,
connectedAtMs: live?.connectedAtMs,
lastSeenAtMs: lastSeen.lastSeenAtMs,
lastSeenReason: lastSeen.lastSeenReason,
@@ -177,6 +266,7 @@ function compareKnownNodes(left: NodeListNode, right: NodeListNode): number {
export function createKnownNodeCatalog(params: {
pairedDevices: readonly PairedDevice[];
pairedNodes?: readonly NodePairingPairedNode[];
pendingNodes?: readonly NodePairingPendingRequest[];
connectedNodes: readonly NodeSession[];
}): KnownNodeCatalog {
const devicePairingById = new Map(
@@ -187,10 +277,18 @@ export function createKnownNodeCatalog(params: {
const nodePairingById = new Map(
(params.pairedNodes ?? []).map((entry) => [entry.nodeId, buildApprovedNodeSource(entry)]),
);
const pendingNodePairingById = new Map<string, KnownNodePendingSource>();
// listNodePairing returns newest requests first; keep the current approval action per node.
for (const entry of params.pendingNodes ?? []) {
if (!pendingNodePairingById.has(entry.nodeId)) {
pendingNodePairingById.set(entry.nodeId, buildPendingNodeSource(entry));
}
}
const liveById = new Map(params.connectedNodes.map((entry) => [entry.nodeId, entry]));
const nodeIds = new Set<string>([
...devicePairingById.keys(),
...nodePairingById.keys(),
...pendingNodePairingById.keys(),
...liveById.keys(),
]);
const entriesById = new Map<string, KnownNodeEntry>();
@@ -198,15 +296,22 @@ export function createKnownNodeCatalog(params: {
const devicePairing = devicePairingById.get(nodeId);
const nodePairing = nodePairingById.get(nodeId);
const live = liveById.get(nodeId);
const pendingNodePairing = resolveCurrentPendingNodePairing({
pending: pendingNodePairingById.get(nodeId),
nodePairing,
live,
});
entriesById.set(nodeId, {
nodeId,
devicePairing,
nodePairing,
pendingNodePairing,
live,
effective: buildEffectiveKnownNode({
nodeId,
devicePairing,
nodePairing,
pendingNodePairing,
live,
}),
});
+22 -1
View File
@@ -174,7 +174,7 @@ describe("reconcileNodePairingOnConnect", () => {
expect(result.pendingPairing?.request.requestId).toBe("req-caps");
});
it("preserves the approved surface when paired node upgrade pairing is throttled", async () => {
it("keeps the approved surface when paired-node reapproval is throttled", async () => {
const requestPairing = vi.fn(async () => null);
const result = await reconcileNodePairingOnConnect({
@@ -195,6 +195,27 @@ describe("reconcileNodePairingOnConnect", () => {
expect(result.effectiveCommands).toEqual([]);
expect(result.declaredCaps).toEqual(["camera", "screen"]);
expect(result.pendingPairing).toBeUndefined();
expect(result.shouldClearPendingPairings).toBeUndefined();
});
it("defers stale pending reapproval cleanup when the node returns to its approved surface", async () => {
const requestPairing = makePendingPairingRequest("req-unused");
const result = await reconcileNodePairingOnConnect({
cfg: {} as never,
connectParams: makeNodeConnectParams({
caps: ["camera"],
commands: ["canvas.snapshot"],
}),
pairedNode: makePairedNode({
caps: ["camera"],
commands: ["canvas.snapshot"],
}),
requestPairing,
});
expect(requestPairing).not.toHaveBeenCalled();
expect(result.shouldClearPendingPairings).toBe(true);
});
it("requires a fresh pairing request when paired node permissions change", async () => {
+2
View File
@@ -30,6 +30,7 @@ export type NodeConnectPairingReconcileResult = {
declaredPermissions?: Record<string, boolean>;
effectivePermissions?: Record<string, boolean>;
pendingPairing?: RequestNodePairingResult;
shouldClearPendingPairings?: boolean;
};
function resolveApprovedReconnectCommands(params: {
@@ -221,5 +222,6 @@ export async function reconcileNodePairingOnConnect(params: {
effectiveCommands: declared,
declaredPermissions,
effectivePermissions: declaredPermissions,
shouldClearPendingPairings: true,
};
}
@@ -0,0 +1,425 @@
// Covers paired-node reapproval reuse and changed-surface write limits.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
approveNodePairing,
beginNodePairingConnect,
listNodePairing,
releaseNodePairingCleanupClaim,
requestNodePairing,
} from "../infra/node-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-reapproval-" });
async function setupPairedNode(baseDir: string): Promise<void> {
const request = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
caps: ["camera"],
},
baseDir,
);
await approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing"] },
baseDir,
);
}
describe("node reapproval coordinator", () => {
beforeAll(async () => {
await tempDirs.setup();
});
afterAll(async () => {
await tempDirs.cleanup();
});
test("reuses identical pending state without consuming changed-surface quota", async () => {
const baseDir = await tempDirs.make("reuse");
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
baseDir,
);
const coordinator = createNodeReapprovalCoordinator({
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
});
const matchingConnect = await beginNodePairingConnect("node-1", baseDir);
await expect(
coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
cleanupClaim: matchingConnect.cleanupClaim,
baseDir,
}),
).resolves.toMatchObject({
request: { requestId: pending.request.requestId },
created: false,
});
if (matchingConnect.cleanupClaim) {
await releaseNodePairingCleanupClaim(matchingConnect.cleanupClaim);
}
const changedConnect = await beginNodePairingConnect("node-1", baseDir);
await expect(
coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "microphone"],
},
cleanupClaim: changedConnect.cleanupClaim,
baseDir,
}),
).resolves.toMatchObject({
request: { caps: ["camera", "microphone"] },
created: true,
});
if (changedConnect.cleanupClaim) {
await releaseNodePairingCleanupClaim(changedConnect.cleanupClaim);
}
await expect(
coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "location"],
},
baseDir,
}),
).resolves.toBeNull();
expect((await listNodePairing(baseDir)).pending).toEqual([
expect.objectContaining({ caps: ["camera", "microphone"] }),
]);
coordinator.dispose();
});
test("stops accepting work after disposal", async () => {
const baseDir = await tempDirs.make("dispose");
await setupPairedNode(baseDir);
const coordinator = createNodeReapprovalCoordinator();
coordinator.dispose();
await expect(
coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
baseDir,
}),
).resolves.toBeNull();
expect((await listNodePairing(baseDir)).pending).toEqual([]);
});
test("bounds metadata refreshes while preserving the latest accepted values", async () => {
const baseDir = await tempDirs.make("metadata");
await setupPairedNode(baseDir);
await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
displayName: "Old Name",
caps: ["camera", "screen"],
},
baseDir,
);
const coordinator = createNodeReapprovalCoordinator({
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
});
await expect(
coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
displayName: "New Name",
caps: ["camera", "screen"],
},
baseDir,
}),
).resolves.toMatchObject({
request: { displayName: "New Name" },
created: false,
});
await expect(
coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
displayName: "Newest Name",
caps: ["camera", "screen"],
},
baseDir,
}),
).resolves.toBeNull();
expect((await listNodePairing(baseDir)).pending).toEqual([
expect.objectContaining({ displayName: "New Name" }),
]);
coordinator.dispose();
});
test("coalesces concurrent reconnect work before pairing storage", async () => {
const baseDir = await tempDirs.make("concurrent");
await setupPairedNode(baseDir);
const coordinator = createNodeReapprovalCoordinator({
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
});
const input = {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
};
const first = coordinator.request({ input, baseDir });
const coalesced = coordinator.request({ input, baseDir });
await expect(first).resolves.toMatchObject({
request: { caps: ["camera", "screen"] },
});
await expect(coalesced).resolves.toMatchObject({
request: { caps: ["camera", "screen"] },
created: false,
});
expect((await listNodePairing(baseDir)).pending).toHaveLength(1);
coordinator.dispose();
});
test("queues one distinct concurrent declaration", async () => {
const baseDir = await tempDirs.make("distinct");
await setupPairedNode(baseDir);
const coordinator = createNodeReapprovalCoordinator({
maxAttempts: 2,
windowMs: 60_000,
lockoutMs: 60_000,
});
const first = coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
baseDir,
});
const second = coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "microphone"],
},
baseDir,
});
await expect(first).resolves.toMatchObject({
request: { caps: ["camera", "screen"] },
});
await expect(second).resolves.toMatchObject({
request: { caps: ["camera", "microphone"] },
});
expect((await listNodePairing(baseDir)).pending).toEqual([
expect.objectContaining({ caps: ["camera", "microphone"] }),
]);
coordinator.dispose();
});
test("keeps only the latest request waiting behind active work", async () => {
const baseDir = await tempDirs.make("latest");
await setupPairedNode(baseDir);
const coordinator = createNodeReapprovalCoordinator({
maxAttempts: 2,
windowMs: 60_000,
lockoutMs: 60_000,
});
const active = coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
baseDir,
});
const superseded = coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "microphone"],
},
baseDir,
});
const latest = coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "location"],
},
baseDir,
});
await expect(active).resolves.toMatchObject({
request: { caps: ["camera", "screen"] },
});
await expect(superseded).resolves.toBeNull();
await expect(latest).resolves.toMatchObject({
request: { caps: ["camera", "location"] },
});
expect((await listNodePairing(baseDir)).pending).toEqual([
expect.objectContaining({ caps: ["camera", "location"] }),
]);
coordinator.dispose();
});
test("cancels queued work when the latest declaration matches active work", async () => {
const baseDir = await tempDirs.make("active-latest");
await setupPairedNode(baseDir);
const coordinator = createNodeReapprovalCoordinator({
maxAttempts: 2,
windowMs: 60_000,
lockoutMs: 60_000,
});
const activeInput = {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
};
const active = coordinator.request({ input: activeInput, baseDir });
const stale = coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "microphone"],
},
baseDir,
});
const latest = coordinator.request({ input: activeInput, baseDir });
await expect(active).resolves.toMatchObject({
request: { caps: ["camera", "screen"] },
});
await expect(stale).resolves.toBeNull();
await expect(latest).resolves.toMatchObject({
request: { caps: ["camera", "screen"] },
created: false,
});
expect((await listNodePairing(baseDir)).pending).toEqual([
expect.objectContaining({ caps: ["camera", "screen"] }),
]);
coordinator.dispose();
});
test("retains the newest cleanup claim across equivalent reconnects", async () => {
const baseDir = await tempDirs.make("cleanup-generation");
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
baseDir,
);
const first = await beginNodePairingConnect("node-1", baseDir);
const staleCleanup = await beginNodePairingConnect("node-1", baseDir);
const latest = await beginNodePairingConnect("node-1", baseDir);
expect(first.cleanupClaim).toBeDefined();
expect(staleCleanup.cleanupClaim).toBeDefined();
expect(latest.cleanupClaim).toBeDefined();
const coordinator = createNodeReapprovalCoordinator();
const input = {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
};
const firstReuse = coordinator.request({
input,
cleanupClaim: first.cleanupClaim,
baseDir,
});
const latestReuse = coordinator.request({
input,
cleanupClaim: latest.cleanupClaim,
baseDir,
});
const cleanup = coordinator.finalizeCleanup(staleCleanup.cleanupClaim!);
await expect(firstReuse).resolves.toMatchObject({
request: { requestId: pending.request.requestId },
});
await expect(latestReuse).resolves.toMatchObject({
request: { requestId: pending.request.requestId },
created: false,
});
await expect(cleanup).resolves.toEqual([]);
expect((await listNodePairing(baseDir)).pending).toEqual([
expect.objectContaining({ requestId: pending.request.requestId }),
]);
coordinator.dispose();
});
test("serializes stale cleanup behind pending-request reuse", async () => {
const baseDir = await tempDirs.make("cleanup-order");
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
const coordinator = createNodeReapprovalCoordinator();
const reused = coordinator.request({
input: {
nodeId: "node-1",
platform: "darwin",
caps: ["camera", "screen"],
},
cleanupClaim: snapshot.cleanupClaim,
baseDir,
});
const cleanup = coordinator.finalizeCleanup(snapshot.cleanupClaim!);
await expect(reused).resolves.toMatchObject({
request: { requestId: pending.request.requestId },
created: false,
});
await expect(cleanup).resolves.toEqual([]);
expect((await listNodePairing(baseDir)).pending).toEqual([
expect.objectContaining({ requestId: pending.request.requestId }),
]);
coordinator.dispose();
});
});
+240
View File
@@ -0,0 +1,240 @@
// Coordinates paired-node reapproval requests before they enter pairing storage.
import {
finalizeNodePairingCleanupClaim,
requestNodePairing,
reusePendingNodePairingForReconnect,
type NodePairingCleanupClaim,
type NodePairingRequestInput,
type NodePairingSupersededRequest,
type RequestNodePairingResult,
} from "../infra/node-pairing.js";
import {
AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL,
buildRateLimitIdentityKey,
createAuthRateLimiter,
type RateLimitConfig,
} from "./auth-rate-limit.js";
import { withSerializedKeyedAttempt } from "./rate-limit-attempt-serialization.js";
type ReapprovalRequestParams = {
input: NodePairingRequestInput;
cleanupClaim?: NodePairingCleanupClaim;
baseDir?: string;
};
type DeferredResult = {
promise: Promise<RequestNodePairingResult | null>;
resolve: (result: RequestNodePairingResult | null) => void;
reject: (error: unknown) => void;
};
type QueuedRequest = {
fingerprint: string;
params: ReapprovalRequestParams;
deferred: DeferredResult;
followers: DeferredResult[];
};
type NodeRequestState = {
activeFingerprint: string;
queued?: QueuedRequest;
};
export type NodeReapprovalCoordinator = {
request: (params: ReapprovalRequestParams) => Promise<RequestNodePairingResult | null>;
finalizeCleanup: (claim: NodePairingCleanupClaim) => Promise<NodePairingSupersededRequest[]>;
dispose: () => void;
};
function createDeferredResult(): DeferredResult {
let resolve!: DeferredResult["resolve"];
let reject!: DeferredResult["reject"];
const promise = new Promise<RequestNodePairingResult | null>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function normalizeFingerprintList(value: string[] | undefined): string[] | undefined {
return value
? [
...new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)),
].toSorted()
: undefined;
}
function buildRequestFingerprint(input: NodePairingRequestInput): string {
const permissions = input.permissions
? Object.fromEntries(
Object.entries(input.permissions).toSorted(([left], [right]) => left.localeCompare(right)),
)
: undefined;
return JSON.stringify({
nodeId: input.nodeId.trim(),
clientId: input.clientId,
clientMode: input.clientMode,
displayName: input.displayName,
platform: input.platform,
version: input.version,
coreVersion: input.coreVersion,
uiVersion: input.uiVersion,
deviceFamily: input.deviceFamily,
modelIdentifier: input.modelIdentifier,
caps: normalizeFingerprintList(input.caps),
commands: normalizeFingerprintList(input.commands),
permissions,
remoteIp: input.remoteIp,
silent: Boolean(input.silent),
});
}
/** Creates the gateway-lifetime owner for paired-node reapproval write limits. */
export function createNodeReapprovalCoordinator(
config?: RateLimitConfig,
): NodeReapprovalCoordinator {
const limiter = createAuthRateLimiter({
...config,
exemptLoopback: false,
});
const requestStates = new Map<string, NodeRequestState>();
let disposed = false;
const executeRequest = async ({
input,
cleanupClaim,
baseDir,
}: ReapprovalRequestParams): Promise<RequestNodePairingResult | null> => {
if (disposed) {
return null;
}
const reused = await reusePendingNodePairingForReconnect(input, cleanupClaim, baseDir);
if (reused) {
return reused;
}
const nodeId = input.nodeId.trim();
const identityKey = buildRateLimitIdentityKey("node", nodeId);
const rateCheck = limiter.check(identityKey, AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL);
if (!rateCheck.allowed) {
return null;
}
const result = await requestNodePairing(input, baseDir);
limiter.recordFailure(identityKey, AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL);
return result;
};
const finishActiveRequest = (nodeId: string, state: NodeRequestState, fingerprint: string) => {
if (requestStates.get(nodeId) !== state || state.activeFingerprint !== fingerprint) {
return;
}
if (!state.queued) {
requestStates.delete(nodeId);
}
};
const startFirstRequest = (
nodeId: string,
state: NodeRequestState,
request: QueuedRequest,
): void => {
void withSerializedKeyedAttempt({
key: `node-reapproval:${nodeId}`,
run: async () => {
try {
request.deferred.resolve(await executeRequest(request.params));
} catch (error) {
request.deferred.reject(error);
} finally {
finishActiveRequest(nodeId, state, request.fingerprint);
}
},
});
};
const startQueuedRequest = (nodeId: string, state: NodeRequestState): void => {
void withSerializedKeyedAttempt({
key: `node-reapproval:${nodeId}`,
run: async () => {
const queued = state.queued;
if (!queued) {
return;
}
state.queued = undefined;
state.activeFingerprint = queued.fingerprint;
try {
queued.deferred.resolve(await executeRequest(queued.params));
for (const follower of queued.followers) {
follower.resolve(null);
}
} catch (error) {
queued.deferred.reject(error);
for (const follower of queued.followers) {
follower.reject(error);
}
} finally {
finishActiveRequest(nodeId, state, queued.fingerprint);
}
},
});
};
return {
request(params) {
if (disposed) {
return Promise.resolve(null);
}
const nodeId = params.input.nodeId.trim();
const fingerprint = buildRequestFingerprint(params.input);
const state = requestStates.get(nodeId);
if (!state) {
const deferred = createDeferredResult();
const nextState: NodeRequestState = { activeFingerprint: fingerprint };
requestStates.set(nodeId, nextState);
startFirstRequest(nodeId, nextState, {
fingerprint,
params,
deferred,
followers: [],
});
return deferred.promise;
}
if (state.queued?.fingerprint === fingerprint) {
const follower = createDeferredResult();
state.queued.params = params;
state.queued.followers.push(follower);
return follower.promise;
}
const deferred = createDeferredResult();
if (state.queued) {
state.queued.deferred.resolve(null);
for (const follower of state.queued.followers) {
follower.resolve(null);
}
state.queued = { fingerprint, params, deferred, followers: [] };
} else {
state.queued = { fingerprint, params, deferred, followers: [] };
startQueuedRequest(nodeId, state);
}
return deferred.promise;
},
async finalizeCleanup(claim) {
return await withSerializedKeyedAttempt({
key: `node-reapproval:${claim.nodeId}`,
run: async () => await finalizeNodePairingCleanupClaim(claim),
});
},
dispose() {
disposed = true;
for (const state of requestStates.values()) {
state.queued?.deferred.resolve(null);
for (const follower of state.queued?.followers ?? []) {
follower.resolve(null);
}
}
requestStates.clear();
limiter.dispose();
},
};
}
@@ -12,13 +12,12 @@ function buildSerializationKey(ip: string | undefined, scope: string | undefined
return `${normalizeScope(scope)}:${normalizeRateLimitClientIp(ip)}`;
}
/** Runs one rate-limit attempt after prior attempts for the same IP/scope finish. */
export async function withSerializedRateLimitAttempt<T>(params: {
ip: string | undefined;
scope: string | undefined;
/** Runs one attempt after prior work for the same stable key finishes. */
export async function withSerializedKeyedAttempt<T>(params: {
key: string;
run: () => Promise<T>;
}): Promise<T> {
const key = buildSerializationKey(params.ip, params.scope);
const key = params.key;
const previous = pendingAttempts.get(key) ?? Promise.resolve();
let releaseCurrent!: () => void;
const current = new Promise<void>((resolve) => {
@@ -37,3 +36,15 @@ export async function withSerializedRateLimitAttempt<T>(params: {
}
}
}
/** Runs one rate-limit attempt after prior attempts for the same IP/scope finish. */
export async function withSerializedRateLimitAttempt<T>(params: {
ip: string | undefined;
scope: string | undefined;
run: () => Promise<T>;
}): Promise<T> {
return await withSerializedKeyedAttempt({
key: buildSerializationKey(params.ip, params.scope),
run: params.run,
});
}
+10 -2
View File
@@ -61,6 +61,7 @@ import {
import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js";
import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js";
import type { NodeSession } from "../node-registry.js";
import { ADMIN_SCOPE, PAIRING_SCOPE } from "../operator-scopes.js";
import { refreshClientPluginNodeCapability } from "../plugin-node-capability.js";
import type { NodeEventContext } from "../server-node-events-types.js";
import {
@@ -119,6 +120,11 @@ type PendingNodeAction = {
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
function canReadPendingNodePairing(client: GatewayClient | null): boolean {
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
return scopes.includes(ADMIN_SCOPE) || scopes.includes(PAIRING_SCOPE);
}
function normalizeBrowserProxyPath(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
@@ -932,7 +938,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
respond(true, { nodeId: updated.nodeId, displayName: updated.displayName }, undefined);
});
},
"node.list": async ({ params, respond, context }) => {
"node.list": async ({ params, respond, client, context }) => {
if (!validateNodeListParams(params)) {
respondInvalidParams({
respond,
@@ -949,13 +955,14 @@ export const nodeHandlers: GatewayRequestHandlers = {
const catalog = createKnownNodeCatalog({
pairedDevices: devicePairing.paired,
pairedNodes: nodePairing.paired,
pendingNodes: canReadPendingNodePairing(client) ? nodePairing.pending : undefined,
connectedNodes: context.nodeRegistry.listConnected(),
});
const nodes = listKnownNodes(catalog);
respond(true, { ts: Date.now(), nodes }, undefined);
});
},
"node.describe": async ({ params, respond, context }) => {
"node.describe": async ({ params, respond, client, context }) => {
if (!validateNodeDescribeParams(params)) {
respondInvalidParams({
respond,
@@ -978,6 +985,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
const catalog = createKnownNodeCatalog({
pairedDevices: devicePairing.paired,
pairedNodes: nodePairing.paired,
pendingNodes: canReadPendingNodePairing(client) ? nodePairing.pending : undefined,
connectedNodes: context.nodeRegistry.listConnected(),
});
const node = getKnownNode(catalog, id);
+1
View File
@@ -31,6 +31,7 @@ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) {
getRequiredSharedGatewaySessionGeneration: params.getRequiredSharedGatewaySessionGeneration,
rateLimiter: params.rateLimiter,
browserRateLimiter: params.browserRateLimiter,
nodeReapprovalCoordinator: params.nodeReapprovalCoordinator,
preauthHandshakeTimeoutMs: params.preauthHandshakeTimeoutMs,
isStartupPending: params.isStartupPending,
gatewayMethods: params.gatewayMethods,
+7 -1
View File
@@ -74,6 +74,7 @@ import {
type GatewayMethodRegistry,
} from "./methods/registry.js";
import { isLoopbackHost } from "./net.js";
import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
import {
listChannelPluginConfigTargetIds,
pluginConfigTargetsChanged,
@@ -819,6 +820,7 @@ export async function startGatewayServer(
const rateLimitConfig = cfgAtStart.gateway?.auth?.rateLimit;
const { rateLimiter: authRateLimiter, browserRateLimiter: browserAuthRateLimiter } =
createGatewayAuthRateLimiters(rateLimitConfig);
const nodeReapprovalCoordinator = createNodeReapprovalCoordinator(rateLimitConfig);
const controlUiRootState = await startupTrace.measure("control-ui.root", () =>
resolveGatewayControlUiRootState({
@@ -990,7 +992,10 @@ export async function startGatewayServer(
runtimeState.skillsRefreshTimer = null;
},
skillsChangeUnsub: runtimeState.skillsChangeUnsub,
disposeAuthRateLimiter: () => authRateLimiter.dispose(),
disposeAuthRateLimiter: () => {
authRateLimiter.dispose();
nodeReapprovalCoordinator.dispose();
},
disposeBrowserAuthRateLimiter: () => browserAuthRateLimiter.dispose(),
stopModelPricingRefresh: runtimeState.stopModelPricingRefresh,
stopChannelHealthMonitor: () => runtimeState?.channelHealthMonitor?.stop(),
@@ -1531,6 +1536,7 @@ export async function startGatewayServer(
getRequiredSharedGatewaySessionGeneration(sharedGatewaySessionGenerationState),
rateLimiter: authRateLimiter,
browserRateLimiter: browserAuthRateLimiter,
nodeReapprovalCoordinator,
preauthHandshakeTimeoutMs,
isStartupPending: isGatewayStartupPending,
gatewayMethods: runtimeState.gatewayMethods,
+187 -2
View File
@@ -10,6 +10,7 @@ import {
} from "../infra/node-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { callGateway } from "./call.js";
import {
loadDeviceIdentity,
openTrackedWs,
@@ -111,10 +112,18 @@ async function expectRePairingRequest(params: {
});
const connectedControlWs = controlWs;
let lastNodes: Array<{ nodeId: string; connected?: boolean; commands?: string[] }> = [];
type NodeDiagnostics = {
nodeId: string;
connected?: boolean;
commands?: string[];
approvalState?: string;
pendingRequestId?: string;
pendingDeclaredCommands?: string[];
};
let lastNodes: NodeDiagnostics[] = [];
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
nodes?: NodeDiagnostics[];
}>(connectedControlWs, "node.list", {});
lastNodes = list.payload?.nodes ?? [];
const node = lastNodes.find(
@@ -140,6 +149,23 @@ async function expectRePairingRequest(params: {
const pending = pairing.pending?.find((entry) => entry.nodeId === pairedNode.identity.deviceId);
expect(pending?.nodeId).toBe(pairedNode.identity.deviceId);
expect(pending?.commands).toEqual(params.reconnectCommands);
const listedNode = lastNodes.find((entry) => entry.nodeId === pairedNode.identity.deviceId);
expect(listedNode).toMatchObject({
approvalState: "pending-reapproval",
pendingRequestId: pending?.requestId,
pendingDeclaredCommands: params.reconnectCommands,
commands: params.expectedVisibleCommands,
});
const described = await rpcReq<NodeDiagnostics>(connectedControlWs, "node.describe", {
nodeId: pairedNode.identity.deviceId,
});
expect(described.payload).toMatchObject({
approvalState: "pending-reapproval",
pendingRequestId: pending?.requestId,
pendingDeclaredCommands: params.reconnectCommands,
commands: params.expectedVisibleCommands,
});
} finally {
controlWs?.close();
await firstClient?.stopAndWait();
@@ -314,7 +340,166 @@ describe("gateway node pairing authorization", () => {
});
});
describeWithGatewayServer("pending diagnostics scopes", (getStarted) => {
test("shows pending pairing records to direct-local backend shared-auth callers", async () => {
const pendingOnlyNodeId = "node-local-backend-pending";
const pending = await requestNodePairing({
nodeId: pendingOnlyNodeId,
platform: "macos",
commands: ["system.run"],
});
const listed = await callGateway<{
nodes?: Array<{
nodeId: string;
approvalState?: string;
pendingRequestId?: string;
}>;
}>({
config: {
gateway: {
mode: "local",
bind: "loopback",
port: getStarted().port,
auth: { mode: "token", token: "secret" },
},
},
method: "node.list",
scopes: ["operator.read", "operator.pairing"],
requireLocalBackendSharedAuth: true,
timeoutMs: 2_000,
});
expect(listed.nodes).toContainEqual(
expect.objectContaining({
nodeId: pendingOnlyNodeId,
approvalState: "pending-approval",
pendingRequestId: pending.request.requestId,
}),
);
});
test("hides pending pairing records from read-only callers", async () => {
const pairedNodeId = "node-read-only-paired";
const pendingOnlyNodeId = "node-read-only-pending";
const initial = await requestNodePairing({
nodeId: pairedNodeId,
platform: "macos",
commands: ["screen.snapshot"],
});
await approveNodePairing(initial.request.requestId, {
callerScopes: ["operator.pairing", "operator.write"],
});
await requestNodePairing({
nodeId: pairedNodeId,
platform: "macos",
commands: ["screen.snapshot", "system.run"],
});
await requestNodePairing({
nodeId: pendingOnlyNodeId,
platform: "macos",
commands: ["system.run"],
});
const ws = await openTrackedWs(getStarted().port);
try {
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
deviceIdentityPath: `${await makeNodePairingStateDir()}/read-only.json`,
});
type NodeDiagnostics = {
nodeId: string;
approvalState?: string;
pendingRequestId?: string;
pendingDeclaredCommands?: string[];
};
const listed = await rpcReq<{ nodes?: NodeDiagnostics[] }>(ws, "node.list", {});
expect(listed.ok).toBe(true);
const nodes = listed.payload?.nodes ?? [];
expect(nodes.some((node) => node.nodeId === pendingOnlyNodeId)).toBe(false);
expect(nodes.find((node) => node.nodeId === pairedNodeId)).toEqual(
expect.objectContaining({
nodeId: pairedNodeId,
approvalState: "approved",
}),
);
expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty(
"pendingRequestId",
);
expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty(
"pendingDeclaredCommands",
);
const described = await rpcReq<NodeDiagnostics>(ws, "node.describe", {
nodeId: pairedNodeId,
});
expect(described.payload).toEqual(
expect.objectContaining({
nodeId: pairedNodeId,
approvalState: "approved",
}),
);
expect(described.payload).not.toHaveProperty("pendingRequestId");
expect(described.payload).not.toHaveProperty("pendingDeclaredCommands");
const pendingOnly = await rpcReq(ws, "node.describe", { nodeId: pendingOnlyNodeId });
expect(pendingOnly.ok).toBe(false);
expect(pendingOnly.error?.message).toContain("unknown nodeId");
} finally {
ws.close();
}
});
});
describeWithGatewayServer("paired node reconnects", (getStarted) => {
test("clears stale reapproval when a node returns to its approved surface", async () => {
const pairedNode = await pairDeviceIdentity({
name: "node-reverted-reapproval",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const initial = await requestNodePairing({
nodeId: pairedNode.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["screen.snapshot"],
});
await approveNodePairing(initial.request.requestId, {
callerScopes: ["operator.pairing", "operator.write"],
});
const upgraded = await connectNodeClient({
port: getStarted().port,
deviceIdentity: pairedNode.identity,
commands: ["screen.snapshot", "system.run"],
});
await upgraded.stopAndWait();
expect(
(await listNodePairing()).pending.some(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
),
).toBe(true);
const reverted = await connectNodeClient({
port: getStarted().port,
deviceIdentity: pairedNode.identity,
commands: ["screen.snapshot"],
});
await reverted.stopAndWait();
await vi.waitFor(async () => {
expect(
(await listNodePairing()).pending.some(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
),
).toBe(false);
});
});
test("requests re-pairing when a paired node reconnects with upgraded commands", async () => {
await expectRePairingRequest({
started: getStarted(),
@@ -36,7 +36,11 @@ async function openWs(port: number) {
return ws;
}
async function attemptNodePairing(port: number, identityPath: string) {
async function attemptNodePairing(
port: number,
identityPath: string,
surface: { caps?: string[]; commands?: string[] } = {},
) {
const ws = await openWs(port);
try {
return await connectReq(ws, {
@@ -44,8 +48,9 @@ async function attemptNodePairing(port: number, identityPath: string) {
role: "node",
scopes: [],
client: NODE_CLIENT,
commands: ["system.run"],
commands: surface.commands ?? ["system.run"],
deviceIdentityPath: identityPath,
...(surface.caps ? { caps: surface.caps } : {}),
});
} finally {
ws.close();
@@ -110,7 +115,7 @@ describe("node pairing rate limit", () => {
});
});
test("keeps paired reconnects on the approved surface when upgrade pairing is limited", async () => {
test("records paired reconnect reapproval despite first-time pairing limits", async () => {
testState.gatewayAuth = {
mode: "token",
token: "secret",
@@ -127,7 +132,10 @@ describe("node pairing rate limit", () => {
`openclaw-node-pairing-upgrade-${randomUUID()}`,
);
const pairedIdentityPath = `${identityPrefix}-paired.json`;
await approveNodeIdentity({ identityPath: pairedIdentityPath, caps: ["camera"] });
const pairedIdentity = await approveNodeIdentity({
identityPath: pairedIdentityPath,
caps: ["camera"],
});
const firstTimeResponses = await Promise.all(
Array.from(
@@ -159,7 +167,70 @@ describe("node pairing rate limit", () => {
});
}
expect((await listNodePairing()).pending).toHaveLength(3);
const pending = (await listNodePairing()).pending;
expect(pending).toHaveLength(4);
expect(pending.find((entry) => entry.nodeId === pairedIdentity.deviceId)?.caps).toEqual([
"camera",
"screen",
]);
});
});
test("reuses identical paired reapproval without rejecting the node", async () => {
testState.gatewayAuth = {
mode: "token",
token: "secret",
rateLimit: {
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
exemptLoopback: true,
},
};
await withGatewayServer(async ({ port }) => {
const identityPath = path.join(os.tmpdir(), `openclaw-node-reapproval-${randomUUID()}.json`);
const identity = await approveNodeIdentity({ identityPath, caps: ["camera"] });
const responses = await Promise.all(
Array.from(
{ length: 20 },
async () =>
await attemptNodePairing(port, identityPath, {
caps: ["camera", "screen"],
commands: [],
}),
),
);
expect(responses.every((res) => res.ok)).toBe(true);
const pendingBeforeReuse = (await listNodePairing()).pending.find(
(entry) => entry.nodeId === identity.deviceId,
);
expect(pendingBeforeReuse).toBeDefined();
await expect(
attemptNodePairing(port, identityPath, {
caps: ["camera", "screen"],
commands: [],
}),
).resolves.toMatchObject({ ok: true });
expect(
(await listNodePairing()).pending.find((entry) => entry.nodeId === identity.deviceId),
).toMatchObject({
requestId: pendingBeforeReuse!.requestId,
ts: pendingBeforeReuse!.ts,
});
const changedSurface = await attemptNodePairing(port, identityPath, {
caps: ["camera", "microphone"],
commands: [],
});
expect(changedSurface.ok).toBe(true);
expect(
(await listNodePairing()).pending.find((entry) => entry.nodeId === identity.deviceId),
).toMatchObject({
requestId: pendingBeforeReuse!.requestId,
caps: ["camera", "screen"],
});
});
});
});
@@ -2,9 +2,12 @@
// gateway accepts matching node hosts and rejects incompatible local runtimes.
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { pairDeviceIdentity } from "./device-authz.test-helpers.js";
import { connectGatewayClient } from "./test-helpers.e2e.js";
import { installGatewayTestHooks, startServer } from "./test-helpers.js";
@@ -75,6 +78,108 @@ describe("node host version mismatch guard", () => {
).rejects.toThrow(/client version mismatch|version mismatch/i);
});
test("rejected local reconnects preserve the active node pending reapproval", async () => {
const pairedNode = await pairDeviceIdentity({
name: "node-version-mismatch-pending-reapproval",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const initial = await requestNodePairing({
nodeId: pairedNode.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["screen.snapshot"],
});
await approveNodePairing(initial.request.requestId, {
callerScopes: ["operator.pairing", "operator.write"],
});
const upgraded = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "test-node-upgraded",
clientVersion: gatewayVersion,
instanceId: TEST_LOCAL_NODE_ID,
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: ["screen.snapshot", "system.run"],
deviceIdentity: pairedNode.identity,
});
try {
const connectReverted = async (clientVersion: string, clientDisplayName: string) =>
await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName,
clientVersion,
instanceId: TEST_LOCAL_NODE_ID,
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: ["screen.snapshot"],
deviceIdentity: pairedNode.identity,
timeoutMs: 5_000,
timeoutMessage: "expected rejected reconnect",
});
const pendingBefore = (await listNodePairing()).pending.find(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
);
expect(pendingBefore?.commands).toEqual(["screen.snapshot", "system.run"]);
await expect(connectReverted("2020.1.1", "test-node-reverted-stale")).rejects.toThrow(
/client version mismatch|version mismatch/i,
);
const pendingAfterVersionMismatch = (await listNodePairing()).pending.find(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
);
expect(pendingAfterVersionMismatch?.requestId).toBe(pendingBefore?.requestId);
expect(pendingAfterVersionMismatch?.commands).toEqual(["screen.snapshot", "system.run"]);
const originalSend = Reflect.get(WebSocket.prototype, "send");
let failNextHelloOk = true;
const sendSpy = vi.spyOn(WebSocket.prototype, "send").mockImplementation(function (
this: WebSocket,
...args: Parameters<WebSocket["send"]>
) {
if (failNextHelloOk && typeof args[0] === "string" && args[0].includes('"hello-ok"')) {
failNextHelloOk = false;
const callback = args.findLast((arg) => typeof arg === "function");
if (typeof callback === "function") {
callback(new Error("test hello-ok send failure"));
}
return;
}
Reflect.apply(originalSend, this, args);
});
try {
await expect(
connectReverted(gatewayVersion, "test-node-reverted-hello-failure"),
).rejects.toThrow(/gateway closed during connect/i);
expect(failNextHelloOk).toBe(false);
} finally {
sendSpy.mockRestore();
}
const pendingAfterHelloFailure = (await listNodePairing()).pending.find(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
);
expect(pendingAfterHelloFailure?.requestId).toBe(pendingBefore?.requestId);
expect(pendingAfterHelloFailure?.commands).toEqual(["screen.snapshot", "system.run"]);
} finally {
await upgraded.stopAndWait({ timeoutMs: 2_000 });
}
});
test("local node with dev/test version is allowed (not a released version)", async () => {
// "dev" does not match YYYY.M.PATCH, so the guard skips
const client = await connectGatewayClient({
+4
View File
@@ -20,6 +20,7 @@ import { resolvePreauthHandshakeTimeoutMs } from "../handshake-timeouts.js";
import { resolveHostedPluginSurfaceUrl } from "../hosted-plugin-surface-url.js";
import type { GatewayMethodRegistry } from "../methods/registry.js";
import { isLoopbackAddress } from "../net.js";
import type { NodeReapprovalCoordinator } from "../node-reapproval-coordinator.js";
import type { PluginNodeCapabilitySurface } from "../plugin-node-capability.js";
import {
MAX_BUFFERED_BYTES,
@@ -155,6 +156,7 @@ export type GatewayWsSharedHandlerParams = {
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
browserRateLimiter?: AuthRateLimiter;
nodeReapprovalCoordinator?: NodeReapprovalCoordinator;
preauthHandshakeTimeoutMs?: number;
isStartupPending?: () => boolean;
gatewayMethods: string[];
@@ -232,6 +234,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
),
rateLimiter,
browserRateLimiter,
nodeReapprovalCoordinator,
isStartupPending,
gatewayMethods,
events,
@@ -528,6 +531,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
getRequiredSharedGatewaySessionGeneration,
rateLimiter,
browserRateLimiter,
nodeReapprovalCoordinator,
isStartupPending,
gatewayMethods,
events,
@@ -68,8 +68,12 @@ import {
runWithDiagnosticTraceContext,
} from "../../../infra/diagnostic-trace-context.js";
import {
getPairedNode,
beginNodePairingConnect,
finalizeNodePairingCleanupClaim,
releaseNodePairingCleanupClaim,
requestNodePairing,
type NodePairingCleanupClaim,
type RequestNodePairingResult,
updatePairedNodeMetadata,
} from "../../../infra/node-pairing.js";
import { upsertPresence } from "../../../infra/system-presence.js";
@@ -110,6 +114,7 @@ import {
resolveNodePairingClientIpSource,
shouldAutoApproveNodePairingFromTrustedCidrs,
} from "../../node-pairing-auto-approve.js";
import type { NodeReapprovalCoordinator } from "../../node-reapproval-coordinator.js";
import { isOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import {
@@ -210,7 +215,18 @@ async function requestNodePairingFromConnect(params: {
input: Parameters<typeof requestNodePairing>[0];
rateLimiter?: AuthRateLimiter;
clientIp?: string;
}): Promise<Awaited<ReturnType<typeof requestNodePairing>>> {
pairedReconnect?: boolean;
cleanupClaim?: NodePairingCleanupClaim;
reapprovalCoordinator?: NodeReapprovalCoordinator;
}): Promise<Awaited<ReturnType<typeof requestNodePairing>> | null> {
if (params.pairedReconnect) {
return params.reapprovalCoordinator
? await params.reapprovalCoordinator.request({
input: params.input,
cleanupClaim: params.cleanupClaim,
})
: await requestNodePairing(params.input);
}
if (!params.rateLimiter) {
return await requestNodePairing(params.input);
}
@@ -362,6 +378,7 @@ export type GatewayWsMessageHandlerParams = {
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
browserRateLimiter?: AuthRateLimiter;
nodeReapprovalCoordinator?: NodeReapprovalCoordinator;
isStartupPending?: () => boolean;
gatewayMethods: string[];
events: string[];
@@ -406,6 +423,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
getRequiredSharedGatewaySessionGeneration,
rateLimiter,
browserRateLimiter,
nodeReapprovalCoordinator,
isStartupPending,
gatewayMethods,
events,
@@ -539,6 +557,42 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
const text = rawDataToString(data);
let pendingNodePairingCleanup: NodePairingCleanupClaim | undefined;
const broadcastNodePairingResult = (result: RequestNodePairingResult) => {
const context = buildRequestContext();
const resolvedAt = Date.now();
for (const superseded of result.created ? (result.superseded ?? []) : []) {
context.broadcast(
"node.pair.resolved",
{
requestId: superseded.requestId,
nodeId: superseded.nodeId,
decision: "rejected",
ts: resolvedAt,
},
{ dropIfSlow: true },
);
}
if (result.created) {
context.broadcast("node.pair.requested", result.request, {
dropIfSlow: true,
});
}
};
const releasePendingNodePairingCleanup = async () => {
const claim = pendingNodePairingCleanup;
pendingNodePairingCleanup = undefined;
if (!claim) {
return;
}
try {
await releaseNodePairingCleanupClaim(claim);
} catch (error) {
logGateway.warn(
`failed to release pending pairing cleanup for ${claim.nodeId}: ${formatForLog(error)}`,
);
}
};
try {
const parsed = JSON.parse(text);
const frameType =
@@ -1614,10 +1668,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
}
if (role === "node") {
const nodeId = connectParams.device?.id ?? connectParams.client.id;
const nodePairingSnapshot = await beginNodePairingConnect(nodeId);
const pairedNode = nodePairingSnapshot.pairedNode;
pendingNodePairingCleanup = nodePairingSnapshot.cleanupClaim;
let reconciliation: Awaited<ReturnType<typeof reconcileNodePairingOnConnect>>;
const pairedNode = await getPairedNode(
connectParams.device?.id ?? connectParams.client.id,
);
try {
reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(),
@@ -1625,23 +1680,18 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
pairedNode,
reportedClientIp,
requestPairing: async (input) => {
try {
return await requestNodePairingFromConnect({
input,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp,
});
} catch (error) {
if (error instanceof NodePairingRateLimitError && pairedNode) {
// Paired upgrade reconnects can keep their approved surface;
// only the fresh pending request is throttled here.
return null;
}
throw error;
}
return await requestNodePairingFromConnect({
input,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp,
pairedReconnect: pairedNode !== null,
cleanupClaim: pendingNodePairingCleanup,
reapprovalCoordinator: nodeReapprovalCoordinator,
});
},
});
} catch (error) {
await releasePendingNodePairingCleanup();
if (error instanceof NodePairingRateLimitError) {
rejectUnauthorized({
ok: false,
@@ -1653,24 +1703,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
throw error;
}
if (reconciliation.pendingPairing?.created) {
const requestContext = buildRequestContext();
const resolvedAt = Date.now();
for (const superseded of reconciliation.pendingPairing.superseded ?? []) {
requestContext.broadcast(
"node.pair.resolved",
{
requestId: superseded.requestId,
nodeId: superseded.nodeId,
decision: "rejected",
ts: resolvedAt,
},
{ dropIfSlow: true },
);
}
requestContext.broadcast("node.pair.requested", reconciliation.pendingPairing.request, {
dropIfSlow: true,
});
if (!reconciliation.shouldClearPendingPairings) {
await releasePendingNodePairingCleanup();
}
if (reconciliation.pendingPairing) {
broadcastNodePairingResult(reconciliation.pendingPairing);
}
const nodeConnectParams = connectParams as ConnectParams & {
declaredCaps?: string[];
@@ -1691,6 +1728,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
if (isClosed()) {
await releasePendingNodePairingCleanup();
setCloseCause("connect-aborted-before-register", {
...clientMeta,
auth: authMethod,
@@ -1784,6 +1822,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
gatewayVersion,
},
});
await releasePendingNodePairingCleanup();
close(1008, "client version mismatch");
return;
}
@@ -1791,6 +1830,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
if (!setClient(nextClient)) {
await releasePendingNodePairingCleanup();
setCloseCause("connect-aborted-before-register", {
...clientMeta,
auth: authMethod,
@@ -1974,10 +2014,38 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
);
}
}
await releasePendingNodePairingCleanup();
setCloseCause("hello-send-failed", { error: formatForLog(err) });
close();
return;
}
if (pendingNodePairingCleanup) {
const context = buildRequestContext();
const cleanupClaim = pendingNodePairingCleanup;
pendingNodePairingCleanup = undefined;
try {
const resolvedPairings = nodeReapprovalCoordinator
? await nodeReapprovalCoordinator.finalizeCleanup(cleanupClaim)
: await finalizeNodePairingCleanupClaim(cleanupClaim);
const resolvedAt = Date.now();
for (const resolved of resolvedPairings) {
context.broadcast(
"node.pair.resolved",
{
requestId: resolved.requestId,
nodeId: resolved.nodeId,
decision: "rejected",
ts: resolvedAt,
},
{ dropIfSlow: true },
);
}
} catch (error) {
logGateway.warn(
`failed to clear stale pending pairings for ${cleanupClaim.nodeId}: ${formatForLog(error)}`,
);
}
}
logWs("out", "hello-ok", {
connId,
methods: gatewayMethods.length,
@@ -2107,6 +2175,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
void dispatch;
} catch (err) {
await releasePendingNodePairingCleanup();
logGateway.error(`parse/handle error: ${String(err)}`);
logWs("out", "parse-error", { connId, error: formatForLog(err) });
if (!getClient()) {
+244
View File
@@ -4,10 +4,14 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import {
approveNodePairing,
beginNodePairingConnect,
finalizeNodePairingCleanupClaim,
getPairedNode,
listNodePairing,
releaseNodePairingCleanupClaim,
removePairedNode,
requestNodePairing,
reusePendingNodePairingForReconnect,
updatePairedNodeMetadata,
verifyNodeToken,
} from "./node-pairing.js";
@@ -87,6 +91,8 @@ describe("node pairing tokens", () => {
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.request.requestId).toBe(first.request.requestId);
expect("revision" in first.request).toBe(false);
expect("revision" in second.request).toBe(false);
const commandFirst = await requestNodePairing(
{
@@ -149,6 +155,7 @@ describe("node pairing tokens", () => {
const pendingNode = findRecordByField(pairing.pending, "nodeId", "node-4");
expect(pendingNode.commands).toEqual(["canvas.present"]);
expect(pendingNode.requiredApproveScopes).toEqual(["operator.pairing", "operator.write"]);
expect("revision" in pendingNode).toBe(false);
expect(pairing.paired).toEqual([]);
});
});
@@ -327,6 +334,243 @@ describe("node pairing tokens", () => {
});
});
test("rejects every pending request for one node without removing its approval", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([
{ requestId: pending.request.requestId, nodeId: "node-1" },
]);
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
const pairing = await listNodePairing(baseDir);
expect(pairing.pending).toEqual([]);
expect(pairing.paired).toHaveLength(1);
expect(pairing.paired[0]?.nodeId).toBe("node-1");
await expect(getPairedNode("node-1", baseDir)).resolves.toMatchObject({
commands: ["system.run"],
});
});
});
test("preserves a pending request refreshed after the connect snapshot", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
const refreshed = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
expect(refreshed.request.requestId).toBe(pending.request.requestId);
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
expect((await listNodePairing(baseDir)).pending).toHaveLength(1);
});
});
test("reuses an unchanged reconnect request without leaving stale cleanup ownership", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
await expect(
reusePendingNodePairingForReconnect(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
snapshot.cleanupClaim,
baseDir,
),
).resolves.toMatchObject({
request: { requestId: pending.request.requestId },
created: false,
});
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toMatchObject({ requestId: pending.request.requestId });
});
});
test("does not reuse a reconnect request when pending metadata changed", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
displayName: "Old Name",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
await expect(
reusePendingNodePairingForReconnect(
{
nodeId: "node-1",
platform: "darwin",
displayName: "New Name",
commands: ["system.run", "canvas.snapshot"],
},
snapshot.cleanupClaim,
baseDir,
),
).resolves.toBeNull();
if (snapshot.cleanupClaim) {
await releaseNodePairingCleanupClaim(snapshot.cleanupClaim);
}
});
});
test("preserves newer cleanup ownership after an older reconnect reuses pending state", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const matchingReconnect = await beginNodePairingConnect("node-1", baseDir);
const changedReconnect = await beginNodePairingConnect("node-1", baseDir);
expect(matchingReconnect.cleanupClaim).toBeDefined();
expect(changedReconnect.cleanupClaim).toBeDefined();
await reusePendingNodePairingForReconnect(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
matchingReconnect.cleanupClaim,
baseDir,
);
await expect(
finalizeNodePairingCleanupClaim(changedReconnect.cleanupClaim!),
).resolves.toEqual([{ requestId: pending.request.requestId, nodeId: "node-1" }]);
expect((await listNodePairing(baseDir)).pending).toEqual([]);
});
});
test("preserves a replacement pending request created after the connect snapshot", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const snapshot = await beginNodePairingConnect("node-1", baseDir);
expect(snapshot.cleanupClaim).toBeDefined();
const replacement = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.present"],
},
baseDir,
);
expect(replacement.request.requestId).not.toBe(pending.request.requestId);
await expect(finalizeNodePairingCleanupClaim(snapshot.cleanupClaim!)).resolves.toEqual([]);
const remaining = (await listNodePairing(baseDir)).pending;
expect(remaining).toHaveLength(1);
expect(remaining[0]?.requestId).toBe(replacement.request.requestId);
});
});
test("blocks approval until a reconnect cleanup claim is released", async () => {
await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir);
const pending = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
commands: ["system.run", "canvas.snapshot"],
},
baseDir,
);
const firstSnapshot = await beginNodePairingConnect("node-1", baseDir);
const secondSnapshot = await beginNodePairingConnect("node-1", baseDir);
expect(firstSnapshot.cleanupClaim).toBeDefined();
expect(secondSnapshot.cleanupClaim?.generation).toBeGreaterThan(
firstSnapshot.cleanupClaim!.generation,
);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toBeNull();
await releaseNodePairingCleanupClaim(firstSnapshot.cleanupClaim!);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toBeNull();
await releaseNodePairingCleanupClaim(secondSnapshot.cleanupClaim!);
await expect(
approveNodePairing(
pending.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
),
).resolves.toMatchObject({ requestId: pending.request.requestId });
});
});
test("requires the right scopes to approve node requests", async () => {
await withNodePairingDir(async (baseDir) => {
const systemRunRequest = await requestNodePairing(
+255 -13
View File
@@ -47,6 +47,23 @@ export type NodePairingPendingRequest = NodePairingRequestInput & {
ts: number;
};
type NodePairingPendingRecord = NodePairingPendingRequest & {
revision?: string;
};
export type NodePairingPendingSnapshot = Pick<NodePairingPendingRequest, "requestId" | "nodeId"> & {
revision?: string;
};
/** Opaque claim preventing approval while a reconnect resolves stale pending state. */
export type NodePairingCleanupClaim = {
baseDir: string | undefined;
generation: number;
nodeId: string;
pendingPath: string;
observed: NodePairingPendingSnapshot[];
};
/** Pending request summary returned when a new approval surface supersedes older requests. */
export type NodePairingSupersededRequest = Pick<NodePairingPendingRequest, "requestId" | "nodeId">;
@@ -79,7 +96,7 @@ type NodePairingList = {
};
type NodePairingStateFile = {
pendingById: Record<string, NodePairingPendingRequest>;
pendingById: Record<string, NodePairingPendingRecord>;
pairedByNodeId: Record<string, NodePairingPairedNode>;
};
@@ -87,13 +104,16 @@ const PENDING_TTL_MS = 5 * 60 * 1000;
const OPERATOR_ROLE = "operator";
const withLock = createAsyncLock();
const activeCleanupRevisionClaims = new Map<string, Set<number>>();
let nextCleanupClaimGeneration = 0;
function buildPendingNodePairingRequest(params: {
requestId?: string;
req: NodePairingRequestInput;
}): NodePairingPendingRequest {
}): NodePairingPendingRecord {
return {
requestId: params.requestId ?? randomUUID(),
revision: randomUUID(),
nodeId: params.req.nodeId,
clientId: params.req.clientId,
clientMode: params.req.clientMode,
@@ -114,11 +134,12 @@ function buildPendingNodePairingRequest(params: {
}
function refreshPendingNodePairingRequest(
existing: NodePairingPendingRequest,
existing: NodePairingPendingRecord,
incoming: NodePairingRequestInput,
): NodePairingPendingRequest {
): NodePairingPendingRecord {
return {
...existing,
revision: randomUUID(),
clientId: incoming.clientId ?? existing.clientId,
clientMode: incoming.clientMode ?? existing.clientMode,
displayName: incoming.displayName ?? existing.displayName,
@@ -139,7 +160,7 @@ function refreshPendingNodePairingRequest(
}
function samePendingApprovalSurface(
existing: NodePairingPendingRequest,
existing: NodePairingPendingRecord,
incoming: NodePairingRequestInput,
): boolean {
const incomingCaps = normalizeArrayBackedTrimmedStringList(incoming.caps) ?? existing.caps;
@@ -154,8 +175,27 @@ function samePendingApprovalSurface(
);
}
function samePendingReconnectMetadata(
existing: NodePairingPendingRecord,
incoming: NodePairingRequestInput,
): boolean {
return (
(incoming.clientId ?? existing.clientId) === existing.clientId &&
(incoming.clientMode ?? existing.clientMode) === existing.clientMode &&
(incoming.displayName ?? existing.displayName) === existing.displayName &&
(incoming.platform ?? existing.platform) === existing.platform &&
(incoming.version ?? existing.version) === existing.version &&
(incoming.coreVersion ?? existing.coreVersion) === existing.coreVersion &&
(incoming.uiVersion ?? existing.uiVersion) === existing.uiVersion &&
(incoming.deviceFamily ?? existing.deviceFamily) === existing.deviceFamily &&
(incoming.modelIdentifier ?? existing.modelIdentifier) === existing.modelIdentifier &&
(incoming.remoteIp ?? existing.remoteIp) === existing.remoteIp &&
Boolean(existing.silent && incoming.silent) === Boolean(existing.silent)
);
}
function mergeNodePairingReplacementInput(params: {
existing: readonly NodePairingPendingRequest[];
existing: readonly NodePairingPendingRecord[];
incoming: NodePairingRequestInput;
}): NodePairingRequestInput {
const latest = params.existing[0];
@@ -180,16 +220,34 @@ function mergeNodePairingReplacementInput(params: {
};
}
function resolveNodeApprovalRequiredScopes(
pending: NodePairingPendingRequest,
): NodeApprovalScope[] {
function resolveNodeApprovalRequiredScopes(pending: NodePairingPendingRecord): NodeApprovalScope[] {
const commands = Array.isArray(pending.commands) ? pending.commands : [];
return resolveNodePairApprovalScopes(commands);
}
function toPendingNodePairingEntry(pending: NodePairingPendingRequest): NodePairingPendingEntry {
function toPublicPendingNodePairingRequest(
pending: NodePairingPendingRecord,
): NodePairingPendingRequest {
const { revision: _revision, ...request } = pending;
return request;
}
function toPendingNodePairingSnapshot(
pending: NodePairingPendingRecord,
): NodePairingPendingSnapshot {
const snapshot: NodePairingPendingSnapshot = {
requestId: pending.requestId,
nodeId: pending.nodeId,
};
if (pending.revision) {
snapshot.revision = pending.revision;
}
return snapshot;
}
function toPendingNodePairingEntry(pending: NodePairingPendingRecord): NodePairingPendingEntry {
return {
...pending,
...toPublicPendingNodePairingRequest(pending),
requiredApproveScopes: resolveNodeApprovalRequiredScopes(pending),
};
}
@@ -205,7 +263,7 @@ async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
readJsonIfExists<unknown>(pairedPath),
]);
const state: NodePairingStateFile = {
pendingById: coercePairingStateRecord<NodePairingPendingRequest>(pending),
pendingById: coercePairingStateRecord<NodePairingPendingRecord>(pending),
pairedByNodeId: coercePairingStateRecord<NodePairingPairedNode>(paired),
};
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
@@ -224,6 +282,61 @@ function normalizeNodeId(nodeId: string) {
return nodeId.trim();
}
function buildCleanupRevisionClaimKey(
pendingPath: string,
observed: NodePairingPendingSnapshot,
): string {
return `${pendingPath}\0${observed.requestId}\0${observed.revision ?? ""}`;
}
function addCleanupClaim(claim: NodePairingCleanupClaim): void {
for (const observed of claim.observed) {
const key = buildCleanupRevisionClaimKey(claim.pendingPath, observed);
const generations = activeCleanupRevisionClaims.get(key) ?? new Set<number>();
generations.add(claim.generation);
activeCleanupRevisionClaims.set(key, generations);
}
}
function cleanupClaimIsActive(claim: NodePairingCleanupClaim): boolean {
return claim.observed.some((observed) => {
const key = buildCleanupRevisionClaimKey(claim.pendingPath, observed);
return activeCleanupRevisionClaims.get(key)?.has(claim.generation) === true;
});
}
function removeCleanupClaim(claim: NodePairingCleanupClaim): void {
for (const observed of claim.observed) {
const key = buildCleanupRevisionClaimKey(claim.pendingPath, observed);
const generations = activeCleanupRevisionClaims.get(key);
generations?.delete(claim.generation);
if (!generations || generations.size === 0) {
activeCleanupRevisionClaims.delete(key);
}
}
}
function invalidateCleanupClaimsThrough(
claim: NodePairingCleanupClaim,
pending: NodePairingPendingRecord,
baseDir: string | undefined,
): void {
const pendingPath = resolvePairingPaths(baseDir, "nodes").pendingPath;
const key = buildCleanupRevisionClaimKey(pendingPath, toPendingNodePairingSnapshot(pending));
const generations = activeCleanupRevisionClaims.get(key);
if (!generations) {
return;
}
for (const generation of generations) {
if (generation <= claim.generation) {
generations.delete(generation);
}
}
if (generations.size === 0) {
activeCleanupRevisionClaims.delete(key);
}
}
function newToken() {
return generatePairingToken();
}
@@ -248,6 +361,93 @@ export async function getPairedNode(
return state.pairedByNodeId[normalizeNodeId(nodeId)] ?? null;
}
/** Snapshot pairing state and claim current pending revisions for one paired reconnect. */
export async function beginNodePairingConnect(
nodeId: string,
baseDir?: string,
): Promise<{
pairedNode: NodePairingPairedNode | null;
cleanupClaim?: NodePairingCleanupClaim;
}> {
return await withLock(async () => {
const state = await loadState(baseDir);
const normalized = normalizeNodeId(nodeId);
const pairedNode = state.pairedByNodeId[normalized] ?? null;
const observed = Object.values(state.pendingById)
.filter((entry) => entry.nodeId === normalized)
.map(toPendingNodePairingSnapshot);
if (!pairedNode || observed.length === 0) {
return { pairedNode };
}
const pendingPath = resolvePairingPaths(baseDir, "nodes").pendingPath;
const claim: NodePairingCleanupClaim = {
baseDir,
generation: ++nextCleanupClaimGeneration,
nodeId: normalized,
pendingPath,
observed,
};
addCleanupClaim(claim);
return { pairedNode, cleanupClaim: claim };
});
}
function pendingHasActiveCleanupClaim(
pending: NodePairingPendingRecord,
baseDir: string | undefined,
): boolean {
const pendingPath = resolvePairingPaths(baseDir, "nodes").pendingPath;
const key = buildCleanupRevisionClaimKey(pendingPath, toPendingNodePairingSnapshot(pending));
return (activeCleanupRevisionClaims.get(key)?.size ?? 0) > 0;
}
/** Release a reconnect cleanup claim without changing pending pairing state. */
export async function releaseNodePairingCleanupClaim(
claim: NodePairingCleanupClaim,
): Promise<void> {
await withLock(async () => {
removeCleanupClaim(claim);
});
}
/** Delete pending revisions claimed by a reconnect after hello succeeds. */
export async function finalizeNodePairingCleanupClaim(
claim: NodePairingCleanupClaim,
): Promise<NodePairingSupersededRequest[]> {
return await withLock(async () => {
if (!cleanupClaimIsActive(claim)) {
return [];
}
try {
const state = await loadState(claim.baseDir);
const observedById = new Map(
claim.observed
.filter((entry) => entry.nodeId === claim.nodeId)
.map((entry) => [entry.requestId, entry] as const),
);
const rejected = Object.values(state.pendingById)
.filter((pending) => {
const observed = observedById.get(pending.requestId);
return observed !== undefined && observed.revision === pending.revision;
})
.toSorted((left, right) => right.ts - left.ts);
if (rejected.length === 0) {
return [];
}
for (const pending of rejected) {
delete state.pendingById[pending.requestId];
}
await persistState(state, claim.baseDir);
return rejected.map((pending) => ({
requestId: pending.requestId,
nodeId: pending.nodeId,
}));
} finally {
removeCleanupClaim(claim);
}
});
}
/** Create or refresh a pending node pairing request for operator approval. */
export async function requestNodePairing(
req: NodePairingRequestInput,
@@ -282,7 +482,44 @@ export async function requestNodePairing(
.filter((pending) => pending.requestId !== result.request.requestId)
.map((pending) => ({ requestId: pending.requestId, nodeId: pending.nodeId }))
: [];
return superseded.length > 0 ? { ...result, superseded } : result;
const publicResult = {
...result,
request: toPublicPendingNodePairingRequest(result.request),
};
return superseded.length > 0 ? { ...publicResult, superseded } : publicResult;
});
}
/** Reuse an unchanged reconnect request without refreshing or writing pairing state. */
export async function reusePendingNodePairingForReconnect(
req: NodePairingRequestInput,
cleanupClaim: NodePairingCleanupClaim | undefined,
baseDir?: string,
): Promise<RequestNodePairingResult | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const nodeId = normalizeNodeId(req.nodeId);
const pendingForNode = Object.values(state.pendingById)
.filter((pending) => pending.nodeId === nodeId)
.toSorted((left, right) => right.ts - left.ts);
if (
pendingForNode.length === 1 &&
samePendingApprovalSurface(pendingForNode[0], { ...req, nodeId }) &&
samePendingReconnectMetadata(pendingForNode[0], req)
) {
const pending = pendingForNode[0];
// The unchanged reconnect supersedes older cleanup ownership without
// refreshing the request or writing pairing state.
if (cleanupClaim) {
invalidateCleanupClaimsThrough(cleanupClaim, pending, baseDir);
}
return {
status: "pending",
request: toPublicPendingNodePairingRequest(pending),
created: false,
};
}
return null;
});
}
@@ -298,6 +535,11 @@ export async function approveNodePairing(
if (!pending) {
return null;
}
// A paired reconnect has atomically observed this revision as stale.
// Approval can resume if the handshake fails and releases its claim.
if (pendingHasActiveCleanupClaim(pending, baseDir)) {
return null;
}
const requiredScopes = resolveNodeApprovalRequiredScopes(pending);
const missingScope = resolveMissingRequestedScope({
role: OPERATOR_ROLE,
+5
View File
@@ -15,6 +15,11 @@ export type NodeListNode = {
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
approvalState?: "approved" | "pending-approval" | "pending-reapproval" | "unapproved";
pendingRequestId?: string;
pendingDeclaredCaps?: string[];
pendingDeclaredCommands?: string[];
pendingDeclaredPermissions?: Record<string, boolean>;
paired?: boolean;
connected?: boolean;
connectedAtMs?: number;