@syengup/friday-channel-next 0.1.8 → 0.1.11

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.
@@ -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 {
@@ -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, `${process.env.HOME ?? ""}/.openclaw/friday-next/history`),
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),
@@ -0,0 +1,13 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export interface FridayAgentEntry {
3
+ id: string;
4
+ name?: string;
5
+ description?: string;
6
+ /** Primary model ref (e.g. "openai/gpt-4"); resolved from string or {primary} forms. */
7
+ model?: string;
8
+ thinkingDefault?: string;
9
+ isDefault: boolean;
10
+ emoji?: string;
11
+ avatar?: string;
12
+ }
13
+ export declare function handleAgentsList(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
@@ -0,0 +1,97 @@
1
+ import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
2
+ import { extractBearerToken } from "../middleware/auth.js";
3
+ const DEFAULT_AGENT_ID = "main";
4
+ /** Agent ids already in path/shell-safe form skip the slug rewrite below. */
5
+ const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
6
+ /**
7
+ * Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
8
+ * lowercase, keep path/shell-safe. Empty → "main".
9
+ */
10
+ function normalizeAgentId(value) {
11
+ const trimmed = typeof value === "string" ? value.trim() : "";
12
+ if (!trimmed)
13
+ return DEFAULT_AGENT_ID;
14
+ const lowered = trimmed.toLowerCase();
15
+ if (SAFE_AGENT_ID.test(lowered))
16
+ return lowered;
17
+ return (lowered
18
+ .replace(/[^a-z0-9_-]+/g, "-")
19
+ .replace(/^-+|-+$/g, "")
20
+ .slice(0, 64) || DEFAULT_AGENT_ID);
21
+ }
22
+ /** Extract a primary model ref from the `model` field (string or {primary,...}). */
23
+ function resolvePrimaryModel(model) {
24
+ if (typeof model === "string")
25
+ return readString(model);
26
+ if (model && typeof model === "object") {
27
+ return readString(model.primary);
28
+ }
29
+ return undefined;
30
+ }
31
+ function readString(value) {
32
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
33
+ }
34
+ /**
35
+ * Reads the configured agents directly from the runtime config (same approach as
36
+ * models-list.ts). When no agents are configured OpenClaw runs an implicit "main"
37
+ * agent, so we return a single default entry to match that behaviour.
38
+ */
39
+ function resolveConfiguredAgents() {
40
+ const rt = getFridayAgentForwardRuntime();
41
+ if (!rt)
42
+ return { agents: [], defaultAgentId: DEFAULT_AGENT_ID };
43
+ const cfg = rt.getConfig();
44
+ const agents = cfg?.agents;
45
+ const list = agents?.list;
46
+ if (!Array.isArray(list) || list.length === 0) {
47
+ return {
48
+ agents: [{ id: DEFAULT_AGENT_ID, isDefault: true }],
49
+ defaultAgentId: DEFAULT_AGENT_ID,
50
+ };
51
+ }
52
+ // Default agent: first entry marked `default: true`, else the first entry.
53
+ const explicitDefault = list.find((a) => a?.default === true);
54
+ const defaultAgentId = normalizeAgentId((explicitDefault ?? list[0])?.id);
55
+ const seen = new Set();
56
+ const entries = [];
57
+ for (const agent of list) {
58
+ if (!agent || typeof agent !== "object")
59
+ continue;
60
+ const id = normalizeAgentId(agent.id);
61
+ if (seen.has(id))
62
+ continue;
63
+ seen.add(id);
64
+ const identity = agent.identity;
65
+ entries.push({
66
+ id,
67
+ name: readString(agent.name) ?? readString(identity?.name),
68
+ description: readString(agent.description),
69
+ model: resolvePrimaryModel(agent.model),
70
+ thinkingDefault: readString(agent.thinkingDefault),
71
+ isDefault: id === defaultAgentId,
72
+ emoji: readString(identity?.emoji),
73
+ avatar: readString(identity?.avatar) ?? readString(identity?.avatarUrl),
74
+ });
75
+ }
76
+ return { agents: entries, defaultAgentId };
77
+ }
78
+ export async function handleAgentsList(req, res) {
79
+ if (req.method !== "GET") {
80
+ res.statusCode = 405;
81
+ res.setHeader("Content-Type", "application/json");
82
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
83
+ return true;
84
+ }
85
+ const token = extractBearerToken(req);
86
+ if (!token) {
87
+ res.statusCode = 401;
88
+ res.setHeader("Content-Type", "application/json");
89
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
90
+ return true;
91
+ }
92
+ const { agents, defaultAgentId } = resolveConfiguredAgents();
93
+ res.statusCode = 200;
94
+ res.setHeader("Content-Type", "application/json");
95
+ res.end(JSON.stringify({ ok: true, agents, defaultAgentId }));
96
+ return true;
97
+ }
@@ -15,7 +15,6 @@ export interface HealthCheckResult {
15
15
  timestamp: number;
16
16
  deviceId: string;
17
17
  nodeDeviceId: string;
18
- devicePairing?: HealthComponentStatus;
19
18
  nodePairing?: HealthComponentStatus;
20
19
  repairActions?: RepairAction[];
21
20
  }
@@ -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
- const statuses = [
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;
@@ -13,6 +13,7 @@ import { handleDeviceApprove } from "./handlers/device-approve.js";
13
13
  import { handleNodesApprove } from "./handlers/nodes-approve.js";
14
14
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
15
15
  import { handleModelsList } from "./handlers/models-list.js";
16
+ import { handleAgentsList } from "./handlers/agents-list.js";
16
17
  import { handleStatus } from "./handlers/status.js";
17
18
  import { handleHealth } from "./handlers/health.js";
18
19
  import { applyCorsHeaders } from "./middleware/cors.js";
@@ -61,6 +62,9 @@ async function handleFridayNextRoute(req, res) {
61
62
  if (req.method === "GET" && pathname === "/friday-next/models") {
62
63
  return await handleModelsList(req, res);
63
64
  }
65
+ if (req.method === "GET" && pathname === "/friday-next/agents") {
66
+ return await handleAgentsList(req, res);
67
+ }
64
68
  if (req.method === "GET" && pathname === "/friday-next/status") {
65
69
  return await handleStatus(req, res);
66
70
  }
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 [`/home/${sudoUser}`, `/Users/${sudoUser}`]) {
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.8",
3
+ "version": "0.1.11",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- `${process.env.HOME ?? ""}/.openclaw/friday-next/history`,
57
+ `${homedir()}/.openclaw/friday-next/history`,
56
58
  ),
57
59
  logLevel: asString(section.logLevel, "info") as FridayNextLogLevel,
58
60
  authToken,
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { createAppSimulator } from "../test-support/app-simulator.js";
3
+ import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
4
+ import {
5
+ setFridayAgentForwardRuntime,
6
+ resetFridayAgentForwardRuntimeForTest,
7
+ } from "../agent-forward-runtime.js";
8
+
9
+ /**
10
+ * Inject a fake host config into the forward runtime, which is what
11
+ * agents-list reads via getFridayAgentForwardRuntime().getConfig().
12
+ * setMockRuntime only wires the auth/runtime config, not the forward runtime.
13
+ */
14
+ function setForwardConfig(config: unknown): void {
15
+ setFridayAgentForwardRuntime({
16
+ runtime: {
17
+ agent: { session: { resolveStorePath: () => "", loadSessionStore: () => ({}) } },
18
+ config: { current: () => config },
19
+ },
20
+ } as never);
21
+ }
22
+
23
+ async function getAgents(app: ReturnType<typeof createAppSimulator>, headers?: Record<string, string>) {
24
+ const res = await app.rawRequest({ method: "GET", path: "/friday-next/agents", headers });
25
+ return { status: res.status, body: res.body ? JSON.parse(res.body) : {}, headers: res.headers };
26
+ }
27
+
28
+ describe("e2e agents list", () => {
29
+ let historyDir = "";
30
+
31
+ beforeEach(() => {
32
+ historyDir = createTempHistoryDir();
33
+ });
34
+
35
+ afterEach(() => {
36
+ resetFridayAgentForwardRuntimeForTest();
37
+ removeTempHistoryDir(historyDir);
38
+ });
39
+
40
+ it("rejects a bad bearer token with 401", async () => {
41
+ setMockRuntime({ historyDir, authToken: "test-token" });
42
+ setForwardConfig({ agents: { list: [{ id: "main" }] } });
43
+ const app = createAppSimulator({ token: "test-token" });
44
+
45
+ const res = await getAgents(app, { authorization: "Bearer wrong-token" });
46
+ expect(res.status).toBe(401);
47
+ });
48
+
49
+ it("returns an implicit main agent when none are configured", async () => {
50
+ setMockRuntime({ historyDir, authToken: "test-token" });
51
+ setForwardConfig({ agents: { defaults: {} } });
52
+ const app = createAppSimulator({ token: "test-token" });
53
+
54
+ const res = await getAgents(app);
55
+ expect(res.status).toBe(200);
56
+ expect(res.body.ok).toBe(true);
57
+ expect(res.body.defaultAgentId).toBe("main");
58
+ expect(res.body.agents).toEqual([{ id: "main", isDefault: true }]);
59
+ });
60
+
61
+ it("lists configured agents end-to-end with normalized ids and default selection", async () => {
62
+ setMockRuntime({ historyDir, authToken: "test-token" });
63
+ setForwardConfig({
64
+ agents: {
65
+ list: [
66
+ { id: "Main", name: "Primary", model: "openai/gpt-4", thinkingDefault: "medium" },
67
+ {
68
+ id: "Research Bot",
69
+ description: "deep research",
70
+ model: { primary: "anthropic/claude", fallbacks: ["x"] },
71
+ identity: { emoji: "🔬", avatar: "data:img" },
72
+ default: true,
73
+ },
74
+ ],
75
+ },
76
+ });
77
+ const app = createAppSimulator({ token: "test-token" });
78
+
79
+ const res = await getAgents(app);
80
+ expect(res.status).toBe(200);
81
+ expect(res.headers["content-type"]).toContain("application/json");
82
+ expect(res.body.defaultAgentId).toBe("research-bot");
83
+ expect(res.body.agents).toEqual([
84
+ {
85
+ id: "main",
86
+ name: "Primary",
87
+ model: "openai/gpt-4",
88
+ thinkingDefault: "medium",
89
+ isDefault: false,
90
+ },
91
+ {
92
+ id: "research-bot",
93
+ description: "deep research",
94
+ model: "anthropic/claude",
95
+ isDefault: true,
96
+ emoji: "🔬",
97
+ avatar: "data:img",
98
+ },
99
+ ]);
100
+ });
101
+ });
@@ -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 同时自动批准 device node", async () => {
375
- // Device is pending
376
- mockListDevices.mockResolvedValueOnce({
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.status).toBe("ok");
350
+ expect(body.devicePairing).toBeUndefined();
410
351
  expect(body.nodePairing.status).toBe("ok");
411
- expect(body.repairActions).toHaveLength(2);
412
- expect(body.repairActions[0].component).toBe("devicePairing");
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
  });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { handleAgentsList } from "./agents-list.js";
4
+ import { setMockRuntime } from "../../test-support/mock-runtime.js";
5
+ import {
6
+ setFridayAgentForwardRuntime,
7
+ resetFridayAgentForwardRuntimeForTest,
8
+ } from "../../agent-forward-runtime.js";
9
+
10
+ class MockRes extends EventEmitter {
11
+ statusCode = 0;
12
+ headers: Record<string, string> = {};
13
+ body = "";
14
+ setHeader(name: string, value: string): void {
15
+ this.headers[name.toLowerCase()] = value;
16
+ }
17
+ end(body?: string): void {
18
+ if (body) this.body += body;
19
+ }
20
+ }
21
+
22
+ function makeReq(headers: Record<string, string> = {}, method = "GET"): any {
23
+ return { method, url: "/friday-next/agents", headers };
24
+ }
25
+
26
+ const AUTH = { authorization: "Bearer test-token" };
27
+
28
+ /** Inject a fake config into the forward runtime (handler reads getConfig()). */
29
+ function setConfig(config: unknown): void {
30
+ setFridayAgentForwardRuntime({
31
+ runtime: {
32
+ agent: { session: { resolveStorePath: () => "", loadSessionStore: () => ({}) } },
33
+ config: { current: () => config },
34
+ },
35
+ } as any);
36
+ }
37
+
38
+ describe("handleAgentsList", () => {
39
+ beforeEach(() => {
40
+ setMockRuntime();
41
+ });
42
+
43
+ afterEach(() => {
44
+ resetFridayAgentForwardRuntimeForTest();
45
+ });
46
+
47
+ it("rejects non-GET methods with 405", async () => {
48
+ const res = new MockRes();
49
+ await handleAgentsList(makeReq(AUTH, "POST"), res as any);
50
+ expect(res.statusCode).toBe(405);
51
+ });
52
+
53
+ it("rejects missing/invalid bearer token with 401", async () => {
54
+ const res = new MockRes();
55
+ await handleAgentsList(makeReq(), res as any);
56
+ expect(res.statusCode).toBe(401);
57
+ });
58
+
59
+ it("returns an implicit main agent when none are configured", async () => {
60
+ setConfig({ agents: { defaults: {} } });
61
+ const res = new MockRes();
62
+ await handleAgentsList(makeReq(AUTH), res as any);
63
+
64
+ expect(res.statusCode).toBe(200);
65
+ const body = JSON.parse(res.body);
66
+ expect(body.ok).toBe(true);
67
+ expect(body.defaultAgentId).toBe("main");
68
+ expect(body.agents).toEqual([{ id: "main", isDefault: true }]);
69
+ });
70
+
71
+ it("lists configured agents with normalized ids and resolved fields", async () => {
72
+ setConfig({
73
+ agents: {
74
+ list: [
75
+ { id: "Main", name: "Primary", model: "openai/gpt-4", thinkingDefault: "medium" },
76
+ {
77
+ id: "Research Bot",
78
+ description: "deep research",
79
+ model: { primary: "anthropic/claude", fallbacks: ["x"] },
80
+ identity: { emoji: "🔬", avatar: "data:..." },
81
+ default: true,
82
+ },
83
+ ],
84
+ },
85
+ });
86
+ const res = new MockRes();
87
+ await handleAgentsList(makeReq(AUTH), res as any);
88
+
89
+ expect(res.statusCode).toBe(200);
90
+ const body = JSON.parse(res.body);
91
+ expect(body.defaultAgentId).toBe("research-bot");
92
+ expect(body.agents).toEqual([
93
+ {
94
+ id: "main",
95
+ name: "Primary",
96
+ model: "openai/gpt-4",
97
+ thinkingDefault: "medium",
98
+ isDefault: false,
99
+ },
100
+ {
101
+ id: "research-bot",
102
+ description: "deep research",
103
+ model: "anthropic/claude",
104
+ isDefault: true,
105
+ emoji: "🔬",
106
+ avatar: "data:...",
107
+ },
108
+ ]);
109
+ });
110
+
111
+ it("defaults to the first entry when none is marked default and dedups ids", async () => {
112
+ setConfig({
113
+ agents: {
114
+ list: [
115
+ { id: "alpha" },
116
+ { id: "alpha", name: "dup" },
117
+ { id: "beta" },
118
+ ],
119
+ },
120
+ });
121
+ const res = new MockRes();
122
+ await handleAgentsList(makeReq(AUTH), res as any);
123
+
124
+ const body = JSON.parse(res.body);
125
+ expect(body.defaultAgentId).toBe("alpha");
126
+ expect(body.agents.map((a: { id: string }) => a.id)).toEqual(["alpha", "beta"]);
127
+ expect(body.agents[0].isDefault).toBe(true);
128
+ });
129
+ });
@@ -0,0 +1,130 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
3
+ import { extractBearerToken } from "../middleware/auth.js";
4
+
5
+ const DEFAULT_AGENT_ID = "main";
6
+
7
+ /** Agent ids already in path/shell-safe form skip the slug rewrite below. */
8
+ const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
9
+
10
+ export interface FridayAgentEntry {
11
+ id: string;
12
+ name?: string;
13
+ description?: string;
14
+ /** Primary model ref (e.g. "openai/gpt-4"); resolved from string or {primary} forms. */
15
+ model?: string;
16
+ thinkingDefault?: string;
17
+ isDefault: boolean;
18
+ emoji?: string;
19
+ avatar?: string;
20
+ }
21
+
22
+ interface ResolvedAgents {
23
+ agents: FridayAgentEntry[];
24
+ defaultAgentId: string;
25
+ }
26
+
27
+ /**
28
+ * Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
29
+ * lowercase, keep path/shell-safe. Empty → "main".
30
+ */
31
+ function normalizeAgentId(value: unknown): string {
32
+ const trimmed = typeof value === "string" ? value.trim() : "";
33
+ if (!trimmed) return DEFAULT_AGENT_ID;
34
+ const lowered = trimmed.toLowerCase();
35
+ if (SAFE_AGENT_ID.test(lowered)) return lowered;
36
+ return (
37
+ lowered
38
+ .replace(/[^a-z0-9_-]+/g, "-")
39
+ .replace(/^-+|-+$/g, "")
40
+ .slice(0, 64) || DEFAULT_AGENT_ID
41
+ );
42
+ }
43
+
44
+ /** Extract a primary model ref from the `model` field (string or {primary,...}). */
45
+ function resolvePrimaryModel(model: unknown): string | undefined {
46
+ if (typeof model === "string") return readString(model);
47
+ if (model && typeof model === "object") {
48
+ return readString((model as Record<string, unknown>).primary);
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ function readString(value: unknown): string | undefined {
54
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
55
+ }
56
+
57
+ /**
58
+ * Reads the configured agents directly from the runtime config (same approach as
59
+ * models-list.ts). When no agents are configured OpenClaw runs an implicit "main"
60
+ * agent, so we return a single default entry to match that behaviour.
61
+ */
62
+ function resolveConfiguredAgents(): ResolvedAgents {
63
+ const rt = getFridayAgentForwardRuntime();
64
+ if (!rt) return { agents: [], defaultAgentId: DEFAULT_AGENT_ID };
65
+
66
+ const cfg = rt.getConfig() as Record<string, unknown>;
67
+ const agents = cfg?.agents as Record<string, unknown> | undefined;
68
+ const list = agents?.list as Array<Record<string, unknown>> | undefined;
69
+
70
+ if (!Array.isArray(list) || list.length === 0) {
71
+ return {
72
+ agents: [{ id: DEFAULT_AGENT_ID, isDefault: true }],
73
+ defaultAgentId: DEFAULT_AGENT_ID,
74
+ };
75
+ }
76
+
77
+ // Default agent: first entry marked `default: true`, else the first entry.
78
+ const explicitDefault = list.find((a) => a?.default === true);
79
+ const defaultAgentId = normalizeAgentId((explicitDefault ?? list[0])?.id);
80
+
81
+ const seen = new Set<string>();
82
+ const entries: FridayAgentEntry[] = [];
83
+ for (const agent of list) {
84
+ if (!agent || typeof agent !== "object") continue;
85
+ const id = normalizeAgentId(agent.id);
86
+ if (seen.has(id)) continue;
87
+ seen.add(id);
88
+
89
+ const identity = agent.identity as Record<string, unknown> | undefined;
90
+ entries.push({
91
+ id,
92
+ name: readString(agent.name) ?? readString(identity?.name),
93
+ description: readString(agent.description),
94
+ model: resolvePrimaryModel(agent.model),
95
+ thinkingDefault: readString(agent.thinkingDefault),
96
+ isDefault: id === defaultAgentId,
97
+ emoji: readString(identity?.emoji),
98
+ avatar: readString(identity?.avatar) ?? readString(identity?.avatarUrl),
99
+ });
100
+ }
101
+
102
+ return { agents: entries, defaultAgentId };
103
+ }
104
+
105
+ export async function handleAgentsList(
106
+ req: IncomingMessage,
107
+ res: ServerResponse,
108
+ ): Promise<boolean> {
109
+ if (req.method !== "GET") {
110
+ res.statusCode = 405;
111
+ res.setHeader("Content-Type", "application/json");
112
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
113
+ return true;
114
+ }
115
+
116
+ const token = extractBearerToken(req);
117
+ if (!token) {
118
+ res.statusCode = 401;
119
+ res.setHeader("Content-Type", "application/json");
120
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
121
+ return true;
122
+ }
123
+
124
+ const { agents, defaultAgentId } = resolveConfiguredAgents();
125
+
126
+ res.statusCode = 200;
127
+ res.setHeader("Content-Type", "application/json");
128
+ res.end(JSON.stringify({ ok: true, agents, defaultAgentId }));
129
+ return true;
130
+ }
@@ -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: device + node ---
230
+ // --- Combined: deviceId + nodeDeviceId ---
463
231
 
464
- it("checks both device and node and reports overall ok", async () => {
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.status).toBe("ok");
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
- const statuses = [
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,
@@ -15,6 +15,7 @@ import { handleDeviceApprove } from "./handlers/device-approve.js";
15
15
  import { handleNodesApprove } from "./handlers/nodes-approve.js";
16
16
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
17
17
  import { handleModelsList } from "./handlers/models-list.js";
18
+ import { handleAgentsList } from "./handlers/agents-list.js";
18
19
  import { handleStatus } from "./handlers/status.js";
19
20
  import { handleHealth } from "./handlers/health.js";
20
21
  import { applyCorsHeaders } from "./middleware/cors.js";
@@ -77,6 +78,10 @@ async function handleFridayNextRoute(
77
78
  return await handleModelsList(req, res);
78
79
  }
79
80
 
81
+ if (req.method === "GET" && pathname === "/friday-next/agents") {
82
+ return await handleAgentsList(req, res);
83
+ }
84
+
80
85
  if (req.method === "GET" && pathname === "/friday-next/status") {
81
86
  return await handleStatus(req, res);
82
87
  }