@syengup/friday-channel-next 0.0.35 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -0
- package/dist/index.js +182 -0
- package/dist/src/agent/abort-run.d.ts +1 -0
- package/dist/src/agent/abort-run.js +11 -0
- package/dist/src/agent/active-runs.d.ts +9 -0
- package/dist/src/agent/active-runs.js +20 -0
- package/dist/src/agent/dispatch-bridge.d.ts +5 -0
- package/dist/src/agent/dispatch-bridge.js +12 -0
- package/dist/src/agent/media-bridge.d.ts +4 -0
- package/dist/src/agent/media-bridge.js +21 -0
- package/dist/src/agent/subagent-registry.d.ts +68 -0
- package/dist/src/agent/subagent-registry.js +142 -0
- package/dist/src/agent-forward-runtime.d.ts +17 -0
- package/dist/src/agent-forward-runtime.js +16 -0
- package/dist/src/agent-run-context-bridge.d.ts +13 -0
- package/dist/src/agent-run-context-bridge.js +23 -0
- package/dist/src/channel-actions.d.ts +13 -0
- package/dist/src/channel-actions.js +101 -0
- package/dist/src/channel.d.ts +6 -0
- package/dist/src/channel.js +248 -0
- package/dist/src/collect-message-media-paths.d.ts +11 -0
- package/dist/src/collect-message-media-paths.js +143 -0
- package/dist/src/config.d.ts +15 -0
- package/dist/src/config.js +39 -0
- package/dist/src/friday-inbound-stats.d.ts +2 -0
- package/dist/src/friday-inbound-stats.js +8 -0
- package/dist/src/friday-session.d.ts +40 -0
- package/dist/src/friday-session.js +395 -0
- package/dist/src/host-config.d.ts +1 -0
- package/dist/src/host-config.js +15 -0
- package/dist/src/http/handlers/cancel.d.ts +2 -0
- package/dist/src/http/handlers/cancel.js +33 -0
- package/dist/src/http/handlers/device-approve.d.ts +2 -0
- package/dist/src/http/handlers/device-approve.js +125 -0
- package/dist/src/http/handlers/files-download.d.ts +10 -0
- package/dist/src/http/handlers/files-download.js +210 -0
- package/dist/src/http/handlers/files-upload.d.ts +8 -0
- package/dist/src/http/handlers/files-upload.js +136 -0
- package/dist/src/http/handlers/files.d.ts +75 -0
- package/dist/src/http/handlers/files.js +305 -0
- package/dist/src/http/handlers/messages.d.ts +34 -0
- package/dist/src/http/handlers/messages.js +476 -0
- package/dist/src/http/handlers/models-list.d.ts +10 -0
- package/dist/src/http/handlers/models-list.js +113 -0
- package/dist/src/http/handlers/nodes-approve.d.ts +2 -0
- package/dist/src/http/handlers/nodes-approve.js +146 -0
- package/dist/src/http/handlers/sessions-delete.d.ts +2 -0
- package/dist/src/http/handlers/sessions-delete.js +49 -0
- package/dist/src/http/handlers/sessions-settings.d.ts +2 -0
- package/dist/src/http/handlers/sessions-settings.js +71 -0
- package/dist/src/http/handlers/sse.d.ts +2 -0
- package/dist/src/http/handlers/sse.js +70 -0
- package/dist/src/http/handlers/status.d.ts +2 -0
- package/dist/src/http/handlers/status.js +29 -0
- package/dist/src/http/middleware/auth.d.ts +13 -0
- package/dist/src/http/middleware/auth.js +29 -0
- package/dist/src/http/middleware/body.d.ts +2 -0
- package/dist/src/http/middleware/body.js +24 -0
- package/dist/src/http/middleware/cors.d.ts +2 -0
- package/dist/src/http/middleware/cors.js +11 -0
- package/dist/src/http/server.d.ts +19 -0
- package/dist/src/http/server.js +87 -0
- package/dist/src/logging.d.ts +7 -0
- package/dist/src/logging.js +28 -0
- package/dist/src/run-metadata.d.ts +25 -0
- package/dist/src/run-metadata.js +139 -0
- package/dist/src/runtime.d.ts +13 -0
- package/dist/src/runtime.js +5 -0
- package/dist/src/session/session-manager.d.ts +22 -0
- package/dist/src/session/session-manager.js +190 -0
- package/dist/src/session-usage-snapshot.d.ts +23 -0
- package/dist/src/session-usage-snapshot.js +65 -0
- package/dist/src/sse/emitter.d.ts +59 -0
- package/dist/src/sse/emitter.js +219 -0
- package/dist/src/sse/offline-queue.d.ts +26 -0
- package/dist/src/sse/offline-queue.js +134 -0
- package/dist/src/vendor/runtime-store.d.ts +26 -0
- package/dist/src/vendor/runtime-store.js +60 -0
- package/index.ts +10 -4
- package/package.json +11 -10
- package/src/agent/subagent-registry.ts +195 -0
- package/src/channel.ts +6 -4
- package/src/e2e/subagent-smoke.e2e.test.ts +223 -0
- package/src/e2e/subagent.e2e.test.ts +502 -0
- package/src/friday-session.ts +140 -1
- package/src/http/handlers/device-approve.test.ts +0 -1
- package/src/http/handlers/device-approve.ts +0 -2
- package/src/http/handlers/files-download.ts +4 -1
- package/src/http/handlers/files.ts +7 -4
- package/src/http/handlers/messages.ts +54 -4
- package/src/http/handlers/models-list.ts +24 -2
- package/src/http/handlers/nodes-approve.test.ts +288 -0
- package/src/http/handlers/nodes-approve.ts +189 -0
- package/src/http/server.ts +5 -0
- package/src/openclaw.d.ts +5 -0
- package/src/sse/emitter.ts +1 -1
- package/src/test-support/mock-runtime.ts +2 -0
|
@@ -4,8 +4,6 @@ import { readJsonBody } from "../middleware/body.js";
|
|
|
4
4
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
5
|
import { createFridayNextLogger } from "../../logging.js";
|
|
6
6
|
|
|
7
|
-
// macOS LaunchAgent strips Homebrew from PATH; prepend common prefixes so
|
|
8
|
-
// the child process can find openclaw. Windows doesn't need this.
|
|
9
7
|
const EXEC_ENV = process.platform === "win32"
|
|
10
8
|
? process.env
|
|
11
9
|
: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
11
12
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
12
13
|
import {
|
|
13
14
|
getExternalFileSourceByUrlToken,
|
|
@@ -20,6 +21,8 @@ import path from "node:path";
|
|
|
20
21
|
import fs from "node:fs";
|
|
21
22
|
import os from "node:os";
|
|
22
23
|
|
|
24
|
+
const logger = createFridayNextLogger("files-download");
|
|
25
|
+
|
|
23
26
|
const MIME_FROM_EXT: Record<string, string> = {
|
|
24
27
|
png: "image/png",
|
|
25
28
|
jpg: "image/jpeg",
|
|
@@ -232,7 +235,7 @@ export async function handleFilesDownload(
|
|
|
232
235
|
sendError(res, 404, "File not found");
|
|
233
236
|
return true;
|
|
234
237
|
} catch (err) {
|
|
235
|
-
|
|
238
|
+
logger.error(`GET download failed: ${String(err)}`);
|
|
236
239
|
sendError(res, 500, "Internal Server Error");
|
|
237
240
|
return true;
|
|
238
241
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import os from "node:os";
|
|
11
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
|
|
@@ -42,6 +43,8 @@ const fileIndex = new Map<string, StoredFile>();
|
|
|
42
43
|
const fileTokenIndex = new Map<string, StoredFile>();
|
|
43
44
|
const externalFileSourceIndex = new Map<string, string>();
|
|
44
45
|
|
|
46
|
+
const logger = createFridayNextLogger("files");
|
|
47
|
+
|
|
45
48
|
function registerStoredFile(file: StoredFile): void {
|
|
46
49
|
fileIndex.set(file.id, file);
|
|
47
50
|
fileIndex.set(file.urlToken, file);
|
|
@@ -115,7 +118,7 @@ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
|
|
|
115
118
|
// Fallback to read+write so attachment persistence still works.
|
|
116
119
|
const raw = fs.readFileSync(resolvedPath);
|
|
117
120
|
fs.writeFileSync(storedPath, raw);
|
|
118
|
-
|
|
121
|
+
logger.warn(`copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`);
|
|
119
122
|
}
|
|
120
123
|
const stat = fs.statSync(storedPath);
|
|
121
124
|
const mimeType = guessMimeType(filename);
|
|
@@ -131,7 +134,7 @@ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
|
|
|
131
134
|
registerStoredFile(file);
|
|
132
135
|
return file;
|
|
133
136
|
} catch (err) {
|
|
134
|
-
|
|
137
|
+
logger.error(`copyLocalFileToAttachments failed for "${resolvedPath}": ${String(err)}`);
|
|
135
138
|
return null;
|
|
136
139
|
}
|
|
137
140
|
}
|
|
@@ -238,10 +241,10 @@ export function resolveMediaUrl(localPath: string): string {
|
|
|
238
241
|
|
|
239
242
|
const stored = copyLocalFileToAttachments(localPath);
|
|
240
243
|
if (!stored) {
|
|
241
|
-
|
|
244
|
+
logger.error(`resolveMediaUrl: file not found or unreadable: ${localPath}`);
|
|
242
245
|
return localPath;
|
|
243
246
|
}
|
|
244
|
-
|
|
247
|
+
logger.info(`resolveMediaUrl: copied "${stored.filename}" → ${stored.urlToken}`);
|
|
245
248
|
return `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
|
|
246
249
|
}
|
|
247
250
|
|
|
@@ -27,7 +27,8 @@ export type FridayReplyPayload = {
|
|
|
27
27
|
import { resolveFridayNextConfig } from "../../config.js";
|
|
28
28
|
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
29
29
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
30
|
-
import {
|
|
30
|
+
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
31
|
+
import { setSessionSettings, splitModelRef, toSessionStoreKey } from "../../session/session-manager.js";
|
|
31
32
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
32
33
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
33
34
|
import { readJsonBody } from "../middleware/body.js";
|
|
@@ -51,12 +52,14 @@ import {
|
|
|
51
52
|
registerRunRoute,
|
|
52
53
|
setRunMetadata,
|
|
53
54
|
} from "../../run-metadata.js";
|
|
55
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
56
|
+
|
|
57
|
+
const logger = createFridayNextLogger("messages");
|
|
54
58
|
|
|
55
59
|
const log = (action: string, deviceId: string, runId?: string, detail?: string) => {
|
|
56
|
-
const ts = new Date().toISOString();
|
|
57
60
|
const runPart = runId ? ` runId=${runId}` : "";
|
|
58
61
|
const detailPart = detail ? ` detail=${detail}` : "";
|
|
59
|
-
|
|
62
|
+
logger.info(`[${action}] deviceId=${deviceId}${runPart}${detailPart}`);
|
|
60
63
|
};
|
|
61
64
|
|
|
62
65
|
function collectReplyPayloadMediaUrls(pl: { mediaUrls?: string[]; mediaUrl?: string | null }): string[] {
|
|
@@ -317,6 +320,9 @@ export interface FridayMessagePayload {
|
|
|
317
320
|
text: string;
|
|
318
321
|
sessionKey: string;
|
|
319
322
|
attachments?: string[];
|
|
323
|
+
modelRef?: string;
|
|
324
|
+
reasoningLevel?: string;
|
|
325
|
+
thinkingLevel?: string;
|
|
320
326
|
}
|
|
321
327
|
|
|
322
328
|
async function buildBodyForAgentWithAttachments(text: string, attachmentIds: string[]): Promise<string> {
|
|
@@ -413,7 +419,51 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
413
419
|
);
|
|
414
420
|
|
|
415
421
|
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
|
|
416
|
-
|
|
422
|
+
|
|
423
|
+
// Resolve defaults from the OpenClaw agent config so settings are never left empty.
|
|
424
|
+
let defaultModel: string | undefined;
|
|
425
|
+
let defaultThinking: string | undefined;
|
|
426
|
+
try {
|
|
427
|
+
const forwardRt = getFridayAgentForwardRuntime();
|
|
428
|
+
if (forwardRt) {
|
|
429
|
+
const ocCfg = (forwardRt.getConfig() ?? {}) as Record<string, unknown>;
|
|
430
|
+
const agents = ocCfg.agents as Record<string, unknown> | undefined;
|
|
431
|
+
const agentDefaults = agents?.defaults as Record<string, unknown> | undefined;
|
|
432
|
+
const model = agentDefaults?.model as Record<string, unknown> | undefined;
|
|
433
|
+
defaultModel = typeof model?.primary === "string" ? (model.primary as string) : undefined;
|
|
434
|
+
defaultThinking =
|
|
435
|
+
typeof agentDefaults?.thinkingDefault === "string"
|
|
436
|
+
? (agentDefaults.thinkingDefault as string)
|
|
437
|
+
: undefined;
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
// Config not available (tests) — leave defaults undefined.
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const modelRef = payload.modelRef ?? defaultModel;
|
|
444
|
+
const reasoningLevel = payload.reasoningLevel ?? "stream";
|
|
445
|
+
const thinkingLevel = payload.thinkingLevel ?? defaultThinking;
|
|
446
|
+
|
|
447
|
+
const settings: Record<string, string | undefined> = {};
|
|
448
|
+
if (modelRef) {
|
|
449
|
+
settings.modelRef = modelRef;
|
|
450
|
+
const split = splitModelRef(modelRef);
|
|
451
|
+
settings.providerOverride = split.provider;
|
|
452
|
+
settings.modelOverride = split.modelId;
|
|
453
|
+
}
|
|
454
|
+
if (reasoningLevel) settings.reasoningLevel = reasoningLevel;
|
|
455
|
+
if (thinkingLevel) settings.thinkingLevel = thinkingLevel;
|
|
456
|
+
|
|
457
|
+
if (Object.keys(settings).length > 0) {
|
|
458
|
+
setSessionSettings(baseSessionKey, settings, cfg.historyDir);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
log(
|
|
462
|
+
"SESSION_SETTINGS",
|
|
463
|
+
normalizedDeviceId,
|
|
464
|
+
runId,
|
|
465
|
+
`sessionKey=${baseSessionKey} modelRef=${modelRef ?? "(default)"} reasoning=${reasoningLevel ?? "(default)"} thinking=${thinkingLevel ?? "(default)"}`,
|
|
466
|
+
);
|
|
417
467
|
|
|
418
468
|
registerFridaySessionDeviceMapping(appSessionKey, normalizedDeviceId);
|
|
419
469
|
sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
|
|
@@ -64,8 +64,30 @@ function resolveConfiguredModels(): ResolvedModels {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const agentModel = agentDefaults?.model
|
|
68
|
-
|
|
67
|
+
const agentModel = agentDefaults?.model;
|
|
68
|
+
let defaultModel =
|
|
69
|
+
typeof agentModel === "string" && agentModel.trim()
|
|
70
|
+
? agentModel.trim()
|
|
71
|
+
: typeof (agentModel as Record<string, unknown> | undefined)?.primary === "string"
|
|
72
|
+
? ((agentModel as Record<string, unknown>).primary as string)
|
|
73
|
+
: "";
|
|
74
|
+
|
|
75
|
+
if (!defaultModel && entries.length > 0) {
|
|
76
|
+
defaultModel = entries[0].id;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (defaultModel && !seen.has(defaultModel)) {
|
|
80
|
+
const split = splitModelRef(defaultModel);
|
|
81
|
+
const meta = providerMeta.get(defaultModel);
|
|
82
|
+
entries.unshift({
|
|
83
|
+
id: defaultModel,
|
|
84
|
+
name: meta?.name ?? split.modelId,
|
|
85
|
+
provider: split.provider ?? "",
|
|
86
|
+
reasoning: meta?.reasoning,
|
|
87
|
+
contextWindow: meta?.contextWindow,
|
|
88
|
+
maxTokens: meta?.maxTokens,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
69
91
|
|
|
70
92
|
return { models: entries, defaultModel };
|
|
71
93
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
+
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
6
|
+
|
|
7
|
+
const mockExecImpl = vi.hoisted(() => vi.fn());
|
|
8
|
+
vi.mock("node:child_process", () => ({
|
|
9
|
+
exec: mockExecImpl,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { handleNodesApprove } from "./nodes-approve.js";
|
|
13
|
+
|
|
14
|
+
class MockRes extends EventEmitter {
|
|
15
|
+
statusCode = 0;
|
|
16
|
+
headers: Record<string, string> = {};
|
|
17
|
+
body = "";
|
|
18
|
+
setHeader(name: string, value: string): void {
|
|
19
|
+
this.headers[name.toLowerCase()] = value;
|
|
20
|
+
}
|
|
21
|
+
end(body?: string): void {
|
|
22
|
+
if (body) this.body += body;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mockReq(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
|
|
27
|
+
const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
|
|
28
|
+
stream.method = method;
|
|
29
|
+
stream.headers = headers;
|
|
30
|
+
return stream;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mockExecSuccess(stdout: string) {
|
|
34
|
+
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
35
|
+
child.stdout = new EventEmitter();
|
|
36
|
+
child.stderr = new EventEmitter();
|
|
37
|
+
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: null, stdout: string, stderr: string) => void) => {
|
|
38
|
+
cb(null, stdout, "");
|
|
39
|
+
return child;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockExecError(message: string, stderr?: string) {
|
|
44
|
+
const err = new Error(message) as Error & { stderr: string };
|
|
45
|
+
err.stderr = stderr ?? "";
|
|
46
|
+
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
47
|
+
child.stdout = new EventEmitter();
|
|
48
|
+
child.stderr = new EventEmitter();
|
|
49
|
+
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
|
|
50
|
+
cb(err);
|
|
51
|
+
return child;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mockExecErrorWithStderr(err: Error & { stderr?: string }) {
|
|
56
|
+
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
57
|
+
child.stdout = new EventEmitter();
|
|
58
|
+
child.stderr = new EventEmitter();
|
|
59
|
+
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
|
|
60
|
+
cb(err);
|
|
61
|
+
return child;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const NODE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
|
|
66
|
+
const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
|
|
67
|
+
|
|
68
|
+
describe("handleNodesApprove", () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
setMockRuntime();
|
|
71
|
+
mockExecImpl.mockReset();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns 405 on non-POST", async () => {
|
|
75
|
+
const req = { method: "GET", headers: {} } as IncomingMessage;
|
|
76
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
77
|
+
await handleNodesApprove(req, res);
|
|
78
|
+
expect((res as unknown as MockRes).statusCode).toBe(405);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns 401 for missing auth", async () => {
|
|
82
|
+
const req = mockReq("POST");
|
|
83
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
84
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
85
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
86
|
+
await p;
|
|
87
|
+
expect((res as unknown as MockRes).statusCode).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns 400 for missing body", async () => {
|
|
91
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
92
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
93
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
94
|
+
req.end("");
|
|
95
|
+
await p;
|
|
96
|
+
expect((res as unknown as MockRes).statusCode).toBe(400);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns 400 for missing nodeId", async () => {
|
|
100
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
101
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
102
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
103
|
+
req.end(JSON.stringify({}));
|
|
104
|
+
await p;
|
|
105
|
+
expect((res as unknown as MockRes).statusCode).toBe(400);
|
|
106
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("nodeId");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns 502 when nodes list CLI fails", async () => {
|
|
110
|
+
mockExecError("ENOENT");
|
|
111
|
+
|
|
112
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
113
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
114
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
115
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
116
|
+
await p;
|
|
117
|
+
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
118
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Failed to list nodes");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns 502 when nodes list returns invalid JSON", async () => {
|
|
122
|
+
mockExecSuccess("not valid json {{{");
|
|
123
|
+
|
|
124
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
125
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
126
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
127
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
128
|
+
await p;
|
|
129
|
+
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
130
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns 404 when nodeId not in pending or paired with caps", async () => {
|
|
134
|
+
mockExecSuccess(JSON.stringify({
|
|
135
|
+
pending: [{ requestId: "uuid-1", nodeId: "OTHER_NODE" }],
|
|
136
|
+
paired: [{ nodeId: "ANOTHER", caps: ["canvas"], commands: ["canvas.navigate"] }],
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
140
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
141
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
142
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
143
|
+
await p;
|
|
144
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
145
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
146
|
+
expect(body.error).toContain("No pending node found");
|
|
147
|
+
expect(body.nodeId).toBe(NODE_ID.toUpperCase());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns 404 when pending is empty and paired has empty caps/commands", async () => {
|
|
151
|
+
mockExecSuccess(JSON.stringify({
|
|
152
|
+
pending: [],
|
|
153
|
+
paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: [], commands: [] }],
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
157
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
158
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
159
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
160
|
+
await p;
|
|
161
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns 200 with alreadyApproved when node in paired with caps", async () => {
|
|
165
|
+
mockExecSuccess(JSON.stringify({
|
|
166
|
+
pending: [],
|
|
167
|
+
paired: [{ nodeId: NODE_ID, approvedAtMs: 1778571972361, caps: ["location", "canvas"], commands: ["canvas.navigate"] }],
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
171
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
172
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
173
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
174
|
+
await p;
|
|
175
|
+
|
|
176
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
177
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
178
|
+
expect(body.ok).toBe(true);
|
|
179
|
+
expect(body.alreadyApproved).toBe(true);
|
|
180
|
+
expect(body.nodeId).toBe(NODE_ID.toUpperCase());
|
|
181
|
+
expect(body.approvedAtMs).toBe(1778571972361);
|
|
182
|
+
expect(body.caps).toEqual(["location", "canvas"]);
|
|
183
|
+
expect(body.commands).toEqual(["canvas.navigate"]);
|
|
184
|
+
expect(mockExecImpl).toHaveBeenCalledTimes(1); // no approve call
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("returns 502 when approve command fails", async () => {
|
|
188
|
+
mockExecSuccess(JSON.stringify({
|
|
189
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
190
|
+
paired: [],
|
|
191
|
+
}));
|
|
192
|
+
const approveErr = new Error("Command failed") as Error & { stderr: string };
|
|
193
|
+
approveErr.stderr = "unknown requestId";
|
|
194
|
+
mockExecErrorWithStderr(approveErr);
|
|
195
|
+
|
|
196
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
197
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
198
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
199
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
200
|
+
await p;
|
|
201
|
+
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
202
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
203
|
+
expect(body.error).toContain("Node approval command failed");
|
|
204
|
+
expect(body.detail).toBe("unknown requestId");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("returns 502 when approve returns non-JSON", async () => {
|
|
208
|
+
mockExecSuccess(JSON.stringify({
|
|
209
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
210
|
+
paired: [],
|
|
211
|
+
}));
|
|
212
|
+
mockExecSuccess("No pending node pairing requests to approve");
|
|
213
|
+
|
|
214
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
215
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
216
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
217
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
218
|
+
await p;
|
|
219
|
+
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
220
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response from node approval");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("succeeds with complete flow", async () => {
|
|
224
|
+
mockExecSuccess(JSON.stringify({
|
|
225
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
226
|
+
paired: [],
|
|
227
|
+
}));
|
|
228
|
+
mockExecSuccess(JSON.stringify({
|
|
229
|
+
requestId: REQUEST_ID,
|
|
230
|
+
node: { nodeId: NODE_ID, approvedAtMs: 1778571972361 },
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
234
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
235
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
236
|
+
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
237
|
+
await p;
|
|
238
|
+
|
|
239
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
240
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
241
|
+
expect(body.ok).toBe(true);
|
|
242
|
+
expect(body.alreadyApproved).toBeUndefined();
|
|
243
|
+
expect(body.nodeId).toBe(NODE_ID.toUpperCase());
|
|
244
|
+
expect(body.requestId).toBe(REQUEST_ID);
|
|
245
|
+
expect(body.approvedAtMs).toBe(1778571972361);
|
|
246
|
+
expect(mockExecImpl).toHaveBeenCalledTimes(2);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("normalizes nodeId case-insensitively in pending", async () => {
|
|
250
|
+
mockExecSuccess(JSON.stringify({
|
|
251
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
252
|
+
paired: [],
|
|
253
|
+
}));
|
|
254
|
+
mockExecSuccess(JSON.stringify({
|
|
255
|
+
requestId: REQUEST_ID,
|
|
256
|
+
node: { nodeId: NODE_ID, approvedAtMs: 1 },
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
260
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
261
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
262
|
+
req.end(JSON.stringify({ nodeId: NODE_ID.toLowerCase() }));
|
|
263
|
+
await p;
|
|
264
|
+
|
|
265
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
266
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
267
|
+
expect(body.ok).toBe(true);
|
|
268
|
+
expect(body.nodeId).toBe(NODE_ID.toUpperCase());
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("normalizes nodeId case-insensitively in paired", async () => {
|
|
272
|
+
mockExecSuccess(JSON.stringify({
|
|
273
|
+
pending: [],
|
|
274
|
+
paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: ["canvas"], commands: [] }],
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
278
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
279
|
+
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
280
|
+
req.end(JSON.stringify({ nodeId: NODE_ID.toLowerCase() }));
|
|
281
|
+
await p;
|
|
282
|
+
|
|
283
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
284
|
+
const body = JSON.parse((res as unknown as MockRes).body);
|
|
285
|
+
expect(body.ok).toBe(true);
|
|
286
|
+
expect(body.alreadyApproved).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
4
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
6
|
+
|
|
7
|
+
const EXEC_ENV = process.platform === "win32"
|
|
8
|
+
? process.env
|
|
9
|
+
: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
|
|
10
|
+
|
|
11
|
+
interface PendingNode {
|
|
12
|
+
requestId: string;
|
|
13
|
+
nodeId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PairedNode {
|
|
17
|
+
nodeId: string;
|
|
18
|
+
approvedAtMs?: number;
|
|
19
|
+
caps?: string[];
|
|
20
|
+
commands?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface NodeListJson {
|
|
24
|
+
pending?: PendingNode[];
|
|
25
|
+
paired?: PairedNode[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ApproveJson {
|
|
29
|
+
requestId: string;
|
|
30
|
+
node?: { nodeId: string; approvedAtMs?: number };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function execAsync(command: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
|
|
36
|
+
if (error) {
|
|
37
|
+
reject(error);
|
|
38
|
+
} else {
|
|
39
|
+
resolve({ stdout, stderr });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
child.stdout?.on("data", () => { /* drain */ });
|
|
43
|
+
child.stderr?.on("data", () => { /* drain */ });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function handleNodesApprove(
|
|
48
|
+
req: IncomingMessage,
|
|
49
|
+
res: ServerResponse,
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
const log = createFridayNextLogger("nodes-approve");
|
|
52
|
+
|
|
53
|
+
if (req.method !== "POST") {
|
|
54
|
+
res.statusCode = 405;
|
|
55
|
+
res.setHeader("Content-Type", "application/json");
|
|
56
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const token = extractBearerToken(req);
|
|
61
|
+
if (!token) {
|
|
62
|
+
res.statusCode = 401;
|
|
63
|
+
res.setHeader("Content-Type", "application/json");
|
|
64
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const body = await readJsonBody(req);
|
|
69
|
+
if (!body) {
|
|
70
|
+
res.statusCode = 400;
|
|
71
|
+
res.setHeader("Content-Type", "application/json");
|
|
72
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rawNodeId = typeof body.nodeId === "string" ? body.nodeId : "";
|
|
77
|
+
if (!rawNodeId.trim()) {
|
|
78
|
+
res.statusCode = 400;
|
|
79
|
+
res.setHeader("Content-Type", "application/json");
|
|
80
|
+
res.end(JSON.stringify({ error: "Missing required field: nodeId" }));
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const normalizedNodeId = rawNodeId.trim().toUpperCase();
|
|
85
|
+
|
|
86
|
+
let listStdout: string;
|
|
87
|
+
try {
|
|
88
|
+
const result = await execAsync("openclaw nodes list --json", 15000);
|
|
89
|
+
listStdout = result.stdout;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const stderr = (err as { stderr?: string })?.stderr?.trim();
|
|
92
|
+
log.error(`nodes list failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
93
|
+
res.statusCode = 502;
|
|
94
|
+
res.setHeader("Content-Type", "application/json");
|
|
95
|
+
res.end(JSON.stringify({ error: "Failed to list nodes from gateway", detail: stderr || undefined }));
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let listData: NodeListJson;
|
|
100
|
+
try {
|
|
101
|
+
listData = JSON.parse(listStdout) as NodeListJson;
|
|
102
|
+
} catch {
|
|
103
|
+
log.error(`nodes list returned invalid JSON: ${listStdout.slice(0, 200)}`);
|
|
104
|
+
res.statusCode = 502;
|
|
105
|
+
res.setHeader("Content-Type", "application/json");
|
|
106
|
+
res.end(JSON.stringify({ error: "Unexpected response from gateway node list" }));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pending = listData.pending ?? [];
|
|
111
|
+
const pendingMatch = pending.find(
|
|
112
|
+
(entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (pendingMatch) {
|
|
116
|
+
const requestId = pendingMatch.requestId;
|
|
117
|
+
log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
|
|
118
|
+
|
|
119
|
+
let approveStdout: string;
|
|
120
|
+
try {
|
|
121
|
+
const result = await execAsync(`openclaw nodes approve ${requestId} --json`, 15000);
|
|
122
|
+
approveStdout = result.stdout;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const stderr = (err as { stderr?: string })?.stderr?.trim();
|
|
125
|
+
log.error(`nodes approve failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
+
res.statusCode = 502;
|
|
127
|
+
res.setHeader("Content-Type", "application/json");
|
|
128
|
+
res.end(JSON.stringify({
|
|
129
|
+
error: "Node approval command failed",
|
|
130
|
+
detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
|
|
131
|
+
}));
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let approveData: ApproveJson;
|
|
136
|
+
try {
|
|
137
|
+
approveData = JSON.parse(approveStdout) as ApproveJson;
|
|
138
|
+
} catch {
|
|
139
|
+
log.error(`nodes approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
|
|
140
|
+
res.statusCode = 502;
|
|
141
|
+
res.setHeader("Content-Type", "application/json");
|
|
142
|
+
res.end(JSON.stringify({ error: "Unexpected response from node approval" }));
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
res.statusCode = 200;
|
|
147
|
+
res.setHeader("Content-Type", "application/json");
|
|
148
|
+
res.end(JSON.stringify({
|
|
149
|
+
ok: true,
|
|
150
|
+
nodeId: normalizedNodeId,
|
|
151
|
+
requestId: approveData.requestId,
|
|
152
|
+
approvedAtMs: approveData.node?.approvedAtMs,
|
|
153
|
+
}));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Not in pending — check if already paired with non-empty caps/commands
|
|
158
|
+
const paired = listData.paired ?? [];
|
|
159
|
+
const pairedMatch = paired.find(
|
|
160
|
+
(entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (pairedMatch) {
|
|
164
|
+
const caps = pairedMatch.caps ?? [];
|
|
165
|
+
const commands = pairedMatch.commands ?? [];
|
|
166
|
+
if (caps.length > 0 || commands.length > 0) {
|
|
167
|
+
log.info(`nodeId=${normalizedNodeId} already paired with caps=${caps.length} commands=${commands.length}`);
|
|
168
|
+
res.statusCode = 200;
|
|
169
|
+
res.setHeader("Content-Type", "application/json");
|
|
170
|
+
res.end(JSON.stringify({
|
|
171
|
+
ok: true,
|
|
172
|
+
nodeId: normalizedNodeId,
|
|
173
|
+
alreadyApproved: true,
|
|
174
|
+
approvedAtMs: pairedMatch.approvedAtMs,
|
|
175
|
+
caps,
|
|
176
|
+
commands,
|
|
177
|
+
}));
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
res.statusCode = 404;
|
|
183
|
+
res.setHeader("Content-Type", "application/json");
|
|
184
|
+
res.end(JSON.stringify({
|
|
185
|
+
error: "No pending node found for this nodeId",
|
|
186
|
+
nodeId: normalizedNodeId,
|
|
187
|
+
}));
|
|
188
|
+
return true;
|
|
189
|
+
}
|