@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.
- package/dist/src/agent/node-pairing-bridge.js +6 -0
- package/dist/src/config.js +2 -1
- package/dist/src/http/handlers/agents-list.d.ts +13 -0
- package/dist/src/http/handlers/agents-list.js +97 -0
- package/dist/src/http/handlers/health.d.ts +0 -1
- package/dist/src/http/handlers/health.js +1 -82
- package/dist/src/http/server.js +4 -0
- package/install.js +5 -1
- package/package.json +1 -1
- package/src/agent/node-pairing-bridge.ts +6 -0
- package/src/config.ts +3 -1
- package/src/e2e/agents-list.e2e.test.ts +101 -0
- package/src/e2e/auto-approve.integration.test.ts +6 -66
- package/src/http/handlers/agents-list.test.ts +129 -0
- package/src/http/handlers/agents-list.ts +130 -0
- package/src/http/handlers/health.test.ts +3 -264
- package/src/http/handlers/health.ts +1 -98
- package/src/http/server.ts +5 -0
|
@@ -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),
|
|
@@ -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
|
+
}
|
|
@@ -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/dist/src/http/server.js
CHANGED
|
@@ -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 [
|
|
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
|
@@ -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,
|
|
@@ -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
|
|
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
|
});
|
|
@@ -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:
|
|
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,
|
package/src/http/server.ts
CHANGED
|
@@ -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
|
}
|