@syengup/friday-channel-next 0.0.12 → 0.0.14
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/install.js +9 -3
- package/install.sh +13 -3
- package/package.json +2 -2
- 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/install.js
CHANGED
|
@@ -46,13 +46,19 @@ function isRunningFromNpmPackage() {
|
|
|
46
46
|
|
|
47
47
|
// --------------- prerequisites ---------------
|
|
48
48
|
|
|
49
|
-
const required = ["
|
|
49
|
+
const required = ["node", "openclaw"];
|
|
50
50
|
const missing = required.filter((c) => !has(c));
|
|
51
51
|
if (missing.length) {
|
|
52
52
|
missing.forEach((c) => err(`${c} is required but not found. Install it first.`));
|
|
53
53
|
process.exit(1);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const PKG = has("pnpm") ? "pnpm" : has("npm") ? "npm" : null;
|
|
57
|
+
if (!PKG) {
|
|
58
|
+
err("pnpm or npm is required but not found. Install one first.");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
if (!existsSync(OPENCLAW_CONFIG)) {
|
|
57
63
|
err(`OpenClaw config not found at ${OPENCLAW_CONFIG}`);
|
|
58
64
|
err("Make sure OpenClaw is installed and has been run at least once.");
|
|
@@ -88,10 +94,10 @@ process.chdir(PLUGIN_DIR);
|
|
|
88
94
|
// --------------- install + build ---------------
|
|
89
95
|
|
|
90
96
|
log("Installing dependencies...");
|
|
91
|
-
execSync(
|
|
97
|
+
execSync(`${PKG} install`, { stdio: "inherit" });
|
|
92
98
|
|
|
93
99
|
log("Building TypeScript...");
|
|
94
|
-
execSync(
|
|
100
|
+
execSync(`${PKG} run build`, { stdio: "inherit" });
|
|
95
101
|
|
|
96
102
|
// --------------- configure OpenClaw ---------------
|
|
97
103
|
|
package/install.sh
CHANGED
|
@@ -22,13 +22,23 @@ err() { printf " ${RED}X${NC} %s\\n" "$1" >&2; }
|
|
|
22
22
|
trap 'err "Install failed."' ERR
|
|
23
23
|
|
|
24
24
|
# Check prerequisites
|
|
25
|
-
for cmd in
|
|
25
|
+
for cmd in node git openclaw; do
|
|
26
26
|
if ! command -v "$cmd" &>/dev/null; then
|
|
27
27
|
err "$cmd is required but not found. Install it first."
|
|
28
28
|
exit 1
|
|
29
29
|
fi
|
|
30
30
|
done
|
|
31
31
|
|
|
32
|
+
# Auto-detect package manager (prefer pnpm, fall back to npm)
|
|
33
|
+
if command -v pnpm &>/dev/null; then
|
|
34
|
+
PKG="pnpm"
|
|
35
|
+
elif command -v npm &>/dev/null; then
|
|
36
|
+
PKG="npm"
|
|
37
|
+
else
|
|
38
|
+
err "pnpm or npm is required but not found. Install one first."
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
32
42
|
if [ ! -f "$OPENCLAW_CONFIG" ]; then
|
|
33
43
|
err "OpenClaw config not found at $OPENCLAW_CONFIG"
|
|
34
44
|
err "Make sure OpenClaw is installed and has been run at least once."
|
|
@@ -48,10 +58,10 @@ fi
|
|
|
48
58
|
cd "$PLUGIN_DIR"
|
|
49
59
|
|
|
50
60
|
log "Installing dependencies..."
|
|
51
|
-
|
|
61
|
+
$PKG install
|
|
52
62
|
|
|
53
63
|
log "Building TypeScript..."
|
|
54
|
-
|
|
64
|
+
$PKG run build
|
|
55
65
|
|
|
56
66
|
# Step 2: Configure OpenClaw
|
|
57
67
|
|
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.14",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc -p tsconfig.json",
|
|
16
|
-
"test": "
|
|
16
|
+
"test": "npm run test:unit && npm run test:e2e",
|
|
17
17
|
"test:unit": "vitest run",
|
|
18
18
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
19
19
|
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
@@ -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
|
}
|