@syengup/friday-channel-next 0.0.11 → 0.0.13
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/index.js +176 -0
- package/install.js +17 -10
- package/install.sh +15 -9
- package/package.json +10 -10
- package/src/http/handlers/device-approve.test.ts +244 -0
- package/src/http/handlers/device-approve.ts +158 -0
- package/src/http/server.ts +5 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { fridayNextChannelPlugin } from "./src/channel.js";
|
|
3
|
+
import { setFridayNextRuntime } from "./src/runtime.js";
|
|
4
|
+
import { resolveFridayNextConfig } from "./src/config.js";
|
|
5
|
+
import { getHostOpenClawConfigSnapshot } from "./src/host-config.js";
|
|
6
|
+
import { registerFridayNextHttpRoutes } from "./src/http/server.js";
|
|
7
|
+
import { getFridayNextRuntime } from "./src/runtime.js";
|
|
8
|
+
import { sseEmitter } from "./src/sse/emitter.js";
|
|
9
|
+
import { forwardAgentEventRaw, getLastRegisteredFridayDeviceId, resolveFridayDeviceIdForSessionKey, } from "./src/friday-session.js";
|
|
10
|
+
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
11
|
+
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
12
|
+
export { fridayNextChannelPlugin } from "./src/channel.js";
|
|
13
|
+
export { setFridayNextRuntime } from "./src/runtime.js";
|
|
14
|
+
/** `api.on` returns void — register tool hooks at most once per process. */
|
|
15
|
+
let fridayNextToolHooksRegistered = false;
|
|
16
|
+
let disposeAgentEventListener = null;
|
|
17
|
+
/** Avoid duplicate `registerHttpRoute` when gateway re-invokes `registerFull`. */
|
|
18
|
+
let fridayNextPluginHttpRegistered = false;
|
|
19
|
+
function deviceIdFromToolContext(ctx) {
|
|
20
|
+
if (ctx.runId) {
|
|
21
|
+
const d = sseEmitter.getDeviceIdByRunId(ctx.runId);
|
|
22
|
+
if (d)
|
|
23
|
+
return d;
|
|
24
|
+
}
|
|
25
|
+
const sk = typeof ctx.sessionKey === "string" && ctx.sessionKey.trim()
|
|
26
|
+
? ctx.sessionKey.trim()
|
|
27
|
+
: (ctx.runId ? getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() : undefined) ?? "";
|
|
28
|
+
if (sk) {
|
|
29
|
+
const d = resolveFridayDeviceIdForSessionKey(sk);
|
|
30
|
+
if (d)
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
33
|
+
const sole = sseEmitter.getSoleConnectedDeviceId();
|
|
34
|
+
if (sole)
|
|
35
|
+
return sole;
|
|
36
|
+
const last = getLastRegisteredFridayDeviceId();
|
|
37
|
+
if (last)
|
|
38
|
+
return last;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function isFridaySessionKey(sk) {
|
|
42
|
+
return /^friday-next-/i.test(sk) || /^agent:main:friday-next-/i.test(sk);
|
|
43
|
+
}
|
|
44
|
+
function shouldForwardToolEventToFriday(ctx) {
|
|
45
|
+
if (ctx.runId) {
|
|
46
|
+
if (sseEmitter.getDeviceIdByRunId(ctx.runId))
|
|
47
|
+
return true;
|
|
48
|
+
const runSk = getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() ?? "";
|
|
49
|
+
if (runSk) {
|
|
50
|
+
if (resolveFridayDeviceIdForSessionKey(runSk))
|
|
51
|
+
return true;
|
|
52
|
+
if (isFridaySessionKey(runSk))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const sk = typeof ctx.sessionKey === "string" ? ctx.sessionKey.trim() : "";
|
|
57
|
+
if (sk) {
|
|
58
|
+
if (resolveFridayDeviceIdForSessionKey(sk))
|
|
59
|
+
return true;
|
|
60
|
+
if (isFridaySessionKey(sk))
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
export default defineChannelPluginEntry({
|
|
66
|
+
id: "friday-next",
|
|
67
|
+
name: "Friday Next",
|
|
68
|
+
description: "Friday Next Apple 应用通道",
|
|
69
|
+
plugin: fridayNextChannelPlugin,
|
|
70
|
+
setRuntime: setFridayNextRuntime,
|
|
71
|
+
registerFull: (api) => {
|
|
72
|
+
setFridayAgentForwardRuntime(api);
|
|
73
|
+
if (!fridayNextPluginHttpRegistered) {
|
|
74
|
+
fridayNextPluginHttpRegistered = true;
|
|
75
|
+
registerFridayNextHttpRoutes(api);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
79
|
+
sseEmitter.setBacklogLimit(cfg.sseBacklogPerDevice);
|
|
80
|
+
}
|
|
81
|
+
disposeAgentEventListener?.();
|
|
82
|
+
disposeAgentEventListener = api.runtime.events.onAgentEvent((evt) => {
|
|
83
|
+
forwardAgentEventRaw({
|
|
84
|
+
runId: evt.runId,
|
|
85
|
+
seq: evt.seq,
|
|
86
|
+
ts: evt.ts,
|
|
87
|
+
stream: evt.stream,
|
|
88
|
+
data: evt.data,
|
|
89
|
+
sessionKey: evt.sessionKey,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
if (fridayNextToolHooksRegistered) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
fridayNextToolHooksRegistered = true;
|
|
96
|
+
api.on("subagent_delivery_target", (event) => {
|
|
97
|
+
if (!event.expectsCompletionMessage)
|
|
98
|
+
return;
|
|
99
|
+
const ch = event.requesterOrigin?.channel?.trim().toLowerCase();
|
|
100
|
+
if (ch !== "friday-next")
|
|
101
|
+
return;
|
|
102
|
+
const sk = event.requesterSessionKey?.trim();
|
|
103
|
+
if (!sk)
|
|
104
|
+
return;
|
|
105
|
+
const raw = resolveFridayDeviceIdForSessionKey(sk);
|
|
106
|
+
if (!raw)
|
|
107
|
+
return;
|
|
108
|
+
const to = raw.toUpperCase();
|
|
109
|
+
return {
|
|
110
|
+
origin: {
|
|
111
|
+
channel: "friday-next",
|
|
112
|
+
accountId: event.requesterOrigin?.accountId?.trim() || "default",
|
|
113
|
+
to,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
api.on("before_tool_call", (event, ctx) => {
|
|
118
|
+
if (!shouldForwardToolEventToFriday(ctx))
|
|
119
|
+
return;
|
|
120
|
+
const deviceId = deviceIdFromToolContext(ctx);
|
|
121
|
+
const runId = ctx.runId ?? "(unknown)";
|
|
122
|
+
const logLine = (detail) => {
|
|
123
|
+
const ts = new Date().toISOString();
|
|
124
|
+
console.error(`[Friday-HOOK] [${ts}] [TOOL_CALL] toolName=${event.toolName} runId=${runId} deviceId=${deviceId ?? "(unknown)"} detail=${detail}`);
|
|
125
|
+
};
|
|
126
|
+
if (!deviceId) {
|
|
127
|
+
logLine("SKIP_no_deviceId");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
logLine("START");
|
|
131
|
+
sseEmitter.broadcastToolEvent(deviceId.toUpperCase(), runId, {
|
|
132
|
+
type: "tool-hook",
|
|
133
|
+
data: {
|
|
134
|
+
when: "before",
|
|
135
|
+
runId,
|
|
136
|
+
deviceId: deviceId.toUpperCase(),
|
|
137
|
+
sessionKey: ctx.sessionKey,
|
|
138
|
+
toolName: event.toolName,
|
|
139
|
+
params: event.params,
|
|
140
|
+
ts: Date.now(),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
api.on("after_tool_call", (event, ctx) => {
|
|
145
|
+
if (!shouldForwardToolEventToFriday(ctx))
|
|
146
|
+
return;
|
|
147
|
+
const deviceId = deviceIdFromToolContext(ctx);
|
|
148
|
+
const runId = ctx.runId ?? "(unknown)";
|
|
149
|
+
const logLine = (detail) => {
|
|
150
|
+
const ts = new Date().toISOString();
|
|
151
|
+
console.error(`[Friday-HOOK] [${ts}] [TOOL_DONE] toolName=${event.toolName} runId=${runId} deviceId=${deviceId ?? "(unknown)"} detail=${detail}`);
|
|
152
|
+
};
|
|
153
|
+
if (!deviceId) {
|
|
154
|
+
logLine("SKIP_no_deviceId");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
logLine("END");
|
|
158
|
+
const normalizedDeviceId = deviceId.toUpperCase();
|
|
159
|
+
sseEmitter.broadcastToolEvent(normalizedDeviceId, runId, {
|
|
160
|
+
type: "tool-hook",
|
|
161
|
+
data: {
|
|
162
|
+
when: "after",
|
|
163
|
+
runId,
|
|
164
|
+
deviceId: normalizedDeviceId,
|
|
165
|
+
sessionKey: ctx.sessionKey,
|
|
166
|
+
toolName: event.toolName,
|
|
167
|
+
toolCallId: event.toolCallId,
|
|
168
|
+
error: event.error ?? null,
|
|
169
|
+
result: event.result,
|
|
170
|
+
durationMs: event.durationMs ?? null,
|
|
171
|
+
ts: Date.now(),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
});
|
package/install.js
CHANGED
|
@@ -260,20 +260,27 @@ log("");
|
|
|
260
260
|
log("Gateway URL: " + BOLD_YELLOW(gatewayUrl));
|
|
261
261
|
log("Bearer Token: " + BOLD_YELLOW(gatewayToken));
|
|
262
262
|
log("");
|
|
263
|
-
function
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
266
|
-
if (
|
|
267
|
-
if (
|
|
268
|
-
if (
|
|
269
|
-
if (
|
|
270
|
-
if (
|
|
271
|
-
return
|
|
263
|
+
function classifyIp(ip) {
|
|
264
|
+
const p = ip.split(".").map(Number);
|
|
265
|
+
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return "tailscale";
|
|
266
|
+
if (p[0] === 127) return "loopback";
|
|
267
|
+
if (p[0] === 10) return "private";
|
|
268
|
+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return "private";
|
|
269
|
+
if (p[0] === 192 && p[1] === 168) return "private";
|
|
270
|
+
if (p[0] === 169 && p[1] === 254) return "private";
|
|
271
|
+
return "public";
|
|
272
272
|
}
|
|
273
273
|
const ip = new URL(gatewayUrl).hostname;
|
|
274
|
-
|
|
274
|
+
const ipType = classifyIp(ip);
|
|
275
|
+
if (ipType === "tailscale") {
|
|
276
|
+
log("This is a Tailscale network URL (" + ip + ").");
|
|
277
|
+
log("Accessible from your Tailnet devices.");
|
|
278
|
+
} else if (ipType === "private") {
|
|
275
279
|
log("This is a LOCAL network URL (" + ip + ", bind=" + bindMode + ").");
|
|
276
280
|
log("If you need public access, configure HTTPS, Tailscale, or a reverse proxy.");
|
|
281
|
+
} else if (ipType === "loopback") {
|
|
282
|
+
log("This is a LOOPBACK URL (" + ip + ").");
|
|
283
|
+
log("Only accessible from this machine.");
|
|
277
284
|
} else {
|
|
278
285
|
log("This URL appears to be publicly accessible (" + ip + ").");
|
|
279
286
|
}
|
package/install.sh
CHANGED
|
@@ -232,19 +232,25 @@ console.log("");
|
|
|
232
232
|
console.log("Gateway URL: " + YB + "http://" + host + ":" + port + N);
|
|
233
233
|
console.log("Bearer Token: " + YB + token + N);
|
|
234
234
|
console.log("");
|
|
235
|
-
function
|
|
235
|
+
function classifyIp(ip) {
|
|
236
236
|
var p = ip.split(".").map(Number);
|
|
237
|
-
if (p[0] === 127) return
|
|
238
|
-
if (p[0] ===
|
|
239
|
-
if (p[0] ===
|
|
240
|
-
if (p[0] ===
|
|
241
|
-
if (p[0] ===
|
|
242
|
-
if (p[0] ===
|
|
243
|
-
return
|
|
237
|
+
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return "tailscale";
|
|
238
|
+
if (p[0] === 127) return "loopback";
|
|
239
|
+
if (p[0] === 10) return "private";
|
|
240
|
+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return "private";
|
|
241
|
+
if (p[0] === 192 && p[1] === 168) return "private";
|
|
242
|
+
if (p[0] === 169 && p[1] === 254) return "private";
|
|
243
|
+
return "public";
|
|
244
244
|
}
|
|
245
|
-
if (
|
|
245
|
+
if (classifyIp(host) === "tailscale") {
|
|
246
|
+
console.log("This is a Tailscale network URL (" + host + ").");
|
|
247
|
+
console.log("Accessible from your Tailnet devices.");
|
|
248
|
+
} else if (classifyIp(host) === "private") {
|
|
246
249
|
console.log("This is a LOCAL network URL (" + host + ", bind=" + bind + ").");
|
|
247
250
|
console.log("If you need public access, configure HTTPS, Tailscale, or a reverse proxy.");
|
|
251
|
+
} else if (classifyIp(host) === "loopback") {
|
|
252
|
+
console.log("This is a LOOPBACK URL (" + host + ").");
|
|
253
|
+
console.log("Only accessible from this machine.");
|
|
248
254
|
} else {
|
|
249
255
|
console.log("This URL appears to be publicly accessible (" + host + ").");
|
|
250
256
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syengup/friday-channel-next",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -11,14 +11,6 @@
|
|
|
11
11
|
"tsconfig.json",
|
|
12
12
|
"openclaw.plugin.json"
|
|
13
13
|
],
|
|
14
|
-
"scripts": {
|
|
15
|
-
"build": "tsc -p tsconfig.json",
|
|
16
|
-
"test": "pnpm test:unit && pnpm test:e2e",
|
|
17
|
-
"test:unit": "vitest run",
|
|
18
|
-
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
19
|
-
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
20
|
-
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
21
|
-
},
|
|
22
14
|
"bin": {
|
|
23
15
|
"friday-channel-next": "./install.js",
|
|
24
16
|
"install-friday-next": "./install.js"
|
|
@@ -64,5 +56,13 @@
|
|
|
64
56
|
"typescript": "^6.0.3",
|
|
65
57
|
"vitest": "^4.1.5",
|
|
66
58
|
"zod": "^4.3.6"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsc -p tsconfig.json",
|
|
62
|
+
"test": "pnpm test:unit && pnpm test:e2e",
|
|
63
|
+
"test:unit": "vitest run",
|
|
64
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
65
|
+
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
66
|
+
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
67
67
|
}
|
|
68
|
-
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
+
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
6
|
+
|
|
7
|
+
const mockExecImpl = vi.hoisted(() => vi.fn());
|
|
8
|
+
vi.mock("node:child_process", () => ({
|
|
9
|
+
exec: mockExecImpl,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { handleDeviceApprove } from "./device-approve.js";
|
|
13
|
+
|
|
14
|
+
class MockRes extends EventEmitter {
|
|
15
|
+
statusCode = 0;
|
|
16
|
+
headers: Record<string, string> = {};
|
|
17
|
+
body = "";
|
|
18
|
+
setHeader(name: string, value: string): void {
|
|
19
|
+
this.headers[name.toLowerCase()] = value;
|
|
20
|
+
}
|
|
21
|
+
end(body?: string): void {
|
|
22
|
+
if (body) this.body += body;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mockReq(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
|
|
27
|
+
const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
|
|
28
|
+
stream.method = method;
|
|
29
|
+
stream.headers = headers;
|
|
30
|
+
return stream;
|
|
31
|
+
}
|
|
32
|
+
|
|
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
|
+
const DEVICE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
|
|
66
|
+
const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
|
|
67
|
+
|
|
68
|
+
describe("handleDeviceApprove", () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
setMockRuntime();
|
|
71
|
+
mockExecImpl.mockReset();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns 405 on non-POST", async () => {
|
|
75
|
+
const req = { method: "GET", headers: {} } as IncomingMessage;
|
|
76
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
77
|
+
await handleDeviceApprove(req, res);
|
|
78
|
+
expect((res as unknown as MockRes).statusCode).toBe(405);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns 401 for missing auth", async () => {
|
|
82
|
+
const req = mockReq("POST");
|
|
83
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
84
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
85
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
86
|
+
await p;
|
|
87
|
+
expect((res as unknown as MockRes).statusCode).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns 400 for missing body", async () => {
|
|
91
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
92
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
93
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
94
|
+
req.end("");
|
|
95
|
+
await p;
|
|
96
|
+
expect((res as unknown as MockRes).statusCode).toBe(400);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns 400 for missing deviceId", async () => {
|
|
100
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
101
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
102
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
103
|
+
req.end(JSON.stringify({}));
|
|
104
|
+
await p;
|
|
105
|
+
expect((res as unknown as MockRes).statusCode).toBe(400);
|
|
106
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("deviceId");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns 502 when devices list CLI fails", async () => {
|
|
110
|
+
mockExecError("ENOENT");
|
|
111
|
+
|
|
112
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
113
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
114
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
115
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
116
|
+
await p;
|
|
117
|
+
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
118
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Failed to list devices");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns 502 when devices list returns invalid JSON", async () => {
|
|
122
|
+
mockExecSuccess("not valid json {{{");
|
|
123
|
+
|
|
124
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
125
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
126
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
127
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
128
|
+
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");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns 404 when deviceId not in pending list", async () => {
|
|
134
|
+
mockExecSuccess(JSON.stringify({
|
|
135
|
+
pending: [{ requestId: "uuid-1", deviceId: "OTHER_DEVICE" }],
|
|
136
|
+
paired: [],
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
140
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
141
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
142
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
143
|
+
await p;
|
|
144
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
145
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
146
|
+
expect(body.error).toContain("No pending device found");
|
|
147
|
+
expect(body.deviceId).toBe(DEVICE_ID.toUpperCase());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns 404 when pending array is empty", async () => {
|
|
151
|
+
mockExecSuccess(JSON.stringify({ pending: [], paired: [] }));
|
|
152
|
+
|
|
153
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
154
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
155
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
156
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
157
|
+
await p;
|
|
158
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns 502 when approve command fails", async () => {
|
|
162
|
+
mockExecSuccess(JSON.stringify({
|
|
163
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
164
|
+
paired: [],
|
|
165
|
+
}));
|
|
166
|
+
// second call (approve) fails
|
|
167
|
+
const approveErr = new Error("Command failed") as Error & { stderr: string };
|
|
168
|
+
approveErr.stderr = "unknown requestId";
|
|
169
|
+
mockExecErrorWithStderr(approveErr);
|
|
170
|
+
|
|
171
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
172
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
173
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
174
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
175
|
+
await p;
|
|
176
|
+
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
177
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
178
|
+
expect(body.error).toContain("Device approval command failed");
|
|
179
|
+
expect(body.detail).toBe("unknown requestId");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("returns 502 when approve returns non-JSON", async () => {
|
|
183
|
+
mockExecSuccess(JSON.stringify({
|
|
184
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
185
|
+
paired: [],
|
|
186
|
+
}));
|
|
187
|
+
mockExecSuccess("No pending device pairing requests to approve");
|
|
188
|
+
|
|
189
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
190
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
191
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
192
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
193
|
+
await p;
|
|
194
|
+
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
195
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response from device approval");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("succeeds with complete flow", async () => {
|
|
199
|
+
mockExecSuccess(JSON.stringify({
|
|
200
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
201
|
+
paired: [],
|
|
202
|
+
}));
|
|
203
|
+
mockExecSuccess(JSON.stringify({
|
|
204
|
+
requestId: REQUEST_ID,
|
|
205
|
+
device: { deviceId: DEVICE_ID, approvedAtMs: 1778571972361 },
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
209
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
210
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
211
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
212
|
+
await p;
|
|
213
|
+
|
|
214
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
215
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
216
|
+
expect(body.ok).toBe(true);
|
|
217
|
+
expect(body.deviceId).toBe(DEVICE_ID.toUpperCase());
|
|
218
|
+
expect(body.requestId).toBe(REQUEST_ID);
|
|
219
|
+
expect(body.approvedAtMs).toBe(1778571972361);
|
|
220
|
+
expect(mockExecImpl).toHaveBeenCalledTimes(2);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("normalizes deviceId case-insensitively", async () => {
|
|
224
|
+
mockExecSuccess(JSON.stringify({
|
|
225
|
+
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
226
|
+
paired: [],
|
|
227
|
+
}));
|
|
228
|
+
mockExecSuccess(JSON.stringify({
|
|
229
|
+
requestId: REQUEST_ID,
|
|
230
|
+
device: { deviceId: DEVICE_ID, approvedAtMs: 1 },
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
234
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
235
|
+
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
236
|
+
req.end(JSON.stringify({ deviceId: DEVICE_ID.toLowerCase() }));
|
|
237
|
+
await p;
|
|
238
|
+
|
|
239
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
240
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
241
|
+
expect(body.ok).toBe(true);
|
|
242
|
+
expect(body.deviceId).toBe(DEVICE_ID.toUpperCase());
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
4
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
6
|
+
|
|
7
|
+
// macOS LaunchAgent strips Homebrew from PATH; prepend common prefixes so
|
|
8
|
+
// the child process can find openclaw. Windows doesn't need this.
|
|
9
|
+
const EXEC_ENV = process.platform === "win32"
|
|
10
|
+
? process.env
|
|
11
|
+
: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
|
|
12
|
+
|
|
13
|
+
interface PendingDevice {
|
|
14
|
+
requestId: string;
|
|
15
|
+
deviceId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DeviceListJson {
|
|
19
|
+
pending?: PendingDevice[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ApproveJson {
|
|
23
|
+
requestId: string;
|
|
24
|
+
device?: { deviceId: string; approvedAtMs?: number };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function execAsync(command: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
|
|
30
|
+
if (error) {
|
|
31
|
+
reject(error);
|
|
32
|
+
} else {
|
|
33
|
+
resolve({ stdout, stderr });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
child.stdout?.on("data", () => { /* drain */ });
|
|
37
|
+
child.stderr?.on("data", () => { /* drain */ });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function handleDeviceApprove(
|
|
42
|
+
req: IncomingMessage,
|
|
43
|
+
res: ServerResponse,
|
|
44
|
+
): Promise<boolean> {
|
|
45
|
+
const log = createFridayNextLogger("device-approve");
|
|
46
|
+
|
|
47
|
+
if (req.method !== "POST") {
|
|
48
|
+
res.statusCode = 405;
|
|
49
|
+
res.setHeader("Content-Type", "application/json");
|
|
50
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const token = extractBearerToken(req);
|
|
55
|
+
if (!token) {
|
|
56
|
+
res.statusCode = 401;
|
|
57
|
+
res.setHeader("Content-Type", "application/json");
|
|
58
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = await readJsonBody(req);
|
|
63
|
+
if (!body) {
|
|
64
|
+
res.statusCode = 400;
|
|
65
|
+
res.setHeader("Content-Type", "application/json");
|
|
66
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rawDeviceId = typeof body.deviceId === "string" ? body.deviceId : "";
|
|
71
|
+
if (!rawDeviceId.trim()) {
|
|
72
|
+
res.statusCode = 400;
|
|
73
|
+
res.setHeader("Content-Type", "application/json");
|
|
74
|
+
res.end(JSON.stringify({ error: "Missing required field: deviceId" }));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const normalizedDeviceId = rawDeviceId.trim().toUpperCase();
|
|
79
|
+
|
|
80
|
+
let listStdout: string;
|
|
81
|
+
try {
|
|
82
|
+
const result = await execAsync("openclaw devices list --json", 15000);
|
|
83
|
+
listStdout = result.stdout;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const stderr = (err as { stderr?: string })?.stderr?.trim();
|
|
86
|
+
log.error(`devices list failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
87
|
+
res.statusCode = 502;
|
|
88
|
+
res.setHeader("Content-Type", "application/json");
|
|
89
|
+
res.end(JSON.stringify({ error: "Failed to list devices from gateway", detail: stderr || undefined }));
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let listData: DeviceListJson;
|
|
94
|
+
try {
|
|
95
|
+
listData = JSON.parse(listStdout) as DeviceListJson;
|
|
96
|
+
} catch {
|
|
97
|
+
log.error(`devices list returned invalid JSON: ${listStdout.slice(0, 200)}`);
|
|
98
|
+
res.statusCode = 502;
|
|
99
|
+
res.setHeader("Content-Type", "application/json");
|
|
100
|
+
res.end(JSON.stringify({ error: "Unexpected response from gateway device list" }));
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pending = listData.pending ?? [];
|
|
105
|
+
const match = pending.find(
|
|
106
|
+
(entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (!match) {
|
|
110
|
+
res.statusCode = 404;
|
|
111
|
+
res.setHeader("Content-Type", "application/json");
|
|
112
|
+
res.end(JSON.stringify({
|
|
113
|
+
error: "No pending device found for this deviceId",
|
|
114
|
+
deviceId: normalizedDeviceId,
|
|
115
|
+
}));
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const requestId = match.requestId;
|
|
120
|
+
log.info(`approving deviceId=${normalizedDeviceId} requestId=${requestId}`);
|
|
121
|
+
|
|
122
|
+
let approveStdout: string;
|
|
123
|
+
try {
|
|
124
|
+
const result = await execAsync(`openclaw devices approve ${requestId} --json`, 15000);
|
|
125
|
+
approveStdout = result.stdout;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const stderr = (err as { stderr?: string })?.stderr?.trim();
|
|
128
|
+
log.error(`devices approve failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
res.statusCode = 502;
|
|
130
|
+
res.setHeader("Content-Type", "application/json");
|
|
131
|
+
res.end(JSON.stringify({
|
|
132
|
+
error: "Device approval command failed",
|
|
133
|
+
detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
|
|
134
|
+
}));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let approveData: ApproveJson;
|
|
139
|
+
try {
|
|
140
|
+
approveData = JSON.parse(approveStdout) as ApproveJson;
|
|
141
|
+
} catch {
|
|
142
|
+
log.error(`devices approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
|
|
143
|
+
res.statusCode = 502;
|
|
144
|
+
res.setHeader("Content-Type", "application/json");
|
|
145
|
+
res.end(JSON.stringify({ error: "Unexpected response from device approval" }));
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
res.statusCode = 200;
|
|
150
|
+
res.setHeader("Content-Type", "application/json");
|
|
151
|
+
res.end(JSON.stringify({
|
|
152
|
+
ok: true,
|
|
153
|
+
deviceId: normalizedDeviceId,
|
|
154
|
+
requestId: approveData.requestId,
|
|
155
|
+
approvedAtMs: approveData.device?.approvedAtMs,
|
|
156
|
+
}));
|
|
157
|
+
return true;
|
|
158
|
+
}
|
package/src/http/server.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { handleSseStream } from "./handlers/sse.js";
|
|
|
11
11
|
import { handleFilesUpload } from "./handlers/files-upload.js";
|
|
12
12
|
import { handleFilesDownload } from "./handlers/files-download.js";
|
|
13
13
|
import { handleCancel } from "./handlers/cancel.js";
|
|
14
|
+
import { handleDeviceApprove } from "./handlers/device-approve.js";
|
|
14
15
|
import { handleSessionsDelete } from "./handlers/sessions-delete.js";
|
|
15
16
|
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
16
17
|
import { handleModelsList } from "./handlers/models-list.js";
|
|
@@ -59,6 +60,10 @@ async function handleFridayNextRoute(
|
|
|
59
60
|
return await handleCancel(req, res);
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
if (req.method === "POST" && pathname === "/friday-next/device-approve") {
|
|
64
|
+
return await handleDeviceApprove(req, res);
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
if (req.method === "DELETE" && pathname === "/friday-next/sessions") {
|
|
63
68
|
return await handleSessionsDelete(req, res);
|
|
64
69
|
}
|