@syengup/friday-channel-next 0.1.8 → 0.1.9
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.js +6 -0
- package/dist/src/config.js +2 -1
- package/dist/src/http/handlers/health.d.ts +0 -1
- package/dist/src/http/handlers/health.js +1 -82
- package/install.js +5 -1
- package/package.json +10 -11
- package/src/agent/node-pairing-bridge.ts +6 -0
- package/src/config.ts +3 -1
- package/src/e2e/auto-approve.integration.test.ts +6 -66
- package/src/http/handlers/health.test.ts +3 -264
- package/src/http/handlers/health.ts +1 -98
|
@@ -29,10 +29,16 @@ function resolveOpenClawDist() {
|
|
|
29
29
|
const candidates = [
|
|
30
30
|
process.env.OPENCLAW_DIST,
|
|
31
31
|
fromBin,
|
|
32
|
+
// Windows: standard npm -g locations
|
|
32
33
|
join(process.env.APPDATA ?? "", "npm/node_modules/openclaw/dist"),
|
|
34
|
+
join(process.env.LOCALAPPDATA ?? "", "npm/node_modules/openclaw/dist"),
|
|
35
|
+
// Cross-platform: version-manager paths detected from PATH resolution
|
|
36
|
+
// (nvm/fnm/asdf installs are found by resolveOpenClawDistFromPath via PATH)
|
|
33
37
|
"/opt/homebrew/lib/node_modules/openclaw/dist",
|
|
34
38
|
"/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw/dist",
|
|
35
39
|
"/usr/local/lib/node_modules/openclaw/dist",
|
|
40
|
+
// Linux: npm -g with prefix=/usr
|
|
41
|
+
"/usr/lib/node_modules/openclaw/dist",
|
|
36
42
|
].filter((v) => typeof v === "string" && v.length > 0);
|
|
37
43
|
for (const root of candidates) {
|
|
38
44
|
try {
|
package/dist/src/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
function asObject(value) {
|
|
2
3
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
3
4
|
? value
|
|
@@ -28,7 +29,7 @@ export function resolveFridayNextConfig(cfg) {
|
|
|
28
29
|
pathPrefix: asString(section.pathPrefix, "/friday-next"),
|
|
29
30
|
transport: asString(section.transport, "http+sse"),
|
|
30
31
|
historyLimit: asNumber(section.historyLimit, 25, 1, 200),
|
|
31
|
-
historyDir: asString(section.historyDir, `${
|
|
32
|
+
historyDir: asString(section.historyDir, `${homedir()}/.openclaw/friday-next/history`),
|
|
32
33
|
logLevel: asString(section.logLevel, "info"),
|
|
33
34
|
authToken,
|
|
34
35
|
corsEnabled: asBool(cors.enabled, false),
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
|
|
2
1
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
3
2
|
import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
|
|
4
3
|
import { createFridayNextLogger } from "../../logging.js";
|
|
@@ -39,95 +38,15 @@ export async function handleHealth(req, res) {
|
|
|
39
38
|
nodeDeviceId,
|
|
40
39
|
};
|
|
41
40
|
const log = createFridayNextLogger("health");
|
|
42
|
-
if (deviceId) {
|
|
43
|
-
result.devicePairing = await checkDevicePairing(deviceId, selfHeal, result, log);
|
|
44
|
-
}
|
|
45
41
|
if (nodeDeviceId) {
|
|
46
42
|
result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
|
|
47
43
|
}
|
|
48
|
-
|
|
49
|
-
result.devicePairing?.status,
|
|
50
|
-
result.nodePairing?.status,
|
|
51
|
-
].filter(Boolean);
|
|
52
|
-
result.ok = statuses.length === 0 || statuses.every((s) => s === "ok" || s === "pending");
|
|
44
|
+
result.ok = !result.nodePairing || (result.nodePairing.status === "ok" || result.nodePairing.status === "pending");
|
|
53
45
|
res.statusCode = 200;
|
|
54
46
|
res.setHeader("Content-Type", "application/json");
|
|
55
47
|
res.end(JSON.stringify(result));
|
|
56
48
|
return true;
|
|
57
49
|
}
|
|
58
|
-
async function checkDevicePairing(deviceId, selfHeal, result, log) {
|
|
59
|
-
const normalizedDeviceId = deviceId.trim().toUpperCase();
|
|
60
|
-
let pairing;
|
|
61
|
-
try {
|
|
62
|
-
pairing = await listDevicePairing();
|
|
63
|
-
}
|
|
64
|
-
catch (err) {
|
|
65
|
-
log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
-
return {
|
|
67
|
-
status: "failed",
|
|
68
|
-
detail: `listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
69
|
-
devicePaired: false,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
const pairedDevice = (pairing?.paired ?? []).find((entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId);
|
|
73
|
-
if (pairedDevice) {
|
|
74
|
-
const approvedScopes = pairedDevice.approvedScopes ?? [];
|
|
75
|
-
const tokens = pairedDevice.tokens ?? {};
|
|
76
|
-
const hasValidToken = Object.values(tokens).some((t) => !t.revokedAtMs);
|
|
77
|
-
if (approvedScopes.length === 0 || !hasValidToken) {
|
|
78
|
-
const issues = [];
|
|
79
|
-
if (approvedScopes.length === 0)
|
|
80
|
-
issues.push("no approved scopes");
|
|
81
|
-
if (!hasValidToken)
|
|
82
|
-
issues.push("all tokens revoked");
|
|
83
|
-
return {
|
|
84
|
-
status: "degraded",
|
|
85
|
-
detail: `Device paired but degraded: ${issues.join(", ")}`,
|
|
86
|
-
devicePaired: true,
|
|
87
|
-
approvedScopesEmpty: approvedScopes.length === 0,
|
|
88
|
-
tokensRevoked: !hasValidToken,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
return { status: "ok", detail: "Device paired and healthy", devicePaired: true };
|
|
92
|
-
}
|
|
93
|
-
const pendingDevice = (pairing?.pending ?? []).find((entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId);
|
|
94
|
-
if (pendingDevice && selfHeal) {
|
|
95
|
-
try {
|
|
96
|
-
const approved = await approveDevicePairing(pendingDevice.requestId);
|
|
97
|
-
const succeeded = approved && approved.status === "approved";
|
|
98
|
-
(result.repairActions ??= []).push({
|
|
99
|
-
component: "devicePairing",
|
|
100
|
-
action: "approveDevicePairing",
|
|
101
|
-
result: succeeded ? "ok" : "failed",
|
|
102
|
-
detail: succeeded
|
|
103
|
-
? `Auto-approved device ${normalizedDeviceId}`
|
|
104
|
-
: `approveDevicePairing returned status=${approved?.status ?? "null"}`,
|
|
105
|
-
});
|
|
106
|
-
if (succeeded) {
|
|
107
|
-
log.info(`Auto-approved device ${normalizedDeviceId}`);
|
|
108
|
-
return { status: "ok", detail: "Device was pending, auto-approved", devicePaired: true };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
catch (err) {
|
|
112
|
-
log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
-
(result.repairActions ??= []).push({
|
|
114
|
-
component: "devicePairing",
|
|
115
|
-
action: "approveDevicePairing",
|
|
116
|
-
result: "failed",
|
|
117
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
return {
|
|
121
|
-
status: "degraded",
|
|
122
|
-
detail: "Device pending but auto-approve failed",
|
|
123
|
-
devicePaired: false,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
if (pendingDevice) {
|
|
127
|
-
return { status: "pending", detail: "Device is pending approval", devicePaired: false };
|
|
128
|
-
}
|
|
129
|
-
return { status: "not_found", detail: `Device ${normalizedDeviceId} not registered`, devicePaired: false };
|
|
130
|
-
}
|
|
131
50
|
async function checkNodePairing(nodeDeviceId, selfHeal, result, log) {
|
|
132
51
|
const normalizedNodeId = nodeDeviceId.trim().toUpperCase();
|
|
133
52
|
let listData, listNodePairing, approveNodePairing;
|
package/install.js
CHANGED
|
@@ -14,7 +14,11 @@ function realHome() {
|
|
|
14
14
|
const h = execSync(`sh -c 'echo ~${sudoUser}'`, { encoding: "utf8" }).trim();
|
|
15
15
|
if (h && !h.startsWith("~") && existsSync(h)) return h;
|
|
16
16
|
} catch {}
|
|
17
|
-
for (const g of [
|
|
17
|
+
for (const g of [
|
|
18
|
+
`/home/${sudoUser}`,
|
|
19
|
+
`/Users/${sudoUser}`,
|
|
20
|
+
`C:\\Users\\${sudoUser}`,
|
|
21
|
+
]) {
|
|
18
22
|
if (existsSync(g)) return g;
|
|
19
23
|
}
|
|
20
24
|
return current;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syengup/friday-channel-next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,15 +12,6 @@
|
|
|
12
12
|
"tsconfig.json",
|
|
13
13
|
"openclaw.plugin.json"
|
|
14
14
|
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc -p tsconfig.json",
|
|
17
|
-
"prepublishOnly": "pnpm build",
|
|
18
|
-
"test": "npm run test:unit && npm run test:e2e",
|
|
19
|
-
"test:unit": "vitest run",
|
|
20
|
-
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
21
|
-
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
22
|
-
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
23
|
-
},
|
|
24
15
|
"bin": {
|
|
25
16
|
"friday-channel-next": "install.js"
|
|
26
17
|
},
|
|
@@ -66,5 +57,13 @@
|
|
|
66
57
|
"typescript": "^6.0.3",
|
|
67
58
|
"vitest": "^4.1.5",
|
|
68
59
|
"zod": "^4.3.6"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc -p tsconfig.json",
|
|
63
|
+
"test": "npm run test:unit && npm run test:e2e",
|
|
64
|
+
"test:unit": "vitest run",
|
|
65
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
66
|
+
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
67
|
+
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
69
68
|
}
|
|
70
|
-
}
|
|
69
|
+
}
|
|
@@ -30,10 +30,16 @@ function resolveOpenClawDist(): string {
|
|
|
30
30
|
const candidates: string[] = [
|
|
31
31
|
process.env.OPENCLAW_DIST,
|
|
32
32
|
fromBin,
|
|
33
|
+
// Windows: standard npm -g locations
|
|
33
34
|
join(process.env.APPDATA ?? "", "npm/node_modules/openclaw/dist"),
|
|
35
|
+
join(process.env.LOCALAPPDATA ?? "", "npm/node_modules/openclaw/dist"),
|
|
36
|
+
// Cross-platform: version-manager paths detected from PATH resolution
|
|
37
|
+
// (nvm/fnm/asdf installs are found by resolveOpenClawDistFromPath via PATH)
|
|
34
38
|
"/opt/homebrew/lib/node_modules/openclaw/dist",
|
|
35
39
|
"/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw/dist",
|
|
36
40
|
"/usr/local/lib/node_modules/openclaw/dist",
|
|
41
|
+
// Linux: npm -g with prefix=/usr
|
|
42
|
+
"/usr/lib/node_modules/openclaw/dist",
|
|
37
43
|
].filter((v): v is string => typeof v === "string" && v.length > 0);
|
|
38
44
|
|
|
39
45
|
for (const root of candidates) {
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
1
3
|
export type FridayNextLogLevel = "debug" | "info" | "warn" | "error";
|
|
2
4
|
|
|
3
5
|
export type FridayNextConfig = {
|
|
@@ -52,7 +54,7 @@ export function resolveFridayNextConfig(cfg: unknown): FridayNextConfig {
|
|
|
52
54
|
historyLimit: asNumber(section.historyLimit, 25, 1, 200),
|
|
53
55
|
historyDir: asString(
|
|
54
56
|
section.historyDir,
|
|
55
|
-
`${
|
|
57
|
+
`${homedir()}/.openclaw/friday-next/history`,
|
|
56
58
|
),
|
|
57
59
|
logLevel: asString(section.logLevel, "info") as FridayNextLogLevel,
|
|
58
60
|
authToken,
|
|
@@ -291,55 +291,6 @@ describe("e2e two-step auto-approval", () => {
|
|
|
291
291
|
|
|
292
292
|
// ── Health endpoint with selfHeal ────────────────────────────────
|
|
293
293
|
|
|
294
|
-
it("GET /friday-next/health?selfHeal=true 自动批准 pending device", async () => {
|
|
295
|
-
mockListDevices.mockResolvedValueOnce({
|
|
296
|
-
pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
|
|
297
|
-
paired: [],
|
|
298
|
-
});
|
|
299
|
-
mockApproveDevice.mockResolvedValueOnce({
|
|
300
|
-
status: "approved",
|
|
301
|
-
requestId: DEVICE_REQUEST_ID,
|
|
302
|
-
device: { deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
const app = createAppSimulator({ token: "test-token" });
|
|
306
|
-
const res = await app.rawRequest({
|
|
307
|
-
method: "GET",
|
|
308
|
-
path: `/friday-next/health?deviceId=${FAKE_DEVICE_ID}&selfHeal=true`,
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
expect(res.status).toBe(200);
|
|
312
|
-
const body = JSON.parse(res.body);
|
|
313
|
-
expect(body.ok).toBe(true);
|
|
314
|
-
expect(body.devicePairing.status).toBe("ok");
|
|
315
|
-
expect(body.devicePairing.detail).toContain("auto-approved");
|
|
316
|
-
expect(body.repairActions).toHaveLength(1);
|
|
317
|
-
expect(body.repairActions[0].component).toBe("devicePairing");
|
|
318
|
-
expect(body.repairActions[0].result).toBe("ok");
|
|
319
|
-
expect(mockApproveDevice).toHaveBeenCalledWith(DEVICE_REQUEST_ID);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("GET /friday-next/health 不带 selfHeal 时不自动批准 (只返回 pending)", async () => {
|
|
323
|
-
mockListDevices.mockResolvedValueOnce({
|
|
324
|
-
pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
|
|
325
|
-
paired: [],
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
const app = createAppSimulator({ token: "test-token" });
|
|
329
|
-
const res = await app.rawRequest({
|
|
330
|
-
method: "GET",
|
|
331
|
-
path: `/friday-next/health?deviceId=${FAKE_DEVICE_ID}`,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
expect(res.status).toBe(200);
|
|
335
|
-
const body = JSON.parse(res.body);
|
|
336
|
-
expect(body.devicePairing.status).toBe("pending");
|
|
337
|
-
expect(body.devicePairing.detail).toContain("pending approval");
|
|
338
|
-
expect(mockApproveDevice).not.toHaveBeenCalled();
|
|
339
|
-
// ok 仍为 true,因为 pending 也视为 ok
|
|
340
|
-
expect(body.ok).toBe(true);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
294
|
it("GET /friday-next/health?selfHeal=true 自动批准 pending node", async () => {
|
|
344
295
|
mockListDevices.mockResolvedValue({ pending: [], paired: [] }); // device 不相关
|
|
345
296
|
|
|
@@ -371,19 +322,9 @@ describe("e2e two-step auto-approval", () => {
|
|
|
371
322
|
expect(body.repairActions[0].result).toBe("ok");
|
|
372
323
|
});
|
|
373
324
|
|
|
374
|
-
it("GET /friday-next/health?selfHeal=true
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
|
|
378
|
-
paired: [],
|
|
379
|
-
});
|
|
380
|
-
mockApproveDevice.mockResolvedValueOnce({
|
|
381
|
-
status: "approved",
|
|
382
|
-
requestId: DEVICE_REQUEST_ID,
|
|
383
|
-
device: { deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// Node is pending
|
|
325
|
+
it("GET /friday-next/health?selfHeal=true 自动批准 pending node (忽略 deviceId)", async () => {
|
|
326
|
+
// Node is pending (deviceId is ignored — device approval is handled by
|
|
327
|
+
// the WebSocket hello-ok path, not by the health endpoint)
|
|
387
328
|
const mockListNodePairing = vi.fn().mockResolvedValueOnce({
|
|
388
329
|
pending: [{ requestId: NODE_REQUEST_ID, nodeId: FAKE_DEVICE_ID }],
|
|
389
330
|
paired: [],
|
|
@@ -406,10 +347,9 @@ describe("e2e two-step auto-approval", () => {
|
|
|
406
347
|
expect(res.status).toBe(200);
|
|
407
348
|
const body = JSON.parse(res.body);
|
|
408
349
|
expect(body.ok).toBe(true);
|
|
409
|
-
expect(body.devicePairing
|
|
350
|
+
expect(body.devicePairing).toBeUndefined();
|
|
410
351
|
expect(body.nodePairing.status).toBe("ok");
|
|
411
|
-
expect(body.repairActions).toHaveLength(
|
|
412
|
-
expect(body.repairActions[0].component).toBe("
|
|
413
|
-
expect(body.repairActions[1].component).toBe("nodePairing");
|
|
352
|
+
expect(body.repairActions).toHaveLength(1);
|
|
353
|
+
expect(body.repairActions[0].component).toBe("nodePairing");
|
|
414
354
|
});
|
|
415
355
|
});
|
|
@@ -4,16 +4,6 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
4
4
|
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
5
5
|
import { __setMockNodePairingForTests } from "../../agent/node-pairing-bridge.js";
|
|
6
6
|
|
|
7
|
-
const { mockListDevices, mockApproveDevice } = vi.hoisted(() => ({
|
|
8
|
-
mockListDevices: vi.fn(),
|
|
9
|
-
mockApproveDevice: vi.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
vi.mock("openclaw/plugin-sdk/device-bootstrap", () => ({
|
|
13
|
-
listDevicePairing: mockListDevices,
|
|
14
|
-
approveDevicePairing: mockApproveDevice,
|
|
15
|
-
}));
|
|
16
|
-
|
|
17
7
|
const { mockListNodePairing, mockApproveNodePairing } = vi.hoisted(() => ({
|
|
18
8
|
mockListNodePairing: vi.fn(),
|
|
19
9
|
mockApproveNodePairing: vi.fn(),
|
|
@@ -45,8 +35,6 @@ describe("handleHealth", () => {
|
|
|
45
35
|
beforeEach(() => {
|
|
46
36
|
setMockRuntime();
|
|
47
37
|
vi.clearAllMocks();
|
|
48
|
-
mockListDevices.mockResolvedValue({ pending: [], paired: [] });
|
|
49
|
-
mockApproveDevice.mockResolvedValue({ status: "approved", requestId: REQUEST_ID });
|
|
50
38
|
__setMockNodePairingForTests({
|
|
51
39
|
listNodePairing: mockListNodePairing,
|
|
52
40
|
approveNodePairing: mockApproveNodePairing,
|
|
@@ -85,226 +73,6 @@ describe("handleHealth", () => {
|
|
|
85
73
|
expect(body.repairActions).toBeUndefined();
|
|
86
74
|
});
|
|
87
75
|
|
|
88
|
-
// --- selfHeal parameter ---
|
|
89
|
-
|
|
90
|
-
it("selfHeal defaults to false (opt-in)", async () => {
|
|
91
|
-
mockListDevices.mockResolvedValueOnce({
|
|
92
|
-
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
93
|
-
paired: [],
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
97
|
-
authorization: "Bearer test-token",
|
|
98
|
-
});
|
|
99
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
100
|
-
await handleHealth(req, res);
|
|
101
|
-
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
102
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
103
|
-
expect(body.devicePairing.status).toBe("pending");
|
|
104
|
-
expect(mockApproveDevice).not.toHaveBeenCalled();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("selfHeal=true enables auto-approve", async () => {
|
|
108
|
-
mockListDevices.mockResolvedValueOnce({
|
|
109
|
-
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
110
|
-
paired: [],
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=true`, {
|
|
114
|
-
authorization: "Bearer test-token",
|
|
115
|
-
});
|
|
116
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
117
|
-
await handleHealth(req, res);
|
|
118
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
119
|
-
expect(body.devicePairing.status).toBe("ok");
|
|
120
|
-
expect(body.repairActions).toHaveLength(1);
|
|
121
|
-
expect(body.repairActions[0].component).toBe("devicePairing");
|
|
122
|
-
expect(body.repairActions[0].result).toBe("ok");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("selfHeal=True (capital T) is case-insensitive and enables auto-heal", async () => {
|
|
126
|
-
mockListDevices.mockResolvedValueOnce({
|
|
127
|
-
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
128
|
-
paired: [],
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=True`, {
|
|
132
|
-
authorization: "Bearer test-token",
|
|
133
|
-
});
|
|
134
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
135
|
-
await handleHealth(req, res);
|
|
136
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
137
|
-
expect(body.devicePairing.status).toBe("ok");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("selfHeal=FALSE is case-insensitive and stays disabled", async () => {
|
|
141
|
-
mockListDevices.mockResolvedValueOnce({
|
|
142
|
-
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
143
|
-
paired: [],
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=FALSE`, {
|
|
147
|
-
authorization: "Bearer test-token",
|
|
148
|
-
});
|
|
149
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
150
|
-
await handleHealth(req, res);
|
|
151
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
152
|
-
expect(body.devicePairing.status).toBe("pending");
|
|
153
|
-
expect(mockApproveDevice).not.toHaveBeenCalled();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// --- Device pairing: paired + healthy ---
|
|
157
|
-
|
|
158
|
-
it("returns ok when device is paired and healthy", async () => {
|
|
159
|
-
mockListDevices.mockResolvedValueOnce({
|
|
160
|
-
pending: [],
|
|
161
|
-
paired: [
|
|
162
|
-
{ deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
163
|
-
],
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
167
|
-
authorization: "Bearer test-token",
|
|
168
|
-
});
|
|
169
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
170
|
-
await handleHealth(req, res);
|
|
171
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
172
|
-
expect(body.ok).toBe(true);
|
|
173
|
-
expect(body.devicePairing.status).toBe("ok");
|
|
174
|
-
expect(body.devicePairing.devicePaired).toBe(true);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// --- Device pairing: degraded ---
|
|
178
|
-
|
|
179
|
-
it("returns degraded when device has no approved scopes", async () => {
|
|
180
|
-
mockListDevices.mockResolvedValueOnce({
|
|
181
|
-
pending: [],
|
|
182
|
-
paired: [
|
|
183
|
-
{ deviceId: DEVICE_ID, approvedScopes: [], tokens: { t1: {} } },
|
|
184
|
-
],
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
188
|
-
authorization: "Bearer test-token",
|
|
189
|
-
});
|
|
190
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
191
|
-
await handleHealth(req, res);
|
|
192
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
193
|
-
expect(body.ok).toBe(false);
|
|
194
|
-
expect(body.devicePairing.status).toBe("degraded");
|
|
195
|
-
expect(body.devicePairing.approvedScopesEmpty).toBe(true);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("returns degraded when all tokens are revoked", async () => {
|
|
199
|
-
mockListDevices.mockResolvedValueOnce({
|
|
200
|
-
pending: [],
|
|
201
|
-
paired: [
|
|
202
|
-
{
|
|
203
|
-
deviceId: DEVICE_ID,
|
|
204
|
-
approvedScopes: ["operator.read"],
|
|
205
|
-
tokens: { t1: { revokedAtMs: 1000 } },
|
|
206
|
-
},
|
|
207
|
-
],
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
211
|
-
authorization: "Bearer test-token",
|
|
212
|
-
});
|
|
213
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
214
|
-
await handleHealth(req, res);
|
|
215
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
216
|
-
expect(body.ok).toBe(false);
|
|
217
|
-
expect(body.devicePairing.status).toBe("degraded");
|
|
218
|
-
expect(body.devicePairing.tokensRevoked).toBe(true);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// --- Device pairing: pending ---
|
|
222
|
-
|
|
223
|
-
it("returns pending when device is pending and selfHeal is false", async () => {
|
|
224
|
-
mockListDevices.mockResolvedValueOnce({
|
|
225
|
-
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
226
|
-
paired: [],
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=false`, {
|
|
230
|
-
authorization: "Bearer test-token",
|
|
231
|
-
});
|
|
232
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
233
|
-
await handleHealth(req, res);
|
|
234
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
235
|
-
expect(body.devicePairing.status).toBe("pending");
|
|
236
|
-
expect(mockApproveDevice).not.toHaveBeenCalled();
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// --- Device pairing: not_found ---
|
|
240
|
-
|
|
241
|
-
it("returns not_found when device is not in paired or pending", async () => {
|
|
242
|
-
mockListDevices.mockResolvedValueOnce({ pending: [], paired: [] });
|
|
243
|
-
|
|
244
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
245
|
-
authorization: "Bearer test-token",
|
|
246
|
-
});
|
|
247
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
248
|
-
await handleHealth(req, res);
|
|
249
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
250
|
-
expect(body.ok).toBe(false);
|
|
251
|
-
expect(body.devicePairing.status).toBe("not_found");
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
// --- Device pairing: listDevicePairing throws ---
|
|
255
|
-
|
|
256
|
-
it("returns failed when listDevicePairing throws", async () => {
|
|
257
|
-
mockListDevices.mockRejectedValueOnce(new Error("ENOENT"));
|
|
258
|
-
|
|
259
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
|
|
260
|
-
authorization: "Bearer test-token",
|
|
261
|
-
});
|
|
262
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
263
|
-
await handleHealth(req, res);
|
|
264
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
265
|
-
expect(body.devicePairing.status).toBe("failed");
|
|
266
|
-
expect(body.devicePairing.detail).toContain("ENOENT");
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// --- Device pairing: approveDevicePairing throws during self-heal ---
|
|
270
|
-
|
|
271
|
-
it("returns degraded when auto-approve device throws", async () => {
|
|
272
|
-
mockListDevices.mockResolvedValueOnce({
|
|
273
|
-
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
274
|
-
paired: [],
|
|
275
|
-
});
|
|
276
|
-
mockApproveDevice.mockRejectedValueOnce(new Error("unknown requestId"));
|
|
277
|
-
|
|
278
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=true`, {
|
|
279
|
-
authorization: "Bearer test-token",
|
|
280
|
-
});
|
|
281
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
282
|
-
await handleHealth(req, res);
|
|
283
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
284
|
-
expect(body.devicePairing.status).toBe("degraded");
|
|
285
|
-
expect(body.repairActions).toHaveLength(1);
|
|
286
|
-
expect(body.repairActions[0].result).toBe("failed");
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// --- Device pairing: normalize case-insensitively ---
|
|
290
|
-
|
|
291
|
-
it("matches deviceId case-insensitively", async () => {
|
|
292
|
-
mockListDevices.mockResolvedValueOnce({
|
|
293
|
-
pending: [],
|
|
294
|
-
paired: [
|
|
295
|
-
{ deviceId: DEVICE_ID.toUpperCase(), approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
296
|
-
],
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID.toLowerCase()}`, {
|
|
300
|
-
authorization: "Bearer test-token",
|
|
301
|
-
});
|
|
302
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
303
|
-
await handleHealth(req, res);
|
|
304
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
305
|
-
expect(body.devicePairing.status).toBe("ok");
|
|
306
|
-
});
|
|
307
|
-
|
|
308
76
|
// --- Node pairing: paired + healthy ---
|
|
309
77
|
|
|
310
78
|
it("returns ok when node is paired with required caps and commands", async () => {
|
|
@@ -459,15 +227,9 @@ describe("handleHealth", () => {
|
|
|
459
227
|
expect(body.nodePairing.status).toBe("failed");
|
|
460
228
|
});
|
|
461
229
|
|
|
462
|
-
// --- Combined:
|
|
230
|
+
// --- Combined: deviceId + nodeDeviceId ---
|
|
463
231
|
|
|
464
|
-
it("
|
|
465
|
-
mockListDevices.mockResolvedValueOnce({
|
|
466
|
-
pending: [],
|
|
467
|
-
paired: [
|
|
468
|
-
{ deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
469
|
-
],
|
|
470
|
-
});
|
|
232
|
+
it("ignores deviceId and only checks node pairing", async () => {
|
|
471
233
|
mockListNodePairing.mockResolvedValueOnce({
|
|
472
234
|
pending: [],
|
|
473
235
|
paired: [
|
|
@@ -486,30 +248,7 @@ describe("handleHealth", () => {
|
|
|
486
248
|
await handleHealth(req, res);
|
|
487
249
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
488
250
|
expect(body.ok).toBe(true);
|
|
489
|
-
expect(body.devicePairing
|
|
251
|
+
expect(body.devicePairing).toBeUndefined();
|
|
490
252
|
expect(body.nodePairing.status).toBe("ok");
|
|
491
253
|
});
|
|
492
|
-
|
|
493
|
-
it("reports ok=false when device is ok but node is degraded", async () => {
|
|
494
|
-
mockListDevices.mockResolvedValueOnce({
|
|
495
|
-
pending: [],
|
|
496
|
-
paired: [
|
|
497
|
-
{ deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
|
|
498
|
-
],
|
|
499
|
-
});
|
|
500
|
-
mockListNodePairing.mockResolvedValueOnce({
|
|
501
|
-
pending: [],
|
|
502
|
-
paired: [{ nodeId: NODE_ID, caps: [], commands: [] }],
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`, {
|
|
506
|
-
authorization: "Bearer test-token",
|
|
507
|
-
});
|
|
508
|
-
const res = new MockRes() as unknown as ServerResponse;
|
|
509
|
-
await handleHealth(req, res);
|
|
510
|
-
const body = JSON.parse((res as unknown as MockRes).body);
|
|
511
|
-
expect(body.ok).toBe(false);
|
|
512
|
-
expect(body.devicePairing.status).toBe("ok");
|
|
513
|
-
expect(body.nodePairing.status).toBe("degraded");
|
|
514
|
-
});
|
|
515
254
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
|
|
3
2
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
3
|
import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
|
|
5
4
|
import { createFridayNextLogger } from "../../logging.js";
|
|
@@ -35,7 +34,6 @@ export interface HealthCheckResult {
|
|
|
35
34
|
timestamp: number;
|
|
36
35
|
deviceId: string;
|
|
37
36
|
nodeDeviceId: string;
|
|
38
|
-
devicePairing?: HealthComponentStatus;
|
|
39
37
|
nodePairing?: HealthComponentStatus;
|
|
40
38
|
repairActions?: RepairAction[];
|
|
41
39
|
}
|
|
@@ -70,112 +68,17 @@ export async function handleHealth(req: IncomingMessage, res: ServerResponse): P
|
|
|
70
68
|
|
|
71
69
|
const log = createFridayNextLogger("health");
|
|
72
70
|
|
|
73
|
-
if (deviceId) {
|
|
74
|
-
result.devicePairing = await checkDevicePairing(deviceId, selfHeal, result, log);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
71
|
if (nodeDeviceId) {
|
|
78
72
|
result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
|
|
79
73
|
}
|
|
80
74
|
|
|
81
|
-
|
|
82
|
-
result.devicePairing?.status,
|
|
83
|
-
result.nodePairing?.status,
|
|
84
|
-
].filter(Boolean);
|
|
85
|
-
result.ok = statuses.length === 0 || statuses.every((s) => s === "ok" || s === "pending");
|
|
75
|
+
result.ok = !result.nodePairing || (result.nodePairing.status === "ok" || result.nodePairing.status === "pending");
|
|
86
76
|
|
|
87
77
|
res.statusCode = 200;
|
|
88
78
|
res.setHeader("Content-Type", "application/json");
|
|
89
79
|
res.end(JSON.stringify(result));
|
|
90
80
|
return true;
|
|
91
81
|
}
|
|
92
|
-
|
|
93
|
-
async function checkDevicePairing(
|
|
94
|
-
deviceId: string,
|
|
95
|
-
selfHeal: boolean,
|
|
96
|
-
result: HealthCheckResult,
|
|
97
|
-
log: ReturnType<typeof createFridayNextLogger>,
|
|
98
|
-
): Promise<HealthComponentStatus> {
|
|
99
|
-
const normalizedDeviceId = deviceId.trim().toUpperCase();
|
|
100
|
-
|
|
101
|
-
let pairing;
|
|
102
|
-
try {
|
|
103
|
-
pairing = await listDevicePairing();
|
|
104
|
-
} catch (err) {
|
|
105
|
-
log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
-
return {
|
|
107
|
-
status: "failed",
|
|
108
|
-
detail: `listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
109
|
-
devicePaired: false,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const pairedDevice = (pairing?.paired ?? []).find(
|
|
114
|
-
(entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId,
|
|
115
|
-
);
|
|
116
|
-
if (pairedDevice) {
|
|
117
|
-
const approvedScopes: string[] = (pairedDevice as any).approvedScopes ?? [];
|
|
118
|
-
const tokens: Record<string, { revokedAtMs?: number }> = (pairedDevice as any).tokens ?? {};
|
|
119
|
-
const hasValidToken = Object.values(tokens).some((t: any) => !t.revokedAtMs);
|
|
120
|
-
|
|
121
|
-
if (approvedScopes.length === 0 || !hasValidToken) {
|
|
122
|
-
const issues: string[] = [];
|
|
123
|
-
if (approvedScopes.length === 0) issues.push("no approved scopes");
|
|
124
|
-
if (!hasValidToken) issues.push("all tokens revoked");
|
|
125
|
-
return {
|
|
126
|
-
status: "degraded",
|
|
127
|
-
detail: `Device paired but degraded: ${issues.join(", ")}`,
|
|
128
|
-
devicePaired: true,
|
|
129
|
-
approvedScopesEmpty: approvedScopes.length === 0,
|
|
130
|
-
tokensRevoked: !hasValidToken,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return { status: "ok", detail: "Device paired and healthy", devicePaired: true };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const pendingDevice = (pairing?.pending ?? []).find(
|
|
138
|
-
(entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId,
|
|
139
|
-
);
|
|
140
|
-
if (pendingDevice && selfHeal) {
|
|
141
|
-
try {
|
|
142
|
-
const approved = await approveDevicePairing(pendingDevice.requestId);
|
|
143
|
-
const succeeded = approved && approved.status === "approved";
|
|
144
|
-
(result.repairActions ??= []).push({
|
|
145
|
-
component: "devicePairing",
|
|
146
|
-
action: "approveDevicePairing",
|
|
147
|
-
result: succeeded ? "ok" : "failed",
|
|
148
|
-
detail: succeeded
|
|
149
|
-
? `Auto-approved device ${normalizedDeviceId}`
|
|
150
|
-
: `approveDevicePairing returned status=${(approved as any)?.status ?? "null"}`,
|
|
151
|
-
});
|
|
152
|
-
if (succeeded) {
|
|
153
|
-
log.info(`Auto-approved device ${normalizedDeviceId}`);
|
|
154
|
-
return { status: "ok", detail: "Device was pending, auto-approved", devicePaired: true };
|
|
155
|
-
}
|
|
156
|
-
} catch (err) {
|
|
157
|
-
log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
-
(result.repairActions ??= []).push({
|
|
159
|
-
component: "devicePairing",
|
|
160
|
-
action: "approveDevicePairing",
|
|
161
|
-
result: "failed",
|
|
162
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
return {
|
|
166
|
-
status: "degraded",
|
|
167
|
-
detail: "Device pending but auto-approve failed",
|
|
168
|
-
devicePaired: false,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (pendingDevice) {
|
|
173
|
-
return { status: "pending", detail: "Device is pending approval", devicePaired: false };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return { status: "not_found", detail: `Device ${normalizedDeviceId} not registered`, devicePaired: false };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
82
|
async function checkNodePairing(
|
|
180
83
|
nodeDeviceId: string,
|
|
181
84
|
selfHeal: boolean,
|