@syengup/friday-channel-next 0.0.38 → 0.0.40

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.
@@ -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;