Skip to content

Commit b95d3b3

Browse files
committed
fix(cli): request node approval scopes
1 parent a002c41 commit b95d3b3

4 files changed

Lines changed: 206 additions & 9 deletions

File tree

src/cli/nodes-cli/register.pairing.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,67 @@
11
import type { Command } from "commander";
2+
import type { OperatorScope } from "../../gateway/method-scopes.js";
3+
import { resolveNodePairApprovalScopes } from "../../infra/node-pairing-authz.js";
24
import { defaultRuntime } from "../../runtime.js";
35
import { normalizeOptionalString } from "../../shared/string-coerce.js";
46
import { getTerminalTableWidth } from "../../terminal/table.js";
57
import { formatCliCommand } from "../command-format.js";
68
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
79
import { parsePairingList } from "./format.js";
810
import { renderPendingPairingRequestsTable } from "./pairing-render.js";
9-
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
10-
import type { NodesRpcOpts } from "./types.js";
11+
import {
12+
callGatewayCli,
13+
callNodePairApprovalGatewayCli,
14+
nodesCallOpts,
15+
resolveNodeId,
16+
} from "./rpc.js";
17+
import type { NodesRpcOpts, PendingRequest } from "./types.js";
18+
19+
const DEFAULT_NODE_PAIR_APPROVE_SCOPES: OperatorScope[] = ["operator.pairing"];
20+
const NODE_PAIR_APPROVE_SCOPE_SET = new Set<OperatorScope>([
21+
"operator.pairing",
22+
"operator.write",
23+
"operator.admin",
24+
]);
25+
26+
function normalizeNodePairApproveScopes(scopes: unknown): OperatorScope[] {
27+
const normalized = new Set<OperatorScope>(DEFAULT_NODE_PAIR_APPROVE_SCOPES);
28+
if (!Array.isArray(scopes)) {
29+
return [...normalized];
30+
}
31+
for (const scope of scopes) {
32+
if (typeof scope !== "string") {
33+
continue;
34+
}
35+
if (!NODE_PAIR_APPROVE_SCOPE_SET.has(scope as OperatorScope)) {
36+
continue;
37+
}
38+
normalized.add(scope as OperatorScope);
39+
}
40+
return [...normalized];
41+
}
42+
43+
async function resolveApproveScopesForRequest(
44+
opts: NodesRpcOpts,
45+
requestId: string,
46+
): Promise<OperatorScope[]> {
47+
try {
48+
const result = await callNodePairApprovalGatewayCli(
49+
"node.pair.list",
50+
opts,
51+
{},
52+
{ scopes: DEFAULT_NODE_PAIR_APPROVE_SCOPES },
53+
);
54+
const { pending } = parsePairingList(result);
55+
const request = pending.find((candidate: PendingRequest) => candidate.requestId === requestId);
56+
const scopes = normalizeNodePairApproveScopes(request?.requiredApproveScopes);
57+
if (scopes.length > DEFAULT_NODE_PAIR_APPROVE_SCOPES.length) {
58+
return scopes;
59+
}
60+
return resolveNodePairApprovalScopes(request?.commands) as OperatorScope[];
61+
} catch {
62+
return [...DEFAULT_NODE_PAIR_APPROVE_SCOPES];
63+
}
64+
}
1165

