@syengup/friday-channel-next 0.0.39 → 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.
- package/dist/src/agent/node-pairing-bridge.d.ts +9 -0
- package/dist/src/agent/node-pairing-bridge.js +19 -0
- package/dist/src/http/handlers/device-approve.js +21 -53
- package/dist/src/http/handlers/nodes-approve.js +22 -52
- package/package.json +1 -1
- package/src/agent/node-pairing-bridge.ts +29 -0
- package/src/http/handlers/device-approve.test.ts +40 -67
- package/src/http/handlers/device-approve.ts +23 -68
- package/src/http/handlers/nodes-approve.test.ts +48 -87
- package/src/http/handlers/nodes-approve.ts +37 -68
- package/src/openclaw.d.ts +30 -0
- package/src/test-support/mock-device-bootstrap.ts +10 -0
|
@@ -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,19 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const OPENCLAW_DIST = "/opt/homebrew/lib/node_modules/openclaw/dist";
|
|
5
|
+
let cache = null;
|
|
6
|
+
export function loadNodePairingModule() {
|
|
7
|
+
if (cache)
|
|
8
|
+
return cache;
|
|
9
|
+
const file = readdirSync(OPENCLAW_DIST).find((f) => f.startsWith("node-pairing-") && f.endsWith(".js") && !f.includes("authz"));
|
|
10
|
+
if (!file)
|
|
11
|
+
throw new Error("node-pairing module not found in OpenClaw dist");
|
|
12
|
+
const gatewayRequire = createRequire(join(OPENCLAW_DIST, "_"));
|
|
13
|
+
cache = gatewayRequire(`./${file.replace(/\.js$/, "")}`);
|
|
14
|
+
return cache;
|
|
15
|
+
}
|
|
16
|
+
/** Vitest-only: inject mock pairing functions. */
|
|
17
|
+
export function __setMockNodePairingForTests(mock) {
|
|
18
|
+
cache = mock;
|
|
19
|
+
}
|
|
@@ -1,24 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
|
35
|
+
let pairing;
|
|
53
36
|
try {
|
|
54
|
-
|
|
55
|
-
listStdout = result.stdout;
|
|
37
|
+
pairing = await listDevicePairing();
|
|
56
38
|
}
|
|
57
39
|
catch (err) {
|
|
58
|
-
|
|
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: "
|
|
43
|
+
res.end(JSON.stringify({ error: "Failed to list devices from gateway" }));
|
|
74
44
|
return true;
|
|
75
45
|
}
|
|
76
|
-
const
|
|
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
|
|
58
|
+
let approved;
|
|
90
59
|
try {
|
|
91
|
-
|
|
92
|
-
approveStdout = result.stdout;
|
|
60
|
+
approved = await approveDevicePairing(requestId);
|
|
93
61
|
}
|
|
94
62
|
catch (err) {
|
|
95
|
-
|
|
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
|
|
101
|
-
detail:
|
|
67
|
+
error: "Device approval failed",
|
|
68
|
+
detail: err instanceof Error ? err.message : "Unknown error",
|
|
102
69
|
}));
|
|
103
70
|
return true;
|
|
104
71
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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:
|
|
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:
|
|
122
|
-
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
|
-
|
|
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
|
-
|
|
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 =
|
|
38
|
+
listData = await listNodePairing();
|
|
68
39
|
}
|
|
69
|
-
catch {
|
|
70
|
-
log.error(`
|
|
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: "
|
|
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
|
|
52
|
+
let approved;
|
|
82
53
|
try {
|
|
83
|
-
|
|
84
|
-
approveStdout = result.stdout;
|
|
54
|
+
approved = await approveNodePairing(requestId, {});
|
|
85
55
|
}
|
|
86
56
|
catch (err) {
|
|
87
|
-
|
|
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
|
|
93
|
-
detail:
|
|
61
|
+
error: "Node approval failed",
|
|
62
|
+
detail: err instanceof Error ? err.message : "Unknown error",
|
|
94
63
|
}));
|
|
95
64
|
return true;
|
|
96
65
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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:
|
|
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:
|
|
114
|
-
approvedAtMs:
|
|
83
|
+
requestId: approved.requestId,
|
|
84
|
+
approvedAtMs: approved.node?.approvedAtMs,
|
|
115
85
|
}));
|
|
116
86
|
return true;
|
|
117
87
|
}
|
|
118
|
-
//
|
|
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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const OPENCLAW_DIST = "/opt/homebrew/lib/node_modules/openclaw/dist";
|
|
6
|
+
|
|
7
|
+
let cache: { listNodePairing: Function; approveNodePairing: Function } | null = null;
|
|
8
|
+
|
|
9
|
+
export function loadNodePairingModule(): {
|
|
10
|
+
listNodePairing: Function;
|
|
11
|
+
approveNodePairing: Function;
|
|
12
|
+
} {
|
|
13
|
+
if (cache) return cache;
|
|
14
|
+
const file = readdirSync(OPENCLAW_DIST).find(
|
|
15
|
+
(f) => f.startsWith("node-pairing-") && f.endsWith(".js") && !f.includes("authz"),
|
|
16
|
+
);
|
|
17
|
+
if (!file) throw new Error("node-pairing module not found in OpenClaw dist");
|
|
18
|
+
const gatewayRequire = createRequire(join(OPENCLAW_DIST, "_"));
|
|
19
|
+
cache = gatewayRequire(`./${file.replace(/\.js$/, "")}`);
|
|
20
|
+
return cache!;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Vitest-only: inject mock pairing functions. */
|
|
24
|
+
export function __setMockNodePairingForTests(mock: {
|
|
25
|
+
listNodePairing: Function;
|
|
26
|
+
approveNodePairing: Function;
|
|
27
|
+
}): void {
|
|
28
|
+
cache = mock;
|
|
29
|
+
}
|
|
@@ -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
|
|
8
|
-
vi.
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
122
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
147
|
+
expect(body.error).toContain("Device approval failed");
|
|
178
148
|
expect(body.detail).toBe("unknown requestId");
|
|
179
149
|
});
|
|
180
150
|
|
|
181
|
-
it("returns
|
|
182
|
-
|
|
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
|
-
|
|
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(
|
|
194
|
-
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("
|
|
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
|
-
|
|
168
|
+
mockList.mockResolvedValueOnce({
|
|
199
169
|
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
200
170
|
paired: [],
|
|
201
|
-
})
|
|
202
|
-
|
|
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(
|
|
190
|
+
expect(mockList).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(mockApprove).toHaveBeenCalledTimes(1);
|
|
220
192
|
});
|
|
221
193
|
|
|
222
194
|
it("normalizes deviceId case-insensitively", async () => {
|
|
223
|
-
|
|
195
|
+
mockList.mockResolvedValueOnce({
|
|
224
196
|
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
225
197
|
paired: [],
|
|
226
|
-
})
|
|
227
|
-
|
|
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 {
|
|
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
|
|
46
|
+
let pairing;
|
|
79
47
|
try {
|
|
80
|
-
|
|
81
|
-
listStdout = result.stdout;
|
|
48
|
+
pairing = await listDevicePairing();
|
|
82
49
|
} catch (err) {
|
|
83
|
-
|
|
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: "
|
|
53
|
+
res.end(JSON.stringify({ error: "Failed to list devices from gateway" }));
|
|
99
54
|
return true;
|
|
100
55
|
}
|
|
101
56
|
|
|
102
|
-
const
|
|
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
|
|
74
|
+
let approved;
|
|
121
75
|
try {
|
|
122
|
-
|
|
123
|
-
approveStdout = result.stdout;
|
|
76
|
+
approved = await approveDevicePairing(requestId);
|
|
124
77
|
} catch (err) {
|
|
125
|
-
|
|
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
|
|
131
|
-
detail:
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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:
|
|
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:
|
|
153
|
-
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
|
|
8
|
-
vi.
|
|
9
|
-
|
|
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 = "
|
|
35
|
+
const REQUEST_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
67
36
|
|
|
68
37
|
describe("handleNodesApprove", () => {
|
|
69
38
|
beforeEach(() => {
|
|
70
39
|
setMockRuntime();
|
|
71
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
122
|
-
|
|
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(
|
|
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
|
-
|
|
106
|
+
mockList.mockResolvedValueOnce({
|
|
135
107
|
pending: [{ requestId: "uuid-1", nodeId: "OTHER_NODE" }],
|
|
136
|
-
paired: [{ nodeId: "
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
mockList.mockResolvedValueOnce({
|
|
166
137
|
pending: [],
|
|
167
|
-
paired: [{ nodeId: NODE_ID, approvedAtMs:
|
|
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.
|
|
181
|
-
expect(body.
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
169
|
+
expect(body.error).toContain("Node approval failed");
|
|
204
170
|
expect(body.detail).toBe("unknown requestId");
|
|
205
171
|
});
|
|
206
172
|
|
|
207
|
-
it("returns
|
|
208
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
189
|
+
mockList.mockResolvedValueOnce({
|
|
225
190
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
226
191
|
paired: [],
|
|
227
|
-
})
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
+
approvedAtMs: number;
|
|
19
14
|
caps?: string[];
|
|
20
15
|
commands?: string[];
|
|
21
16
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
67
|
+
let listData;
|
|
100
68
|
try {
|
|
101
|
-
listData =
|
|
102
|
-
} catch {
|
|
103
|
-
log.error(`
|
|
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: "
|
|
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
|
|
87
|
+
let approved;
|
|
120
88
|
try {
|
|
121
|
-
|
|
122
|
-
approveStdout = result.stdout;
|
|
89
|
+
approved = await approveNodePairing(requestId, {});
|
|
123
90
|
} catch (err) {
|
|
124
|
-
|
|
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
|
|
130
|
-
detail:
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
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:
|
|
152
|
-
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
|
-
//
|
|
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
|
+
}
|