@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
3
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
5
|
+
const EXEC_ENV = process.platform === "win32"
|
|
6
|
+
? process.env
|
|
7
|
+
: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
|
|
8
|
+
function execAsync(command, timeoutMs) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
|
|
11
|
+
if (error) {
|
|
12
|
+
reject(error);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
resolve({ stdout, stderr });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
child.stdout?.on("data", () => { });
|
|
19
|
+
child.stderr?.on("data", () => { });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export async function handleNodesApprove(req, res) {
|
|
23
|
+
const log = createFridayNextLogger("nodes-approve");
|
|
24
|
+
if (req.method !== "POST") {
|
|
25
|
+
res.statusCode = 405;
|
|
26
|
+
res.setHeader("Content-Type", "application/json");
|
|
27
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
const token = extractBearerToken(req);
|
|
31
|
+
if (!token) {
|
|
32
|
+
res.statusCode = 401;
|
|
33
|
+
res.setHeader("Content-Type", "application/json");
|
|
34
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
const body = await readJsonBody(req);
|
|
38
|
+
if (!body) {
|
|
39
|
+
res.statusCode = 400;
|
|
40
|
+
res.setHeader("Content-Type", "application/json");
|
|
41
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const rawNodeId = typeof body.nodeId === "string" ? body.nodeId : "";
|
|
45
|
+
if (!rawNodeId.trim()) {
|
|
46
|
+
res.statusCode = 400;
|
|
47
|
+
res.setHeader("Content-Type", "application/json");
|
|
48
|
+
res.end(JSON.stringify({ error: "Missing required field: nodeId" }));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
const normalizedNodeId = rawNodeId.trim().toUpperCase();
|
|
52
|
+
let listStdout;
|
|
53
|
+
try {
|
|
54
|
+
const result = await execAsync("openclaw nodes list --json", 15000);
|
|
55
|
+
listStdout = result.stdout;
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const stderr = err?.stderr?.trim();
|
|
59
|
+
log.error(`nodes list failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
60
|
+
res.statusCode = 502;
|
|
61
|
+
res.setHeader("Content-Type", "application/json");
|
|
62
|
+
res.end(JSON.stringify({ error: "Failed to list nodes from gateway", detail: stderr || undefined }));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
let listData;
|
|
66
|
+
try {
|
|
67
|
+
listData = JSON.parse(listStdout);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
log.error(`nodes list returned invalid JSON: ${listStdout.slice(0, 200)}`);
|
|
71
|
+
res.statusCode = 502;
|
|
72
|
+
res.setHeader("Content-Type", "application/json");
|
|
73
|
+
res.end(JSON.stringify({ error: "Unexpected response from gateway node list" }));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
const pending = listData.pending ?? [];
|
|
77
|
+
const pendingMatch = pending.find((entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId);
|
|
78
|
+
if (pendingMatch) {
|
|
79
|
+
const requestId = pendingMatch.requestId;
|
|
80
|
+
log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
|
|
81
|
+
let approveStdout;
|
|
82
|
+
try {
|
|
83
|
+
const result = await execAsync(`openclaw nodes approve ${requestId} --json`, 15000);
|
|
84
|
+
approveStdout = result.stdout;
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const stderr = err?.stderr?.trim();
|
|
88
|
+
log.error(`nodes approve failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
res.statusCode = 502;
|
|
90
|
+
res.setHeader("Content-Type", "application/json");
|
|
91
|
+
res.end(JSON.stringify({
|
|
92
|
+
error: "Node approval command failed",
|
|
93
|
+
detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
|
|
94
|
+
}));
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
let approveData;
|
|
98
|
+
try {
|
|
99
|
+
approveData = JSON.parse(approveStdout);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
log.error(`nodes approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
|
|
103
|
+
res.statusCode = 502;
|
|
104
|
+
res.setHeader("Content-Type", "application/json");
|
|
105
|
+
res.end(JSON.stringify({ error: "Unexpected response from node approval" }));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
res.statusCode = 200;
|
|
109
|
+
res.setHeader("Content-Type", "application/json");
|
|
110
|
+
res.end(JSON.stringify({
|
|
111
|
+
ok: true,
|
|
112
|
+
nodeId: normalizedNodeId,
|
|
113
|
+
requestId: approveData.requestId,
|
|
114
|
+
approvedAtMs: approveData.node?.approvedAtMs,
|
|
115
|
+
}));
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// Not in pending — check if already paired with non-empty caps/commands
|
|
119
|
+
const paired = listData.paired ?? [];
|
|
120
|
+
const pairedMatch = paired.find((entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId);
|
|
121
|
+
if (pairedMatch) {
|
|
122
|
+
const caps = pairedMatch.caps ?? [];
|
|
123
|
+
const commands = pairedMatch.commands ?? [];
|
|
124
|
+
if (caps.length > 0 || commands.length > 0) {
|
|
125
|
+
log.info(`nodeId=${normalizedNodeId} already paired with caps=${caps.length} commands=${commands.length}`);
|
|
126
|
+
res.statusCode = 200;
|
|
127
|
+
res.setHeader("Content-Type", "application/json");
|
|
128
|
+
res.end(JSON.stringify({
|
|
129
|
+
ok: true,
|
|
130
|
+
nodeId: normalizedNodeId,
|
|
131
|
+
alreadyApproved: true,
|
|
132
|
+
approvedAtMs: pairedMatch.approvedAtMs,
|
|
133
|
+
caps,
|
|
134
|
+
commands,
|
|
135
|
+
}));
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
res.statusCode = 404;
|
|
140
|
+
res.setHeader("Content-Type", "application/json");
|
|
141
|
+
res.end(JSON.stringify({
|
|
142
|
+
error: "No pending node found for this nodeId",
|
|
143
|
+
nodeId: normalizedNodeId,
|
|
144
|
+
}));
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { deleteFridaySession, toSessionStoreKey } from "../../session/session-manager.js";
|
|
2
|
+
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
3
|
+
import { abortRun } from "../../agent/abort-run.js";
|
|
4
|
+
import { getRunRoute } from "../../run-metadata.js";
|
|
5
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
6
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
7
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
8
|
+
async function cancelActiveRunsForSession(sessionKey) {
|
|
9
|
+
const storeKey = toSessionStoreKey(sessionKey);
|
|
10
|
+
const cancelled = [];
|
|
11
|
+
for (const runId of getActiveRunIds()) {
|
|
12
|
+
const route = getRunRoute(runId);
|
|
13
|
+
if (route?.sessionKey === storeKey) {
|
|
14
|
+
await abortRun(runId);
|
|
15
|
+
sseEmitter.untrackRun(runId);
|
|
16
|
+
cancelled.push(runId);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return cancelled;
|
|
20
|
+
}
|
|
21
|
+
export async function handleSessionsDelete(req, res) {
|
|
22
|
+
if (req.method !== "DELETE") {
|
|
23
|
+
res.statusCode = 405;
|
|
24
|
+
res.setHeader("Content-Type", "application/json");
|
|
25
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const token = extractBearerToken(req);
|
|
29
|
+
if (!token) {
|
|
30
|
+
res.statusCode = 401;
|
|
31
|
+
res.setHeader("Content-Type", "application/json");
|
|
32
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const body = await readJsonBody(req);
|
|
36
|
+
const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
|
|
37
|
+
if (!sessionKey) {
|
|
38
|
+
res.statusCode = 400;
|
|
39
|
+
res.setHeader("Content-Type", "application/json");
|
|
40
|
+
res.end(JSON.stringify({ error: "Missing required field: sessionKey" }));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const cancelledRuns = await cancelActiveRunsForSession(sessionKey);
|
|
44
|
+
const result = deleteFridaySession(sessionKey);
|
|
45
|
+
res.statusCode = 200;
|
|
46
|
+
res.setHeader("Content-Type", "application/json");
|
|
47
|
+
res.end(JSON.stringify({ ok: true, ...result, cancelledRuns }));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { setSessionSettings, getSessionSettings, splitModelRef, } from "../../session/session-manager.js";
|
|
2
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
3
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
const VALID_REASONING = new Set(["on", "off", "stream"]);
|
|
5
|
+
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high"]);
|
|
6
|
+
export async function handleSessionsSettings(req, res) {
|
|
7
|
+
if (req.method !== "PUT" && req.method !== "GET") {
|
|
8
|
+
res.statusCode = 405;
|
|
9
|
+
res.setHeader("Content-Type", "application/json");
|
|
10
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
const token = extractBearerToken(req);
|
|
14
|
+
if (!token) {
|
|
15
|
+
res.statusCode = 401;
|
|
16
|
+
res.setHeader("Content-Type", "application/json");
|
|
17
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
if (req.method === "GET") {
|
|
21
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
22
|
+
const sessionKey = (url.searchParams.get("sessionKey") ?? "").trim();
|
|
23
|
+
if (!sessionKey) {
|
|
24
|
+
res.statusCode = 400;
|
|
25
|
+
res.setHeader("Content-Type", "application/json");
|
|
26
|
+
res.end(JSON.stringify({ error: "Missing required query param: sessionKey" }));
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const settings = getSessionSettings(sessionKey);
|
|
30
|
+
res.statusCode = 200;
|
|
31
|
+
res.setHeader("Content-Type", "application/json");
|
|
32
|
+
res.end(JSON.stringify({ ok: true, sessionKey, ...settings }));
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
// PUT
|
|
36
|
+
const body = await readJsonBody(req);
|
|
37
|
+
const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
|
|
38
|
+
if (!sessionKey) {
|
|
39
|
+
res.statusCode = 400;
|
|
40
|
+
res.setHeader("Content-Type", "application/json");
|
|
41
|
+
res.end(JSON.stringify({ error: "Missing required field: sessionKey" }));
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const reasoningLevel = typeof body?.reasoningLevel === "string" ? body.reasoningLevel : undefined;
|
|
45
|
+
const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
|
|
46
|
+
const modelRef = typeof body?.modelRef === "string" ? body.modelRef : undefined;
|
|
47
|
+
const errors = [];
|
|
48
|
+
if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
|
|
49
|
+
errors.push(`reasoningLevel must be one of: ${[...VALID_REASONING].join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
if (thinkingLevel !== undefined && !VALID_THINKING.has(thinkingLevel)) {
|
|
52
|
+
errors.push(`thinkingLevel must be one of: ${[...VALID_THINKING].join(", ")}`);
|
|
53
|
+
}
|
|
54
|
+
if (errors.length > 0) {
|
|
55
|
+
res.statusCode = 400;
|
|
56
|
+
res.setHeader("Content-Type", "application/json");
|
|
57
|
+
res.end(JSON.stringify({ error: errors.join("; ") }));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const settings = { reasoningLevel, thinkingLevel, modelRef };
|
|
61
|
+
if (modelRef) {
|
|
62
|
+
const split = splitModelRef(modelRef);
|
|
63
|
+
settings["providerOverride"] = split.provider;
|
|
64
|
+
settings["modelOverride"] = split.modelId;
|
|
65
|
+
}
|
|
66
|
+
const result = setSessionSettings(sessionKey, settings);
|
|
67
|
+
res.statusCode = 200;
|
|
68
|
+
res.setHeader("Content-Type", "application/json");
|
|
69
|
+
res.end(JSON.stringify({ ok: true, sessionKey, ...result }));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
2
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
3
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
4
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
5
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
6
|
+
function parseLastEventId(req, url) {
|
|
7
|
+
const query = Number.parseInt(url.searchParams.get("lastEventId") ?? "", 10);
|
|
8
|
+
if (Number.isFinite(query))
|
|
9
|
+
return query;
|
|
10
|
+
const header = Number.parseInt(req.headers["last-event-id"] ?? "", 10);
|
|
11
|
+
return Number.isFinite(header) ? header : 0;
|
|
12
|
+
}
|
|
13
|
+
export async function handleSseStream(req, res) {
|
|
14
|
+
if (req.method !== "GET") {
|
|
15
|
+
res.statusCode = 405;
|
|
16
|
+
res.setHeader("Content-Type", "application/json");
|
|
17
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
const token = extractBearerToken(req);
|
|
21
|
+
if (!token) {
|
|
22
|
+
res.statusCode = 401;
|
|
23
|
+
res.setHeader("Content-Type", "application/json");
|
|
24
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
28
|
+
const deviceId = (url.searchParams.get("deviceId") ?? "").trim();
|
|
29
|
+
if (!deviceId) {
|
|
30
|
+
res.statusCode = 400;
|
|
31
|
+
res.setHeader("Content-Type", "application/json");
|
|
32
|
+
res.end(JSON.stringify({ error: "Missing required query parameter: deviceId" }));
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
res.statusCode = 200;
|
|
36
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
37
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
38
|
+
res.setHeader("Connection", "keep-alive");
|
|
39
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
40
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
41
|
+
res.flushHeaders();
|
|
42
|
+
const conn = sseEmitter.addConnection(deviceId, res);
|
|
43
|
+
const normalized = deviceId.trim().toUpperCase();
|
|
44
|
+
const lastSeq = sseEmitter.latestSeqForDevice(normalized);
|
|
45
|
+
sseEmitter.broadcast({
|
|
46
|
+
type: "connected",
|
|
47
|
+
data: {
|
|
48
|
+
deviceId: normalized,
|
|
49
|
+
serverTime: Date.now(),
|
|
50
|
+
lastSeq,
|
|
51
|
+
},
|
|
52
|
+
}, deviceId, true);
|
|
53
|
+
const lastEventId = parseLastEventId(req, url);
|
|
54
|
+
if (lastEventId > 0)
|
|
55
|
+
sseEmitter.replayBacklog(deviceId, lastEventId);
|
|
56
|
+
const config = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
57
|
+
const keepalive = setInterval(() => {
|
|
58
|
+
if (conn.isClosed) {
|
|
59
|
+
clearInterval(keepalive);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
conn.sendRaw(": keepalive\n\n");
|
|
63
|
+
}, config.sseKeepaliveSec * 1000);
|
|
64
|
+
keepalive.unref();
|
|
65
|
+
req.on("close", () => {
|
|
66
|
+
clearInterval(keepalive);
|
|
67
|
+
sseEmitter.removeConnection(deviceId, conn);
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
2
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
3
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
export async function handleStatus(req, res) {
|
|
5
|
+
if (req.method !== "GET") {
|
|
6
|
+
res.statusCode = 405;
|
|
7
|
+
res.setHeader("Content-Type", "application/json");
|
|
8
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (!extractBearerToken(req)) {
|
|
12
|
+
res.statusCode = 401;
|
|
13
|
+
res.setHeader("Content-Type", "application/json");
|
|
14
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const activeRuns = getActiveRunIds();
|
|
18
|
+
res.statusCode = 200;
|
|
19
|
+
res.setHeader("Content-Type", "application/json");
|
|
20
|
+
res.end(JSON.stringify({
|
|
21
|
+
ok: true,
|
|
22
|
+
channel: "friday-next",
|
|
23
|
+
version: "v2",
|
|
24
|
+
connections: sseEmitter.getConnectionCount(),
|
|
25
|
+
activeRuns,
|
|
26
|
+
activeRunCount: activeRuns.length,
|
|
27
|
+
}));
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer token authentication middleware for Friday HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the bearer token matches the gateway's configured auth token.
|
|
5
|
+
* This ensures plugin HTTP endpoints use the same token as gateway WS connections.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage } from "node:http";
|
|
8
|
+
/**
|
|
9
|
+
* Extract and validate bearer token from Authorization header.
|
|
10
|
+
* Returns the token only if it matches the gateway's configured auth token.
|
|
11
|
+
* Returns null if token is missing, malformed, or doesn't match.
|
|
12
|
+
*/
|
|
13
|
+
export declare function extractBearerToken(req: IncomingMessage): string | null;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer token authentication middleware for Friday HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the bearer token matches the gateway's configured auth token.
|
|
5
|
+
* This ensures plugin HTTP endpoints use the same token as gateway WS connections.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
8
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
9
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
10
|
+
/**
|
|
11
|
+
* Extract and validate bearer token from Authorization header.
|
|
12
|
+
* Returns the token only if it matches the gateway's configured auth token.
|
|
13
|
+
* Returns null if token is missing, malformed, or doesn't match.
|
|
14
|
+
*/
|
|
15
|
+
export function extractBearerToken(req) {
|
|
16
|
+
const auth = req.headers.authorization;
|
|
17
|
+
if (!auth || typeof auth !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
const parts = auth.trim().split(/\s+/);
|
|
20
|
+
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer")
|
|
21
|
+
return null;
|
|
22
|
+
const token = parts[1];
|
|
23
|
+
// Validate token matches the gateway's configured auth token.
|
|
24
|
+
const cfg = getHostOpenClawConfigSnapshot(getFridayNextRuntime().config);
|
|
25
|
+
const runtimeConfig = resolveFridayNextConfig(cfg);
|
|
26
|
+
if (!runtimeConfig.authToken || token !== runtimeConfig.authToken)
|
|
27
|
+
return null;
|
|
28
|
+
return token;
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
2
|
+
return await new Promise((resolve) => {
|
|
3
|
+
const chunks = [];
|
|
4
|
+
let total = 0;
|
|
5
|
+
req.on("data", (chunk) => {
|
|
6
|
+
total += chunk.length;
|
|
7
|
+
if (total > maxBytes) {
|
|
8
|
+
resolve(null);
|
|
9
|
+
req.destroy();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
chunks.push(chunk);
|
|
13
|
+
});
|
|
14
|
+
req.on("end", () => {
|
|
15
|
+
try {
|
|
16
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
resolve(null);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
req.on("error", () => resolve(null));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
2
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
3
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
4
|
+
export function applyCorsHeaders(res) {
|
|
5
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
6
|
+
if (!cfg.corsEnabled)
|
|
7
|
+
return;
|
|
8
|
+
res.setHeader("Access-Control-Allow-Origin", cfg.corsAllowOrigin || "*");
|
|
9
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID");
|
|
10
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server registration for the Friday channel.
|
|
3
|
+
*
|
|
4
|
+
* Registers routes on the gateway HTTP server under the /friday-next/ path prefix.
|
|
5
|
+
* Routes are registered via the plugin API's registerHttpRoute method.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
export declare function registerFridayNextHttpRoutes(api: {
|
|
9
|
+
logger: {
|
|
10
|
+
info: (msg: string) => void;
|
|
11
|
+
warn: (msg: string) => void;
|
|
12
|
+
};
|
|
13
|
+
registerHttpRoute: (route: {
|
|
14
|
+
path: string;
|
|
15
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
16
|
+
auth: string;
|
|
17
|
+
match: string;
|
|
18
|
+
}) => void;
|
|
19
|
+
}): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server registration for the Friday channel.
|
|
3
|
+
*
|
|
4
|
+
* Registers routes on the gateway HTTP server under the /friday-next/ path prefix.
|
|
5
|
+
* Routes are registered via the plugin API's registerHttpRoute method.
|
|
6
|
+
*/
|
|
7
|
+
import { handleMessages } from "./handlers/messages.js";
|
|
8
|
+
import { handleSseStream } from "./handlers/sse.js";
|
|
9
|
+
import { handleFilesUpload } from "./handlers/files-upload.js";
|
|
10
|
+
import { handleFilesDownload } from "./handlers/files-download.js";
|
|
11
|
+
import { handleCancel } from "./handlers/cancel.js";
|
|
12
|
+
import { handleDeviceApprove } from "./handlers/device-approve.js";
|
|
13
|
+
import { handleNodesApprove } from "./handlers/nodes-approve.js";
|
|
14
|
+
import { handleSessionsDelete } from "./handlers/sessions-delete.js";
|
|
15
|
+
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
16
|
+
import { handleModelsList } from "./handlers/models-list.js";
|
|
17
|
+
import { handleStatus } from "./handlers/status.js";
|
|
18
|
+
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
19
|
+
import { resolveFridayNextConfig } from "../config.js";
|
|
20
|
+
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
21
|
+
import { getFridayNextRuntime } from "../runtime.js";
|
|
22
|
+
import { sseEmitter } from "../sse/emitter.js";
|
|
23
|
+
/** Route matcher - returns the matched handler or null. */
|
|
24
|
+
async function handleFridayNextRoute(req, res) {
|
|
25
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
26
|
+
const pathname = url.pathname;
|
|
27
|
+
applyCorsHeaders(res);
|
|
28
|
+
if (req.method === "OPTIONS") {
|
|
29
|
+
res.statusCode = 204;
|
|
30
|
+
res.end();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// Route: GET /friday-next/events?deviceId=...
|
|
34
|
+
if (req.method === "GET" && pathname === "/friday-next/events") {
|
|
35
|
+
return await handleSseStream(req, res);
|
|
36
|
+
}
|
|
37
|
+
// Route: POST /friday-next/messages
|
|
38
|
+
if (req.method === "POST" && pathname === "/friday-next/messages") {
|
|
39
|
+
return await handleMessages(req, res);
|
|
40
|
+
}
|
|
41
|
+
// Route: POST /friday-next/files (multipart upload)
|
|
42
|
+
if (req.method === "POST" && pathname === "/friday-next/files") {
|
|
43
|
+
return await handleFilesUpload(req, res);
|
|
44
|
+
}
|
|
45
|
+
// Route: GET /friday-next/files/:id (download)
|
|
46
|
+
if (req.method === "GET" && pathname.startsWith("/friday-next/files/")) {
|
|
47
|
+
return await handleFilesDownload(req, res);
|
|
48
|
+
}
|
|
49
|
+
if (req.method === "POST" && pathname === "/friday-next/cancel") {
|
|
50
|
+
return await handleCancel(req, res);
|
|
51
|
+
}
|
|
52
|
+
if (req.method === "POST" && pathname === "/friday-next/device-approve") {
|
|
53
|
+
return await handleDeviceApprove(req, res);
|
|
54
|
+
}
|
|
55
|
+
if (req.method === "POST" && pathname === "/friday-next/nodes-approve") {
|
|
56
|
+
return await handleNodesApprove(req, res);
|
|
57
|
+
}
|
|
58
|
+
if (req.method === "DELETE" && pathname === "/friday-next/sessions") {
|
|
59
|
+
return await handleSessionsDelete(req, res);
|
|
60
|
+
}
|
|
61
|
+
if ((req.method === "PUT" || req.method === "GET") && pathname === "/friday-next/sessions/settings") {
|
|
62
|
+
return await handleSessionsSettings(req, res);
|
|
63
|
+
}
|
|
64
|
+
if (req.method === "GET" && pathname === "/friday-next/models") {
|
|
65
|
+
return await handleModelsList(req, res);
|
|
66
|
+
}
|
|
67
|
+
if (req.method === "GET" && pathname === "/friday-next/status") {
|
|
68
|
+
return await handleStatus(req, res);
|
|
69
|
+
}
|
|
70
|
+
// Not found
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
export function registerFridayNextHttpRoutes(api) {
|
|
74
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
75
|
+
sseEmitter.setBacklogLimit(cfg.sseBacklogPerDevice);
|
|
76
|
+
if (!cfg.authToken) {
|
|
77
|
+
api.logger.warn("friday-next authToken not configured; all requests will 401");
|
|
78
|
+
}
|
|
79
|
+
// Plugin handles its own auth via extractBearerToken()
|
|
80
|
+
api.registerHttpRoute({
|
|
81
|
+
path: "/friday-next",
|
|
82
|
+
handler: handleFridayNextRoute,
|
|
83
|
+
auth: "plugin",
|
|
84
|
+
match: "prefix",
|
|
85
|
+
});
|
|
86
|
+
api.logger.info("Friday Next channel HTTP routes registered at /friday-next/*");
|
|
87
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FridayNextLogLevel } from "./config.js";
|
|
2
|
+
export declare function createFridayNextLogger(scope: string, level?: FridayNextLogLevel): {
|
|
3
|
+
debug: (message: string) => void;
|
|
4
|
+
info: (message: string) => void;
|
|
5
|
+
warn: (message: string) => void;
|
|
6
|
+
error: (message: string) => void;
|
|
7
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const levelOrder = {
|
|
2
|
+
debug: 10,
|
|
3
|
+
info: 20,
|
|
4
|
+
warn: 30,
|
|
5
|
+
error: 40,
|
|
6
|
+
};
|
|
7
|
+
export function createFridayNextLogger(scope, level = "info") {
|
|
8
|
+
const base = `[friday-next:${scope}]`;
|
|
9
|
+
const enabled = (current) => levelOrder[current] >= levelOrder[level];
|
|
10
|
+
return {
|
|
11
|
+
debug: (message) => {
|
|
12
|
+
if (enabled("debug"))
|
|
13
|
+
console.debug(`${base} ${message}`);
|
|
14
|
+
},
|
|
15
|
+
info: (message) => {
|
|
16
|
+
if (enabled("info"))
|
|
17
|
+
console.info(`${base} ${message}`);
|
|
18
|
+
},
|
|
19
|
+
warn: (message) => {
|
|
20
|
+
if (enabled("warn"))
|
|
21
|
+
console.warn(`${base} ${message}`);
|
|
22
|
+
},
|
|
23
|
+
error: (message) => {
|
|
24
|
+
if (enabled("error"))
|
|
25
|
+
console.error(`${base} ${message}`);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type RunRoute = {
|
|
2
|
+
runId: string;
|
|
3
|
+
deviceId: string;
|
|
4
|
+
sessionKey: string;
|
|
5
|
+
};
|
|
6
|
+
export type RunMetadata = {
|
|
7
|
+
modelName?: string;
|
|
8
|
+
totalTokens?: number;
|
|
9
|
+
/** Tokens counted toward the model context window (prompt-side: input + cache read + cache write when present). */
|
|
10
|
+
contextTokensUsed?: number;
|
|
11
|
+
/** Resolved model context window limit when the runtime exposes it. */
|
|
12
|
+
contextWindowMax?: number;
|
|
13
|
+
};
|
|
14
|
+
/** Vitest / harness: clears per-run metadata and final-delivered flags (not routes). */
|
|
15
|
+
export declare function resetRunMetadataForTest(): void;
|
|
16
|
+
export declare function registerRunRoute(route: RunRoute): void;
|
|
17
|
+
export declare function getRunRoute(runId: string): RunRoute | undefined;
|
|
18
|
+
export declare function setRunMetadata(runId: string, metadata: RunMetadata): void;
|
|
19
|
+
export declare function getRunMetadata(runId: string): RunMetadata | undefined;
|
|
20
|
+
export declare function markRunFinalDelivered(runId: string): void;
|
|
21
|
+
export declare function hasRunFinalDelivered(runId: string): boolean;
|
|
22
|
+
/** Best-effort prompt-side context footprint from a provider usage object. */
|
|
23
|
+
export declare function contextTokensFromUsageRecord(u: Record<string, unknown>): number | undefined;
|
|
24
|
+
export declare function ingestAgentEventMetadata(runId: string, data: Record<string, unknown>): void;
|
|
25
|
+
export {};
|