1266
export function registerNodesPairingCommands(nodes: Command) {
1367
nodesCallOpts(
@@ -49,9 +103,17 @@ export function registerNodesPairingCommands(nodes: Command) {
49103
.argument("<requestId>", "Pending request id")
50104
.action(async (requestId: string, opts: NodesRpcOpts) => {
51105
await runNodesCommand("approve", async () => {
52-
const result = await callGatewayCli("node.pair.approve", opts, {
53-
requestId,
54-
});
106+
const scopes = await resolveApproveScopesForRequest(opts, requestId);
107+
const result = await callNodePairApprovalGatewayCli(
108+
"node.pair.approve",
109+
opts,
110+
{
111+
requestId,
112+
},
113+
{
114+
scopes,
115+
},
116+
);
55117
defaultRuntime.writeJson(result);
56118
});
57119
}),

src/cli/nodes-cli/rpc.runtime.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { callGateway } from "../../gateway/call.js";
2+
import type { OperatorScope } from "../../gateway/method-scopes.js";
23
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../gateway/protocol/client-info.js";
34
import { withProgress } from "../progress.js";
45
import type { NodesRpcOpts } from "./types.js";
56

7+
const NODE_PAIR_APPROVAL_GATEWAY_METHODS = new Set<string>(["node.pair.list", "node.pair.approve"]);
8+
69
export async function callGatewayCliRuntime(
710
method: string,
811
opts: NodesRpcOpts,
@@ -27,3 +30,34 @@ export async function callGatewayCliRuntime(
2730
}),
2831
);
2932
}
33+
34+
export async function callNodePairApprovalGatewayCliRuntime(
35+
method: "node.pair.list" | "node.pair.approve",
36+
opts: NodesRpcOpts,
37+
params: unknown,
38+
callOpts: { scopes: OperatorScope[]; transportTimeoutMs?: number },
39+
) {
40+
if (!NODE_PAIR_APPROVAL_GATEWAY_METHODS.has(method)) {
41+
throw new Error(`unsupported node pair approval gateway method: ${method}`);
42+
}
43+
// Node approval may need the local gateway's backend shared-auth authority
44+
// before the CLI device has been granted the node's required operator scopes.
45+
return await withProgress(
46+
{
47+
label: `Nodes ${method}`,
48+
indeterminate: true,
49+
enabled: opts.json !== true,
50+
},
51+
async () =>
52+
await callGateway({
53+
url: opts.url,
54+
token: opts.token,
55+
method,
56+
params,
57+
timeoutMs: callOpts.transportTimeoutMs ?? Number(opts.timeout ?? 10_000),
58+
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
59+
mode: GATEWAY_CLIENT_MODES.BACKEND,
60+
scopes: callOpts.scopes,
61+
}),
62+
);
63+
}

src/cli/nodes-cli/rpc.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { randomUUID } from "node:crypto";
22
import type { Command } from "commander";
3+
import type { OperatorScope } from "../../gateway/method-scopes.js";
34
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
45
import { resolveNodeFromNodeList } from "../../shared/node-resolve.js";
56
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
@@ -33,6 +34,16 @@ export const callGatewayCli = async (
3334
return await runtime.callGatewayCliRuntime(method, opts, params, callOpts);
3435
};
3536

37+
export const callNodePairApprovalGatewayCli = async (
38+
method: "node.pair.list" | "node.pair.approve",
39+
opts: NodesRpcOpts,
40+
params: unknown,
41+
callOpts: { scopes: OperatorScope[]; transportTimeoutMs?: number },
42+
) => {
43+
const runtime = await loadNodesCliRpcRuntime();
44+
return await runtime.callNodePairApprovalGatewayCliRuntime(method, opts, params, callOpts);
45+
};
46+
3647
export function buildNodeInvokeParams(params: {
3748
nodeId: string;
3849
command: string;

src/cli/program.nodes-basic.e2e.test.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ installBaseProgramMocks();
88
let registerNodesCli: typeof import("./nodes-cli.js").registerNodesCli;
99

1010
type GatewayCallRequest = {
11+
clientName?: string;
1112
method?: string;
13+
mode?: string;
1214
params?: unknown;
15+
scopes?: unknown;
1316
};
1417

1518
function formatRuntimeLogCallArg(value: unknown): string {
@@ -434,19 +437,103 @@ describe("cli program (nodes basics)", () => {
434437

435438
expectGatewayRequest("node.list", {});
436439
expectGatewayRequest("node.describe", { nodeId: "ios-node" });
440+
const describeRequest = gatewayRequests().find(
441+
(candidate) => candidate.method === "node.describe",
442+
);
443+
expect(describeRequest?.clientName).toBe("cli");
444+
expect(describeRequest?.mode).toBe("cli");
437445

438446
const out = getRuntimeOutput();
439447
expect(out).toContain("Commands");
440448
expect(out).toContain("canvas.eval");
441449
});
442450

443-
it("runs nodes approve and calls node.pair.approve", async () => {
444-
callGateway.mockResolvedValue({
445-
requestId: "r1",
446-
node: { nodeId: "n1", token: "t1" },
451+
it("runs nodes approve with the pending request approval scopes", async () => {
452+
callGateway.mockImplementation(async (...args: unknown[]) => {
453+
const opts = (args[0] ?? {}) as { method?: string };
454+
if (opts.method === "node.pair.list") {
455+
return {
456+
pending: [
457+
{
458+
requestId: "r1",
459+
nodeId: "n1",
460+
ts: Date.now(),
461+
requiredApproveScopes: ["operator.pairing", "operator.admin"],
462+
},
463+
],
464+
paired: [],
465+
};
466+
}
467+
if (opts.method === "node.pair.approve") {
468+
return {
469+
requestId: "r1",
470+
node: { nodeId: "n1", token: "t1" },
471+
};
472+
}
473+
return { ok: true };
447474
});
475+
448476
await runProgram(["nodes", "approve", "r1"]);
477+
expectGatewayRequest("node.pair.list", {});
449478
expectGatewayRequest("node.pair.approve", { requestId: "r1" });
479+
const listRequest = gatewayRequests().find(
480+
(candidate) => candidate.method === "node.pair.list",
481+
);
482+
const approveRequest = gatewayRequests().find(
483+
(candidate) => candidate.method === "node.pair.approve",
484+
);
485+
expect(listRequest?.clientName).toBe("gateway-client");
486+
expect(listRequest?.mode).toBe("backend");
487+
expect(approveRequest?.scopes).toEqual(["operator.pairing", "operator.admin"]);
488+
expect(approveRequest?.clientName).toBe("gateway-client");
489+
expect(approveRequest?.mode).toBe("backend");
490+
});
491+
492+
it("falls back to command-derived nodes approve scopes", async () => {
493+
callGateway.mockImplementation(async (...args: unknown[]) => {
494+
const opts = (args[0] ?? {}) as { method?: string };
495+
if (opts.method === "node.pair.list") {
496+
return {
497+
pending: [
498+
{
499+
requestId: "r1",
500+
nodeId: "n1",
501+
ts: Date.now(),
502+
commands: ["system.run"],
503+
},
504+
],
505+
paired: [],
506+
};
507+
}
508+
if (opts.method === "node.pair.approve") {
509+
return {
510+
requestId: "r1",
511+
node: { nodeId: "n1", token: "t1" },
512+
};
513+
}
514+
return { ok: true };
515+
});
516+
517+
await runProgram(["nodes", "approve", "r1"]);
518+
519+
const approveRequest = gatewayRequests().find(
520+
(candidate) => candidate.method === "node.pair.approve",
521+
);
522+
expect(approveRequest?.scopes).toEqual(["operator.pairing", "operator.admin"]);
523+
});
524+
525+
it("rejects unsupported node approval backend methods at runtime", async () => {
526+
const { callNodePairApprovalGatewayCliRuntime } = await import("./nodes-cli/rpc.runtime.js");
527+
528+
await expect(
529+
callNodePairApprovalGatewayCliRuntime(
530+
"node.invoke" as never,
531+
{ json: true },
532+
{},
533+
{ scopes: ["operator.admin"] },
534+
),
535+
).rejects.toThrow("unsupported node pair approval gateway method: node.invoke");
536+
expect(callGateway).not.toHaveBeenCalled();
450537
});
451538

452539
it("runs nodes remove and calls node.pair.remove", async () => {
@@ -500,5 +587,8 @@ describe("cli program (nodes basics)", () => {
500587
timeoutMs: 15000,
501588
idempotencyKey: "idem-test",
502589
});
590+
const invokeRequest = gatewayRequests().find((candidate) => candidate.method === "node.invoke");
591+
expect(invokeRequest?.clientName).toBe("cli");
592+
expect(invokeRequest?.mode).toBe("cli");
503593
});
504594
});

0 commit comments

Comments
 (0)