@syengup/friday-channel-next 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ export declare function loadNodePairingModule(): {
2
+ listNodePairing: Function;
3
+ approveNodePairing: Function;
4
+ };
5
+ /** Vitest-only: inject mock pairing functions. */
6
+ export declare function __setMockNodePairingForTests(mock: {
7
+ listNodePairing: Function;
8
+ approveNodePairing: Function;
9
+ }): void;
@@ -0,0 +1,45 @@
1
+ import { createRequire } from "node:module";
2
+ import { readdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ let cache = null;
5
+ function resolveOpenClawDist() {
6
+ // Resolve any known openclaw SDK module to find the dist directory.
7
+ // This works cross-platform since the gateway's module loader
8
+ // maps `openclaw/*` to the installed dist.
9
+ const gatewayRequire = createRequire(import.meta.url);
10
+ try {
11
+ const corePath = gatewayRequire.resolve("openclaw/plugin-sdk/core");
12
+ return dirname(dirname(corePath)); // dist/plugin-sdk/core.js → dist/
13
+ }
14
+ catch {
15
+ // Fallback for when the plugin runs outside the gateway process.
16
+ // Probe common install paths.
17
+ for (const root of [
18
+ join(process.env.APPDATA ?? "", "npm/node_modules/openclaw/dist"), // Windows npm -g
19
+ "/opt/homebrew/lib/node_modules/openclaw/dist", // macOS Homebrew
20
+ "/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw/dist", // Linux Homebrew
21
+ "/usr/local/lib/node_modules/openclaw/dist", // Unix npm -g
22
+ ]) {
23
+ try {
24
+ readdirSync(root);
25
+ return root;
26
+ }
27
+ catch { }
28
+ }
29
+ throw new Error("OpenClaw dist directory not found");
30
+ }
31
+ }
32
+ export function loadNodePairingModule() {
33
+ if (cache)
34
+ return cache;
35
+ const dist = resolveOpenClawDist();
36
+ const file = readdirSync(dist).find((f) => f.startsWith("node-pairing-") && f.endsWith(".js") && !f.includes("authz"));
37
+ if (!file)
38
+ throw new Error("node-pairing module not found in OpenClaw dist");
39
+ cache = createRequire(join(dist, "_"))(`./${file.replace(/\.js$/, "")}`);
40
+ return cache;
41
+ }
42
+ /** Vitest-only: inject mock pairing functions. */
43
+ export function __setMockNodePairingForTests(mock) {
44
+ cache = mock;
45
+ }
@@ -1,24 +1,7 @@
1
- import { exec } from "node:child_process";
1
+ import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
2
2
  import { readJsonBody } from "../middleware/body.js";
3
3
  import { extractBearerToken } from "../middleware/auth.js";
4
4
  import { createFridayNextLogger } from "../../logging.js";
5
- const EXEC_ENV = process.platform === "win32"
6
- ? process.env
7
- : { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
8
- function execAsync(command, timeoutMs) {
9
- return new Promise((resolve, reject) => {
10
- const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
11
- if (error) {
12
- reject(error);
13
- }
14
- else {
15
- resolve({ stdout, stderr });
16
- }
17
- });
18
- child.stdout?.on("data", () => { });
19
- child.stderr?.on("data", () => { });
20
- });
21
- }
22
5
  export async function handleDeviceApprove(req, res) {
23
6
  const log = createFridayNextLogger("device-approve");
24
7
  if (req.method !== "POST") {
@@ -49,32 +32,18 @@ export async function handleDeviceApprove(req, res) {
49
32
  return true;
50
33
  }
51
34
  const normalizedDeviceId = rawDeviceId.trim().toUpperCase();
52
- let listStdout;
35
+ let pairing;
53
36
  try {
54
- const result = await execAsync("openclaw devices list --json", 15000);
55
- listStdout = result.stdout;
37
+ pairing = await listDevicePairing();
56
38
  }
57
39
  catch (err) {
58
- const stderr = err?.stderr?.trim();
59
- log.error(`devices list failed: ${err instanceof Error ? err.message : String(err)}`);
60
- res.statusCode = 502;
61
- res.setHeader("Content-Type", "application/json");
62
- res.end(JSON.stringify({ error: "Failed to list devices from gateway", detail: stderr || undefined }));
63
- return true;
64
- }
65
- let listData;
66
- try {
67
- listData = JSON.parse(listStdout);
68
- }
69
- catch {
70
- log.error(`devices list returned invalid JSON: ${listStdout.slice(0, 200)}`);
40
+ log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
71
41
  res.statusCode = 502;
72
42
  res.setHeader("Content-Type", "application/json");
73
- res.end(JSON.stringify({ error: "Unexpected response from gateway device list" }));
43
+ res.end(JSON.stringify({ error: "Failed to list devices from gateway" }));
74
44
  return true;
75
45
  }
76
- const pending = listData.pending ?? [];
77
- const match = pending.find((entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId);
46
+ const match = pairing.pending.find((entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId);
78
47
  if (!match) {
79
48
  res.statusCode = 404;
80
49
  res.setHeader("Content-Type", "application/json");
@@ -86,31 +55,30 @@ export async function handleDeviceApprove(req, res) {
86
55
  }
87
56
  const requestId = match.requestId;
88
57
  log.info(`approving deviceId=${normalizedDeviceId} requestId=${requestId}`);
89
- let approveStdout;
58
+ let approved;
90
59
  try {
91
- const result = await execAsync(`openclaw devices approve ${requestId} --json`, 15000);
92
- approveStdout = result.stdout;
60
+ approved = await approveDevicePairing(requestId);
93
61
  }
94
62
  catch (err) {
95
- const stderr = err?.stderr?.trim();
96
- log.error(`devices approve failed: ${err instanceof Error ? err.message : String(err)}`);
63
+ log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
97
64
  res.statusCode = 502;
98
65
  res.setHeader("Content-Type", "application/json");
99
66
  res.end(JSON.stringify({
100
- error: "Device approval command failed",
101
- detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
67
+ error: "Device approval failed",
68
+ detail: err instanceof Error ? err.message : "Unknown error",
102
69
  }));
103
70
  return true;
104
71
  }
105
- let approveData;
106
- try {
107
- approveData = JSON.parse(approveStdout);
72
+ if (!approved) {
73
+ res.statusCode = 404;
74
+ res.setHeader("Content-Type", "application/json");
75
+ res.end(JSON.stringify({ error: "Pending device request not found" }));
76
+ return true;
108
77
  }
109
- catch {
110
- log.error(`devices approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
111
- res.statusCode = 502;
78
+ if (approved.status === "forbidden") {
79
+ res.statusCode = 403;
112
80
  res.setHeader("Content-Type", "application/json");
113
- res.end(JSON.stringify({ error: "Unexpected response from device approval" }));
81
+ res.end(JSON.stringify({ error: `Device approval forbidden: ${approved.reason ?? "unknown"}` }));
114
82
  return true;
115
83
  }
116
84
  res.statusCode = 200;
@@ -118,8 +86,8 @@ export async function handleDeviceApprove(req, res) {
118
86
  res.end(JSON.stringify({
119
87
  ok: true,
120
88
  deviceId: normalizedDeviceId,
121
- requestId: approveData.requestId,
122
- approvedAtMs: approveData.device?.approvedAtMs,
89
+ requestId: approved.requestId,
90
+ approvedAtMs: approved.device?.approvedAtMs,
123
91
  }));
124
92
  return true;
125
93
  }
@@ -1,24 +1,7 @@
1
- import { exec } from "node:child_process";
2
1
  import { readJsonBody } from "../middleware/body.js";
3
2
  import { extractBearerToken } from "../middleware/auth.js";
4
3
  import { createFridayNextLogger } from "../../logging.js";
5
- const EXEC_ENV = process.platform === "win32"
6
- ? process.env
7
- : { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
8
- function execAsync(command, timeoutMs) {
9
- return new Promise((resolve, reject) => {
10
- const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
11
- if (error) {
12
- reject(error);
13
- }
14
- else {
15
- resolve({ stdout, stderr });
16
- }
17
- });
18
- child.stdout?.on("data", () => { });
19
- child.stderr?.on("data", () => { });
20
- });
21
- }
4
+ import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
22
5
  export async function handleNodesApprove(req, res) {
23
6
  const log = createFridayNextLogger("nodes-approve");
24
7
  if (req.method !== "POST") {
@@ -49,28 +32,16 @@ export async function handleNodesApprove(req, res) {
49
32
  return true;
50
33
  }
51
34
  const normalizedNodeId = rawNodeId.trim().toUpperCase();
52
- let listStdout;
53
- try {
54
- const result = await execAsync("openclaw nodes list --json", 15000);
55
- listStdout = result.stdout;
56
- }
57
- catch (err) {
58
- const stderr = err?.stderr?.trim();
59
- log.error(`nodes list failed: ${err instanceof Error ? err.message : String(err)}`);
60
- res.statusCode = 502;
61
- res.setHeader("Content-Type", "application/json");
62
- res.end(JSON.stringify({ error: "Failed to list nodes from gateway", detail: stderr || undefined }));
63
- return true;
64
- }
35
+ const { listNodePairing, approveNodePairing } = loadNodePairingModule();
65
36
  let listData;
66
37
  try {
67
- listData = JSON.parse(listStdout);
38
+ listData = await listNodePairing();
68
39
  }
69
- catch {
70
- log.error(`nodes list returned invalid JSON: ${listStdout.slice(0, 200)}`);
40
+ catch (err) {
41
+ log.error(`listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
71
42
  res.statusCode = 502;
72
43
  res.setHeader("Content-Type", "application/json");
73
- res.end(JSON.stringify({ error: "Unexpected response from gateway node list" }));
44
+ res.end(JSON.stringify({ error: "Failed to list nodes from gateway" }));
74
45
  return true;
75
46
  }
76
47
  const pending = listData.pending ?? [];
@@ -78,31 +49,30 @@ export async function handleNodesApprove(req, res) {
78
49
  if (pendingMatch) {
79
50
  const requestId = pendingMatch.requestId;
80
51
  log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
81
- let approveStdout;
52
+ let approved;
82
53
  try {
83
- const result = await execAsync(`openclaw nodes approve ${requestId} --json`, 15000);
84
- approveStdout = result.stdout;
54
+ approved = await approveNodePairing(requestId, {});
85
55
  }
86
56
  catch (err) {
87
- const stderr = err?.stderr?.trim();
88
- log.error(`nodes approve failed: ${err instanceof Error ? err.message : String(err)}`);
57
+ log.error(`approveNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
89
58
  res.statusCode = 502;
90
59
  res.setHeader("Content-Type", "application/json");
91
60
  res.end(JSON.stringify({
92
- error: "Node approval command failed",
93
- detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
61
+ error: "Node approval failed",
62
+ detail: err instanceof Error ? err.message : "Unknown error",
94
63
  }));
95
64
  return true;
96
65
  }
97
- let approveData;
98
- try {
99
- approveData = JSON.parse(approveStdout);
66
+ if (!approved) {
67
+ res.statusCode = 404;
68
+ res.setHeader("Content-Type", "application/json");
69
+ res.end(JSON.stringify({ error: "Pending node request not found" }));
70
+ return true;
100
71
  }
101
- catch {
102
- log.error(`nodes approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
103
- res.statusCode = 502;
72
+ if ("status" in approved && approved.status === "forbidden") {
73
+ res.statusCode = 403;
104
74
  res.setHeader("Content-Type", "application/json");
105
- res.end(JSON.stringify({ error: "Unexpected response from node approval" }));
75
+ res.end(JSON.stringify({ error: `Node approval forbidden: ${approved.missingScope ?? "unknown"}` }));
106
76
  return true;
107
77
  }
108
78
  res.statusCode = 200;
@@ -110,12 +80,12 @@ export async function handleNodesApprove(req, res) {
110
80
  res.end(JSON.stringify({
111
81
  ok: true,
112
82
  nodeId: normalizedNodeId,
113
- requestId: approveData.requestId,
114
- approvedAtMs: approveData.node?.approvedAtMs,
83
+ requestId: approved.requestId,
84
+ approvedAtMs: approved.node?.approvedAtMs,
115
85
  }));
116
86
  return true;
117
87
  }
118
- // Not in pending — check if already paired with non-empty caps/commands
88
+ // Check if already paired with non-empty caps/commands
119
89
  const paired = listData.paired ?? [];
120
90
  const pairedMatch = paired.find((entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId);
121
91
  if (pairedMatch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,50 @@
1
+ import { createRequire } from "node:module";
2
+ import { readdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+
5
+ let cache: { listNodePairing: Function; approveNodePairing: Function } | null = null;
6
+
7
+ function resolveOpenClawDist(): string {
8
+ // Resolve any known openclaw SDK module to find the dist directory.
9
+ // This works cross-platform since the gateway's module loader
10
+ // maps `openclaw/*` to the installed dist.
11
+ const gatewayRequire = createRequire(import.meta.url);
12
+ try {
13
+ const corePath = gatewayRequire.resolve("openclaw/plugin-sdk/core");
14
+ return dirname(dirname(corePath)); // dist/plugin-sdk/core.js → dist/
15
+ } catch {
16
+ // Fallback for when the plugin runs outside the gateway process.
17
+ // Probe common install paths.
18
+ for (const root of [
19
+ join(process.env.APPDATA ?? "", "npm/node_modules/openclaw/dist"), // Windows npm -g
20
+ "/opt/homebrew/lib/node_modules/openclaw/dist", // macOS Homebrew
21
+ "/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw/dist", // Linux Homebrew
22
+ "/usr/local/lib/node_modules/openclaw/dist", // Unix npm -g
23
+ ]) {
24
+ try { readdirSync(root); return root; } catch {}
25
+ }
26
+ throw new Error("OpenClaw dist directory not found");
27
+ }
28
+ }
29
+
30
+ export function loadNodePairingModule(): {
31
+ listNodePairing: Function;
32
+ approveNodePairing: Function;
33
+ } {
34
+ if (cache) return cache;
35
+ const dist = resolveOpenClawDist();
36
+ const file = readdirSync(dist).find(
37
+ (f) => f.startsWith("node-pairing-") && f.endsWith(".js") && !f.includes("authz"),
38
+ );
39
+ if (!file) throw new Error("node-pairing module not found in OpenClaw dist");
40
+ cache = createRequire(join(dist, "_"))(`./${file.replace(/\.js$/, "")}`);
41
+ return cache!;
42
+ }
43
+
44
+ /** Vitest-only: inject mock pairing functions. */
45
+ export function __setMockNodePairingForTests(mock: {
46
+ listNodePairing: Function;
47
+ approveNodePairing: Function;
48
+ }): void {
49
+ cache = mock;
50
+ }
@@ -4,9 +4,14 @@ import { PassThrough } from "node:stream";
4
4
  import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import { setMockRuntime } from "../../test-support/mock-runtime.js";
6
6
 
7
- const mockExecImpl = vi.hoisted(() => vi.fn());
8
- vi.mock("node:child_process", () => ({
9
- exec: mockExecImpl,
7
+ const { mockList, mockApprove } = vi.hoisted(() => ({
8
+ mockList: vi.fn(),
9
+ mockApprove: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("openclaw/plugin-sdk/device-bootstrap", () => ({
13
+ listDevicePairing: mockList,
14
+ approveDevicePairing: mockApprove,
10
15
  }));
11
16
 
12
17
  import { handleDeviceApprove } from "./device-approve.js";
@@ -30,45 +35,13 @@ function mockReq(method: string, headers: Record<string, string> = {}): PassThro
30
35
  return stream;
31
36
  }
32
37
 
33
- function mockExecSuccess(stdout: string) {
34
- const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
35
- child.stdout = new EventEmitter();
36
- child.stderr = new EventEmitter();
37
- mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: null, stdout: string, stderr: string) => void) => {
38
- cb(null, stdout, "");
39
- return child;
40
- });
41
- }
42
-
43
- function mockExecError(message: string, stderr?: string) {
44
- const err = new Error(message) as Error & { stderr: string };
45
- err.stderr = stderr ?? "";
46
- const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
47
- child.stdout = new EventEmitter();
48
- child.stderr = new EventEmitter();
49
- mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
50
- cb(err);
51
- return child;
52
- });
53
- }
54
-
55
- function mockExecErrorWithStderr(err: Error & { stderr?: string }) {
56
- const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
57
- child.stdout = new EventEmitter();
58
- child.stderr = new EventEmitter();
59
- mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
60
- cb(err);
61
- return child;
62
- });
63
- }
64
-
65
38
  const DEVICE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
66
39
  const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
67
40
 
68
41
  describe("handleDeviceApprove", () => {
69
42
  beforeEach(() => {
70
43
  setMockRuntime();
71
- mockExecImpl.mockReset();
44
+ vi.clearAllMocks();
72
45
  });
73
46
 
74
47
  it("returns 405 on non-POST", async () => {
@@ -106,8 +79,8 @@ describe("handleDeviceApprove", () => {
106
79
  expect(JSON.parse((res as unknown as MockRes).body).error).toContain("deviceId");
107
80
  });
108
81
 
109
- it("returns 502 when devices list CLI fails", async () => {
110
- mockExecError("ENOENT");
82
+ it("returns 502 when listDevicePairing fails", async () => {
83
+ mockList.mockRejectedValueOnce(new Error("ENOENT"));
111
84
 
112
85
  const req = mockReq("POST", { authorization: "Bearer test-token" });
113
86
  const res = new MockRes() as unknown as ServerResponse;
@@ -118,23 +91,22 @@ describe("handleDeviceApprove", () => {
118
91
  expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Failed to list devices");
119
92
  });
120
93
 
121
- it("returns 502 when devices list returns invalid JSON", async () => {
122
- mockExecSuccess("not valid json {{{");
94
+ it("returns 404 when listDevicePairing returns data without matching device", async () => {
95
+ mockList.mockResolvedValueOnce({ pending: [{ requestId: "x", deviceId: "UNMATCHED" }], paired: [] });
123
96
 
124
97
  const req = mockReq("POST", { authorization: "Bearer test-token" });
125
98
  const res = new MockRes() as unknown as ServerResponse;
126
99
  const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
127
100
  req.end(JSON.stringify({ deviceId: DEVICE_ID }));
128
101
  await p;
129
- expect((res as unknown as MockRes).statusCode).toBe(502);
130
- expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response");
102
+ expect((res as unknown as MockRes).statusCode).toBe(404);
131
103
  });
132
104
 
133
105
  it("returns 404 when deviceId not in pending list", async () => {
134
- mockExecSuccess(JSON.stringify({
106
+ mockList.mockResolvedValueOnce({
135
107
  pending: [{ requestId: "uuid-1", deviceId: "OTHER_DEVICE" }],
136
108
  paired: [],
137
- }));
109
+ });
138
110
 
139
111
  const req = mockReq("POST", { authorization: "Bearer test-token" });
140
112
  const res = new MockRes() as unknown as ServerResponse;
@@ -148,7 +120,7 @@ describe("handleDeviceApprove", () => {
148
120
  });
149
121
 
150
122
  it("returns 404 when pending array is empty", async () => {
151
- mockExecSuccess(JSON.stringify({ pending: [], paired: [] }));
123
+ mockList.mockResolvedValueOnce({ pending: [], paired: [] });
152
124
 
153
125
  const req = mockReq("POST", { authorization: "Bearer test-token" });
154
126
  const res = new MockRes() as unknown as ServerResponse;
@@ -158,14 +130,12 @@ describe("handleDeviceApprove", () => {
158
130
  expect((res as unknown as MockRes).statusCode).toBe(404);
159
131
  });
160
132
 
161
- it("returns 502 when approve command fails", async () => {
162
- mockExecSuccess(JSON.stringify({
133
+ it("returns 502 when approveDevicePairing fails", async () => {
134
+ mockList.mockResolvedValueOnce({
163
135
  pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
164
136
  paired: [],
165
- }));
166
- const approveErr = new Error("Command failed") as Error & { stderr: string };
167
- approveErr.stderr = "unknown requestId";
168
- mockExecErrorWithStderr(approveErr);
137
+ });
138
+ mockApprove.mockRejectedValueOnce(new Error("unknown requestId"));
169
139
 
170
140
  const req = mockReq("POST", { authorization: "Bearer test-token" });
171
141
  const res = new MockRes() as unknown as ServerResponse;
@@ -174,35 +144,36 @@ describe("handleDeviceApprove", () => {
174
144
  await p;
175
145
  expect((res as unknown as MockRes).statusCode).toBe(502);
176
146
  const body = JSON.parse((res as unknown as MockRes).body);
177
- expect(body.error).toContain("Device approval command failed");
147
+ expect(body.error).toContain("Device approval failed");
178
148
  expect(body.detail).toBe("unknown requestId");
179
149
  });
180
150
 
181
- it("returns 502 when approve returns non-JSON", async () => {
182
- mockExecSuccess(JSON.stringify({
151
+ it("returns 404 when approveDevicePairing returns null", async () => {
152
+ mockList.mockResolvedValueOnce({
183
153
  pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
184
154
  paired: [],
185
- }));
186
- mockExecSuccess("No pending device pairing requests to approve");
155
+ });
156
+ mockApprove.mockResolvedValueOnce(null);
187
157
 
188
158
  const req = mockReq("POST", { authorization: "Bearer test-token" });
189
159
  const res = new MockRes() as unknown as ServerResponse;
190
160
  const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
191
161
  req.end(JSON.stringify({ deviceId: DEVICE_ID }));
192
162
  await p;
193
- expect((res as unknown as MockRes).statusCode).toBe(502);
194
- expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response from device approval");
163
+ expect((res as unknown as MockRes).statusCode).toBe(404);
164
+ expect(JSON.parse((res as unknown as MockRes).body).error).toContain("not found");
195
165
  });
196
166
 
197
167
  it("succeeds with complete flow", async () => {
198
- mockExecSuccess(JSON.stringify({
168
+ mockList.mockResolvedValueOnce({
199
169
  pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
200
170
  paired: [],
201
- }));
202
- mockExecSuccess(JSON.stringify({
171
+ });
172
+ mockApprove.mockResolvedValueOnce({
173
+ status: "approved",
203
174
  requestId: REQUEST_ID,
204
175
  device: { deviceId: DEVICE_ID, approvedAtMs: 1778571972361 },
205
- }));
176
+ });
206
177
 
207
178
  const req = mockReq("POST", { authorization: "Bearer test-token" });
208
179
  const res = new MockRes() as unknown as ServerResponse;
@@ -216,18 +187,20 @@ describe("handleDeviceApprove", () => {
216
187
  expect(body.deviceId).toBe(DEVICE_ID.toUpperCase());
217
188
  expect(body.requestId).toBe(REQUEST_ID);
218
189
  expect(body.approvedAtMs).toBe(1778571972361);
219
- expect(mockExecImpl).toHaveBeenCalledTimes(2);
190
+ expect(mockList).toHaveBeenCalledTimes(1);
191
+ expect(mockApprove).toHaveBeenCalledTimes(1);
220
192
  });
221
193
 
222
194
  it("normalizes deviceId case-insensitively", async () => {
223
- mockExecSuccess(JSON.stringify({
195
+ mockList.mockResolvedValueOnce({
224
196
  pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
225
197
  paired: [],
226
- }));
227
- mockExecSuccess(JSON.stringify({
198
+ });
199
+ mockApprove.mockResolvedValueOnce({
200
+ status: "approved",
228
201
  requestId: REQUEST_ID,
229
202
  device: { deviceId: DEVICE_ID, approvedAtMs: 1 },
230
- }));
203
+ });
231
204
 
232
205
  const req = mockReq("POST", { authorization: "Bearer test-token" });
233
206
  const res = new MockRes() as unknown as ServerResponse;
@@ -1,41 +1,9 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { exec } from "node:child_process";
2
+ import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
3
3
  import { readJsonBody } from "../middleware/body.js";
4
4
  import { extractBearerToken } from "../middleware/auth.js";
5
5
  import { createFridayNextLogger } from "../../logging.js";
6
6
 
7
- const EXEC_ENV = process.platform === "win32"
8
- ? process.env
9
- : { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
10
-
11
- interface PendingDevice {
12
- requestId: string;
13
- deviceId: string;
14
- }
15
-
16
- interface DeviceListJson {
17
- pending?: PendingDevice[];
18
- }
19
-
20
- interface ApproveJson {
21
- requestId: string;
22
- device?: { deviceId: string; approvedAtMs?: number };
23
- }
24
-
25
- function execAsync(command: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
26
- return new Promise((resolve, reject) => {
27
- const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
28
- if (error) {
29
- reject(error);
30
- } else {
31
- resolve({ stdout, stderr });
32
- }
33
- });
34
- child.stdout?.on("data", () => { /* drain */ });
35
- child.stderr?.on("data", () => { /* drain */ });
36
- });
37
- }
38
-
39
7
  export async function handleDeviceApprove(
40
8
  req: IncomingMessage,
41
9
  res: ServerResponse,
@@ -75,32 +43,18 @@ export async function handleDeviceApprove(
75
43
 
76
44
  const normalizedDeviceId = rawDeviceId.trim().toUpperCase();
77
45
 
78
- let listStdout: string;
46
+ let pairing;
79
47
  try {
80
- const result = await execAsync("openclaw devices list --json", 15000);
81
- listStdout = result.stdout;
48
+ pairing = await listDevicePairing();
82
49
  } catch (err) {
83
- const stderr = (err as { stderr?: string })?.stderr?.trim();
84
- log.error(`devices list failed: ${err instanceof Error ? err.message : String(err)}`);
85
- res.statusCode = 502;
86
- res.setHeader("Content-Type", "application/json");
87
- res.end(JSON.stringify({ error: "Failed to list devices from gateway", detail: stderr || undefined }));
88
- return true;
89
- }
90
-
91
- let listData: DeviceListJson;
92
- try {
93
- listData = JSON.parse(listStdout) as DeviceListJson;
94
- } catch {
95
- log.error(`devices list returned invalid JSON: ${listStdout.slice(0, 200)}`);
50
+ log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
96
51
  res.statusCode = 502;
97
52
  res.setHeader("Content-Type", "application/json");
98
- res.end(JSON.stringify({ error: "Unexpected response from gateway device list" }));
53
+ res.end(JSON.stringify({ error: "Failed to list devices from gateway" }));
99
54
  return true;
100
55
  }
101
56
 
102
- const pending = listData.pending ?? [];
103
- const match = pending.find(
57
+ const match = pairing.pending.find(
104
58
  (entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId,
105
59
  );
106
60
 
@@ -117,30 +71,31 @@ export async function handleDeviceApprove(
117
71
  const requestId = match.requestId;
118
72
  log.info(`approving deviceId=${normalizedDeviceId} requestId=${requestId}`);
119
73
 
120
- let approveStdout: string;
74
+ let approved;
121
75
  try {
122
- const result = await execAsync(`openclaw devices approve ${requestId} --json`, 15000);
123
- approveStdout = result.stdout;
76
+ approved = await approveDevicePairing(requestId);
124
77
  } catch (err) {
125
- const stderr = (err as { stderr?: string })?.stderr?.trim();
126
- log.error(`devices approve failed: ${err instanceof Error ? err.message : String(err)}`);
78
+ log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
127
79
  res.statusCode = 502;
128
80
  res.setHeader("Content-Type", "application/json");
129
81
  res.end(JSON.stringify({
130
- error: "Device approval command failed",
131
- detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
82
+ error: "Device approval failed",
83
+ detail: err instanceof Error ? err.message : "Unknown error",
132
84
  }));
133
85
  return true;
134
86
  }
135
87
 
136
- let approveData: ApproveJson;
137
- try {
138
- approveData = JSON.parse(approveStdout) as ApproveJson;
139
- } catch {
140
- log.error(`devices approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
141
- res.statusCode = 502;
88
+ if (!approved) {
89
+ res.statusCode = 404;
90
+ res.setHeader("Content-Type", "application/json");
91
+ res.end(JSON.stringify({ error: "Pending device request not found" }));
92
+ return true;
93
+ }
94
+
95
+ if (approved.status === "forbidden") {
96
+ res.statusCode = 403;
142
97
  res.setHeader("Content-Type", "application/json");
143
- res.end(JSON.stringify({ error: "Unexpected response from device approval" }));
98
+ res.end(JSON.stringify({ error: `Device approval forbidden: ${(approved as any).reason ?? "unknown"}` }));
144
99
  return true;
145
100
  }
146
101
 
@@ -149,8 +104,8 @@ export async function handleDeviceApprove(
149
104
  res.end(JSON.stringify({
150
105
  ok: true,
151
106
  deviceId: normalizedDeviceId,
152
- requestId: approveData.requestId,
153
- approvedAtMs: approveData.device?.approvedAtMs,
107
+ requestId: approved.requestId,
108
+ approvedAtMs: (approved as any).device?.approvedAtMs,
154
109
  }));
155
110
  return true;
156
111
  }
@@ -3,10 +3,11 @@ import { EventEmitter } from "node:events";
3
3
  import { PassThrough } from "node:stream";
4
4
  import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import { setMockRuntime } from "../../test-support/mock-runtime.js";
6
+ import { __setMockNodePairingForTests } from "../../agent/node-pairing-bridge.js";
6
7
 
7
- const mockExecImpl = vi.hoisted(() => vi.fn());
8
- vi.mock("node:child_process", () => ({
9
- exec: mockExecImpl,
8
+ const { mockList, mockApprove } = vi.hoisted(() => ({
9
+ mockList: vi.fn(),
10
+ mockApprove: vi.fn(),
10
11
  }));
11
12
 
12
13
  import { handleNodesApprove } from "./nodes-approve.js";
@@ -30,45 +31,17 @@ function mockReq(method: string, headers: Record<string, string> = {}): PassThro
30
31
  return stream;
31
32
  }
32
33
 
33
- function mockExecSuccess(stdout: string) {
34
- const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
35
- child.stdout = new EventEmitter();
36
- child.stderr = new EventEmitter();
37
- mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: null, stdout: string, stderr: string) => void) => {
38
- cb(null, stdout, "");
39
- return child;
40
- });
41
- }
42
-
43
- function mockExecError(message: string, stderr?: string) {
44
- const err = new Error(message) as Error & { stderr: string };
45
- err.stderr = stderr ?? "";
46
- const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
47
- child.stdout = new EventEmitter();
48
- child.stderr = new EventEmitter();
49
- mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
50
- cb(err);
51
- return child;
52
- });
53
- }
54
-
55
- function mockExecErrorWithStderr(err: Error & { stderr?: string }) {
56
- const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
57
- child.stdout = new EventEmitter();
58
- child.stderr = new EventEmitter();
59
- mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
60
- cb(err);
61
- return child;
62
- });
63
- }
64
-
65
34
  const NODE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
66
- const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
35
+ const REQUEST_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
67
36
 
68
37
  describe("handleNodesApprove", () => {
69
38
  beforeEach(() => {
70
39
  setMockRuntime();
71
- mockExecImpl.mockReset();
40
+ vi.clearAllMocks();
41
+ __setMockNodePairingForTests({
42
+ listNodePairing: mockList,
43
+ approveNodePairing: mockApprove,
44
+ });
72
45
  });
73
46
 
74
47
  it("returns 405 on non-POST", async () => {
@@ -106,8 +79,8 @@ describe("handleNodesApprove", () => {
106
79
  expect(JSON.parse((res as unknown as MockRes).body).error).toContain("nodeId");
107
80
  });
108
81
 
109
- it("returns 502 when nodes list CLI fails", async () => {
110
- mockExecError("ENOENT");
82
+ it("returns 502 when listNodePairing fails", async () => {
83
+ mockList.mockRejectedValueOnce(new Error("ENOENT"));
111
84
 
112
85
  const req = mockReq("POST", { authorization: "Bearer test-token" });
113
86
  const res = new MockRes() as unknown as ServerResponse;
@@ -118,23 +91,22 @@ describe("handleNodesApprove", () => {
118
91
  expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Failed to list nodes");
119
92
  });
120
93
 
121
- it("returns 502 when nodes list returns invalid JSON", async () => {
122
- mockExecSuccess("not valid json {{{");
94
+ it("returns 404 when listNodePairing returns data without matching node", async () => {
95
+ mockList.mockResolvedValueOnce({ pending: [{ requestId: "x", nodeId: "UNMATCHED" }], paired: [] });
123
96
 
124
97
  const req = mockReq("POST", { authorization: "Bearer test-token" });
125
98
  const res = new MockRes() as unknown as ServerResponse;
126
99
  const p = handleNodesApprove(req as unknown as IncomingMessage, res);
127
100
  req.end(JSON.stringify({ nodeId: NODE_ID }));
128
101
  await p;
129
- expect((res as unknown as MockRes).statusCode).toBe(502);
130
- expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response");
102
+ expect((res as unknown as MockRes).statusCode).toBe(404);
131
103
  });
132
104
 
133
105
  it("returns 404 when nodeId not in pending or paired with caps", async () => {
134
- mockExecSuccess(JSON.stringify({
106
+ mockList.mockResolvedValueOnce({
135
107
  pending: [{ requestId: "uuid-1", nodeId: "OTHER_NODE" }],
136
- paired: [{ nodeId: "ANOTHER", caps: ["canvas"], commands: ["canvas.navigate"] }],
137
- }));
108
+ paired: [{ nodeId: "OTHER_NODE", approvedAtMs: 1, caps: ["canvas"], commands: [] }],
109
+ });
138
110
 
139
111
  const req = mockReq("POST", { authorization: "Bearer test-token" });
140
112
  const res = new MockRes() as unknown as ServerResponse;
@@ -144,14 +116,13 @@ describe("handleNodesApprove", () => {
144
116
  expect((res as unknown as MockRes).statusCode).toBe(404);
145
117
  const body = JSON.parse((res as unknown as MockRes).body);
146
118
  expect(body.error).toContain("No pending node found");
147
- expect(body.nodeId).toBe(NODE_ID.toUpperCase());
148
119
  });
149
120
 
150
121
  it("returns 404 when pending is empty and paired has empty caps/commands", async () => {
151
- mockExecSuccess(JSON.stringify({
122
+ mockList.mockResolvedValueOnce({
152
123
  pending: [],
153
124
  paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: [], commands: [] }],
154
- }));
125
+ });
155
126
 
156
127
  const req = mockReq("POST", { authorization: "Bearer test-token" });
157
128
  const res = new MockRes() as unknown as ServerResponse;
@@ -162,10 +133,10 @@ describe("handleNodesApprove", () => {
162
133
  });
163
134
 
164
135
  it("returns 200 with alreadyApproved when node in paired with caps", async () => {
165
- mockExecSuccess(JSON.stringify({
136
+ mockList.mockResolvedValueOnce({
166
137
  pending: [],
167
- paired: [{ nodeId: NODE_ID, approvedAtMs: 1778571972361, caps: ["location", "canvas"], commands: ["canvas.navigate"] }],
168
- }));
138
+ paired: [{ nodeId: NODE_ID, approvedAtMs: 100, caps: ["canvas"], commands: ["canvas.present"] }],
139
+ });
169
140
 
170
141
  const req = mockReq("POST", { authorization: "Bearer test-token" });
171
142
  const res = new MockRes() as unknown as ServerResponse;
@@ -177,21 +148,16 @@ describe("handleNodesApprove", () => {
177
148
  const body = JSON.parse((res as unknown as MockRes).body);
178
149
  expect(body.ok).toBe(true);
179
150
  expect(body.alreadyApproved).toBe(true);
180
- expect(body.nodeId).toBe(NODE_ID.toUpperCase());
181
- expect(body.approvedAtMs).toBe(1778571972361);
182
- expect(body.caps).toEqual(["location", "canvas"]);
183
- expect(body.commands).toEqual(["canvas.navigate"]);
184
- expect(mockExecImpl).toHaveBeenCalledTimes(1); // no approve call
151
+ expect(body.caps).toEqual(["canvas"]);
152
+ expect(body.commands).toEqual(["canvas.present"]);
185
153
  });
186
154
 
187
- it("returns 502 when approve command fails", async () => {
188
- mockExecSuccess(JSON.stringify({
155
+ it("returns 502 when approveNodePairing fails", async () => {
156
+ mockList.mockResolvedValueOnce({
189
157
  pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
190
158
  paired: [],
191
- }));
192
- const approveErr = new Error("Command failed") as Error & { stderr: string };
193
- approveErr.stderr = "unknown requestId";
194
- mockExecErrorWithStderr(approveErr);
159
+ });
160
+ mockApprove.mockRejectedValueOnce(new Error("unknown requestId"));
195
161
 
196
162
  const req = mockReq("POST", { authorization: "Bearer test-token" });
197
163
  const res = new MockRes() as unknown as ServerResponse;
@@ -200,35 +166,34 @@ describe("handleNodesApprove", () => {
200
166
  await p;
201
167
  expect((res as unknown as MockRes).statusCode).toBe(502);
202
168
  const body = JSON.parse((res as unknown as MockRes).body);
203
- expect(body.error).toContain("Node approval command failed");
169
+ expect(body.error).toContain("Node approval failed");
204
170
  expect(body.detail).toBe("unknown requestId");
205
171
  });
206
172
 
207
- it("returns 502 when approve returns non-JSON", async () => {
208
- mockExecSuccess(JSON.stringify({
173
+ it("returns 404 when approveNodePairing returns null", async () => {
174
+ mockList.mockResolvedValueOnce({
209
175
  pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
210
176
  paired: [],
211
- }));
212
- mockExecSuccess("No pending node pairing requests to approve");
177
+ });
178
+ mockApprove.mockResolvedValueOnce(null);
213
179
 
214
180
  const req = mockReq("POST", { authorization: "Bearer test-token" });
215
181
  const res = new MockRes() as unknown as ServerResponse;
216
182
  const p = handleNodesApprove(req as unknown as IncomingMessage, res);
217
183
  req.end(JSON.stringify({ nodeId: NODE_ID }));
218
184
  await p;
219
- expect((res as unknown as MockRes).statusCode).toBe(502);
220
- expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response from node approval");
185
+ expect((res as unknown as MockRes).statusCode).toBe(404);
221
186
  });
222
187
 
223
188
  it("succeeds with complete flow", async () => {
224
- mockExecSuccess(JSON.stringify({
189
+ mockList.mockResolvedValueOnce({
225
190
  pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
226
191
  paired: [],
227
- }));
228
- mockExecSuccess(JSON.stringify({
192
+ });
193
+ mockApprove.mockResolvedValueOnce({
229
194
  requestId: REQUEST_ID,
230
195
  node: { nodeId: NODE_ID, approvedAtMs: 1778571972361 },
231
- }));
196
+ });
232
197
 
233
198
  const req = mockReq("POST", { authorization: "Bearer test-token" });
234
199
  const res = new MockRes() as unknown as ServerResponse;
@@ -239,22 +204,19 @@ describe("handleNodesApprove", () => {
239
204
  expect((res as unknown as MockRes).statusCode).toBe(200);
240
205
  const body = JSON.parse((res as unknown as MockRes).body);
241
206
  expect(body.ok).toBe(true);
242
- expect(body.alreadyApproved).toBeUndefined();
243
207
  expect(body.nodeId).toBe(NODE_ID.toUpperCase());
244
208
  expect(body.requestId).toBe(REQUEST_ID);
245
- expect(body.approvedAtMs).toBe(1778571972361);
246
- expect(mockExecImpl).toHaveBeenCalledTimes(2);
247
209
  });
248
210
 
249
211
  it("normalizes nodeId case-insensitively in pending", async () => {
250
- mockExecSuccess(JSON.stringify({
251
- pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
212
+ mockList.mockResolvedValueOnce({
213
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID.toUpperCase() }],
252
214
  paired: [],
253
- }));
254
- mockExecSuccess(JSON.stringify({
215
+ });
216
+ mockApprove.mockResolvedValueOnce({
255
217
  requestId: REQUEST_ID,
256
- node: { nodeId: NODE_ID, approvedAtMs: 1 },
257
- }));
218
+ node: { nodeId: NODE_ID.toUpperCase(), approvedAtMs: 1 },
219
+ });
258
220
 
259
221
  const req = mockReq("POST", { authorization: "Bearer test-token" });
260
222
  const res = new MockRes() as unknown as ServerResponse;
@@ -265,14 +227,13 @@ describe("handleNodesApprove", () => {
265
227
  expect((res as unknown as MockRes).statusCode).toBe(200);
266
228
  const body = JSON.parse((res as unknown as MockRes).body);
267
229
  expect(body.ok).toBe(true);
268
- expect(body.nodeId).toBe(NODE_ID.toUpperCase());
269
230
  });
270
231
 
271
232
  it("normalizes nodeId case-insensitively in paired", async () => {
272
- mockExecSuccess(JSON.stringify({
233
+ mockList.mockResolvedValueOnce({
273
234
  pending: [],
274
- paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: ["canvas"], commands: [] }],
275
- }));
235
+ paired: [{ nodeId: NODE_ID.toUpperCase(), approvedAtMs: 1, caps: ["canvas"], commands: [] }],
236
+ });
276
237
 
277
238
  const req = mockReq("POST", { authorization: "Bearer test-token" });
278
239
  const res = new MockRes() as unknown as ServerResponse;
@@ -1,48 +1,27 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { exec } from "node:child_process";
3
2
  import { readJsonBody } from "../middleware/body.js";
4
3
  import { extractBearerToken } from "../middleware/auth.js";
5
4
  import { createFridayNextLogger } from "../../logging.js";
5
+ import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
6
6
 
7
- const EXEC_ENV = process.platform === "win32"
8
- ? process.env
9
- : { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
10
-
11
- interface PendingNode {
7
+ interface PendingNodeEntry {
12
8
  requestId: string;
13
9
  nodeId: string;
14
10
  }
15
-
16
- interface PairedNode {
11
+ interface PairedNodeEntry {
17
12
  nodeId: string;
18
- approvedAtMs?: number;
13
+ approvedAtMs: number;
19
14
  caps?: string[];
20
15
  commands?: string[];
21
16
  }
22
-
23
- interface NodeListJson {
24
- pending?: PendingNode[];
25
- paired?: PairedNode[];
26
- }
27
-
28
- interface ApproveJson {
29
- requestId: string;
30
- node?: { nodeId: string; approvedAtMs?: number };
31
- }
32
-
33
- function execAsync(command: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
34
- return new Promise((resolve, reject) => {
35
- const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
36
- if (error) {
37
- reject(error);
38
- } else {
39
- resolve({ stdout, stderr });
40
- }
41
- });
42
- child.stdout?.on("data", () => { /* drain */ });
43
- child.stderr?.on("data", () => { /* drain */ });
44
- });
17
+ interface NodePairingList {
18
+ pending: PendingNodeEntry[];
19
+ paired: PairedNodeEntry[];
45
20
  }
21
+ type ApproveNodePairingResult =
22
+ | { requestId: string; node: PairedNodeEntry }
23
+ | { status: "forbidden"; missingScope: string }
24
+ | null;
46
25
 
47
26
  export async function handleNodesApprove(
48
27
  req: IncomingMessage,
@@ -83,63 +62,53 @@ export async function handleNodesApprove(
83
62
 
84
63
  const normalizedNodeId = rawNodeId.trim().toUpperCase();
85
64
 
86
- let listStdout: string;
87
- try {
88
- const result = await execAsync("openclaw nodes list --json", 15000);
89
- listStdout = result.stdout;
90
- } catch (err) {
91
- const stderr = (err as { stderr?: string })?.stderr?.trim();
92
- log.error(`nodes list failed: ${err instanceof Error ? err.message : String(err)}`);
93
- res.statusCode = 502;
94
- res.setHeader("Content-Type", "application/json");
95
- res.end(JSON.stringify({ error: "Failed to list nodes from gateway", detail: stderr || undefined }));
96
- return true;
97
- }
65
+ const { listNodePairing, approveNodePairing } = loadNodePairingModule();
98
66
 
99
- let listData: NodeListJson;
67
+ let listData;
100
68
  try {
101
- listData = JSON.parse(listStdout) as NodeListJson;
102
- } catch {
103
- log.error(`nodes list returned invalid JSON: ${listStdout.slice(0, 200)}`);
69
+ listData = await listNodePairing();
70
+ } catch (err) {
71
+ log.error(`listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
104
72
  res.statusCode = 502;
105
73
  res.setHeader("Content-Type", "application/json");
106
- res.end(JSON.stringify({ error: "Unexpected response from gateway node list" }));
74
+ res.end(JSON.stringify({ error: "Failed to list nodes from gateway" }));
107
75
  return true;
108
76
  }
109
77
 
110
78
  const pending = listData.pending ?? [];
111
79
  const pendingMatch = pending.find(
112
- (entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
80
+ (entry: PendingNodeEntry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
113
81
  );
114
82
 
115
83
  if (pendingMatch) {
116
84
  const requestId = pendingMatch.requestId;
117
85
  log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
118
86
 
119
- let approveStdout: string;
87
+ let approved;
120
88
  try {
121
- const result = await execAsync(`openclaw nodes approve ${requestId} --json`, 15000);
122
- approveStdout = result.stdout;
89
+ approved = await approveNodePairing(requestId, {});
123
90
  } catch (err) {
124
- const stderr = (err as { stderr?: string })?.stderr?.trim();
125
- log.error(`nodes approve failed: ${err instanceof Error ? err.message : String(err)}`);
91
+ log.error(`approveNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
126
92
  res.statusCode = 502;
127
93
  res.setHeader("Content-Type", "application/json");
128
94
  res.end(JSON.stringify({
129
- error: "Node approval command failed",
130
- detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
95
+ error: "Node approval failed",
96
+ detail: err instanceof Error ? err.message : "Unknown error",
131
97
  }));
132
98
  return true;
133
99
  }
134
100
 
135
- let approveData: ApproveJson;
136
- try {
137
- approveData = JSON.parse(approveStdout) as ApproveJson;
138
- } catch {
139
- log.error(`nodes approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
140
- res.statusCode = 502;
101
+ if (!approved) {
102
+ res.statusCode = 404;
103
+ res.setHeader("Content-Type", "application/json");
104
+ res.end(JSON.stringify({ error: "Pending node request not found" }));
105
+ return true;
106
+ }
107
+
108
+ if ("status" in approved && approved.status === "forbidden") {
109
+ res.statusCode = 403;
141
110
  res.setHeader("Content-Type", "application/json");
142
- res.end(JSON.stringify({ error: "Unexpected response from node approval" }));
111
+ res.end(JSON.stringify({ error: `Node approval forbidden: ${(approved as any).missingScope ?? "unknown"}` }));
143
112
  return true;
144
113
  }
145
114
 
@@ -148,16 +117,16 @@ export async function handleNodesApprove(
148
117
  res.end(JSON.stringify({
149
118
  ok: true,
150
119
  nodeId: normalizedNodeId,
151
- requestId: approveData.requestId,
152
- approvedAtMs: approveData.node?.approvedAtMs,
120
+ requestId: (approved as any).requestId,
121
+ approvedAtMs: (approved as any).node?.approvedAtMs,
153
122
  }));
154
123
  return true;
155
124
  }
156
125
 
157
- // Not in pending — check if already paired with non-empty caps/commands
126
+ // Check if already paired with non-empty caps/commands
158
127
  const paired = listData.paired ?? [];
159
128
  const pairedMatch = paired.find(
160
- (entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
129
+ (entry: PairedNodeEntry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
161
130
  );
162
131
 
163
132
  if (pairedMatch) {
package/src/openclaw.d.ts CHANGED
@@ -3,6 +3,36 @@ declare module "openclaw/plugin-sdk/agent-harness" {
3
3
  export const runAgentHarness: (...args: any[]) => any;
4
4
  }
5
5
 
6
+ declare module "openclaw/plugin-sdk/device-bootstrap" {
7
+ export const listDevicePairing: (baseDir?: string) => Promise<DevicePairingList>;
8
+ export const approveDevicePairing: (requestId: string, options?: { callerScopes?: readonly string[] }, baseDir?: string) => Promise<ApproveDevicePairingResult>;
9
+
10
+ interface DevicePairingPendingRequest {
11
+ requestId: string;
12
+ deviceId: string;
13
+ publicKey: string;
14
+ displayName?: string;
15
+ platform?: string;
16
+ ts: number;
17
+ }
18
+ interface PairedDevice {
19
+ deviceId: string;
20
+ approvedAtMs: number;
21
+ }
22
+ interface DevicePairingList {
23
+ pending: DevicePairingPendingRequest[];
24
+ paired: PairedDevice[];
25
+ }
26
+ type ApproveDevicePairingResult = {
27
+ status: "approved";
28
+ requestId: string;
29
+ device: PairedDevice;
30
+ } | {
31
+ status: "forbidden";
32
+ reason: string;
33
+ } | null;
34
+ }
35
+
6
36
  declare module "openclaw/plugin-sdk/core" {
7
37
  export const defineChannelPluginEntry: (...args: any[]) => any;
8
38
  export const createChatChannelPlugin: (...args: any[]) => any;
@@ -0,0 +1,10 @@
1
+ // Mock module for openclaw/plugin-sdk/device-bootstrap in tests.
2
+ // Tests replace these via vi.mock + vi.fn() in hoisted blocks.
3
+
4
+ export function listDevicePairing(): Promise<any> {
5
+ throw new Error("listDevicePairing not mocked");
6
+ }
7
+
8
+ export function approveDevicePairing(): Promise<any> {
9
+ throw new Error("approveDevicePairing not mocked");
10
+ }