@syengup/friday-channel-next 0.0.1
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/README.md +35 -0
- package/index.ts +191 -0
- package/install.mjs +158 -0
- package/install.sh +118 -0
- package/openclaw.plugin.json +53 -0
- package/package.json +65 -0
- package/src/agent/abort-run.ts +10 -0
- package/src/agent/active-runs.ts +26 -0
- package/src/agent/dispatch-bridge.ts +18 -0
- package/src/agent/media-bridge.ts +23 -0
- package/src/agent-forward-runtime.ts +30 -0
- package/src/agent-run-context-bridge.ts +32 -0
- package/src/channel-actions.ts +129 -0
- package/src/channel.ts +284 -0
- package/src/collect-message-media-paths.ts +132 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +64 -0
- package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
- package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
- package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
- package/src/e2e/offline-replay.e2e.test.ts +43 -0
- package/src/e2e/send-text.e2e.test.ts +73 -0
- package/src/e2e/slash-commands.e2e.test.ts +33 -0
- package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
- package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
- package/src/friday-inbound-stats.ts +10 -0
- package/src/friday-session.forward-agent.test.ts +270 -0
- package/src/friday-session.ts +327 -0
- package/src/host-config.ts +20 -0
- package/src/http/handlers/cancel.test.ts +70 -0
- package/src/http/handlers/cancel.ts +35 -0
- package/src/http/handlers/files-download.ts +239 -0
- package/src/http/handlers/files-upload.ts +166 -0
- package/src/http/handlers/files.ts +335 -0
- package/src/http/handlers/messages.test.ts +119 -0
- package/src/http/handlers/messages.ts +555 -0
- package/src/http/handlers/models-list.ts +126 -0
- package/src/http/handlers/sessions-delete.ts +59 -0
- package/src/http/handlers/sessions-settings.ts +90 -0
- package/src/http/handlers/sse.test.ts +71 -0
- package/src/http/handlers/sse.ts +84 -0
- package/src/http/handlers/status.test.ts +52 -0
- package/src/http/handlers/status.ts +33 -0
- package/src/http/middleware/auth.test.ts +46 -0
- package/src/http/middleware/auth.ts +31 -0
- package/src/http/middleware/body.test.ts +27 -0
- package/src/http/middleware/body.ts +28 -0
- package/src/http/middleware/cors.test.ts +40 -0
- package/src/http/middleware/cors.ts +12 -0
- package/src/http/server.ts +106 -0
- package/src/logging.ts +27 -0
- package/src/openclaw.d.ts +32 -0
- package/src/run-metadata.ts +180 -0
- package/src/runtime.ts +14 -0
- package/src/session/session-manager.ts +230 -0
- package/src/session-usage-snapshot.ts +80 -0
- package/src/sse/emitter.test.ts +85 -0
- package/src/sse/emitter.ts +249 -0
- package/src/sse/frame-format.test.ts +56 -0
- package/src/sse/offline-queue.test.ts +65 -0
- package/src/sse/offline-queue.ts +140 -0
- package/src/test-support/app-simulator.ts +243 -0
- package/src/test-support/mock-dispatch.ts +181 -0
- package/src/test-support/mock-runtime.ts +74 -0
- package/src/vendor/runtime-store.ts +99 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { deleteFridaySession, toSessionStoreKey } from "../../session/session-manager.js";
|
|
3
|
+
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
4
|
+
import { abortRun } from "../../agent/abort-run.js";
|
|
5
|
+
import { getRunRoute } from "../../run-metadata.js";
|
|
6
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
7
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
8
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
9
|
+
|
|
10
|
+
async function cancelActiveRunsForSession(sessionKey: string): Promise<string[]> {
|
|
11
|
+
const storeKey = toSessionStoreKey(sessionKey);
|
|
12
|
+
const cancelled: string[] = [];
|
|
13
|
+
for (const runId of getActiveRunIds()) {
|
|
14
|
+
const route = getRunRoute(runId);
|
|
15
|
+
if (route?.sessionKey === storeKey) {
|
|
16
|
+
await abortRun(runId);
|
|
17
|
+
sseEmitter.untrackRun(runId);
|
|
18
|
+
cancelled.push(runId);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return cancelled;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function handleSessionsDelete(
|
|
25
|
+
req: IncomingMessage,
|
|
26
|
+
res: ServerResponse,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
if (req.method !== "DELETE") {
|
|
29
|
+
res.statusCode = 405;
|
|
30
|
+
res.setHeader("Content-Type", "application/json");
|
|
31
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const token = extractBearerToken(req);
|
|
36
|
+
if (!token) {
|
|
37
|
+
res.statusCode = 401;
|
|
38
|
+
res.setHeader("Content-Type", "application/json");
|
|
39
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body = await readJsonBody(req);
|
|
44
|
+
const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
|
|
45
|
+
if (!sessionKey) {
|
|
46
|
+
res.statusCode = 400;
|
|
47
|
+
res.setHeader("Content-Type", "application/json");
|
|
48
|
+
res.end(JSON.stringify({ error: "Missing required field: sessionKey" }));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cancelledRuns = await cancelActiveRunsForSession(sessionKey);
|
|
53
|
+
const result = deleteFridaySession(sessionKey);
|
|
54
|
+
|
|
55
|
+
res.statusCode = 200;
|
|
56
|
+
res.setHeader("Content-Type", "application/json");
|
|
57
|
+
res.end(JSON.stringify({ ok: true, ...result, cancelledRuns }));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import {
|
|
3
|
+
setSessionSettings,
|
|
4
|
+
getSessionSettings,
|
|
5
|
+
splitModelRef,
|
|
6
|
+
} from "../../session/session-manager.js";
|
|
7
|
+
import { readJsonBody } from "../middleware/body.js";
|
|
8
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
9
|
+
|
|
10
|
+
const VALID_REASONING = new Set(["on", "off", "stream"]);
|
|
11
|
+
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high"]);
|
|
12
|
+
|
|
13
|
+
export async function handleSessionsSettings(
|
|
14
|
+
req: IncomingMessage,
|
|
15
|
+
res: ServerResponse,
|
|
16
|
+
): Promise<boolean> {
|
|
17
|
+
if (req.method !== "PUT" && req.method !== "GET") {
|
|
18
|
+
res.statusCode = 405;
|
|
19
|
+
res.setHeader("Content-Type", "application/json");
|
|
20
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const token = extractBearerToken(req);
|
|
25
|
+
if (!token) {
|
|
26
|
+
res.statusCode = 401;
|
|
27
|
+
res.setHeader("Content-Type", "application/json");
|
|
28
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (req.method === "GET") {
|
|
33
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
34
|
+
const sessionKey = (url.searchParams.get("sessionKey") ?? "").trim();
|
|
35
|
+
if (!sessionKey) {
|
|
36
|
+
res.statusCode = 400;
|
|
37
|
+
res.setHeader("Content-Type", "application/json");
|
|
38
|
+
res.end(JSON.stringify({ error: "Missing required query param: sessionKey" }));
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
const settings = getSessionSettings(sessionKey);
|
|
42
|
+
res.statusCode = 200;
|
|
43
|
+
res.setHeader("Content-Type", "application/json");
|
|
44
|
+
res.end(JSON.stringify({ ok: true, sessionKey, ...settings }));
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// PUT
|
|
49
|
+
const body = await readJsonBody(req);
|
|
50
|
+
const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
|
|
51
|
+
if (!sessionKey) {
|
|
52
|
+
res.statusCode = 400;
|
|
53
|
+
res.setHeader("Content-Type", "application/json");
|
|
54
|
+
res.end(JSON.stringify({ error: "Missing required field: sessionKey" }));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const reasoningLevel = typeof body?.reasoningLevel === "string" ? body.reasoningLevel : undefined;
|
|
59
|
+
const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
|
|
60
|
+
const modelRef = typeof body?.modelRef === "string" ? body.modelRef : undefined;
|
|
61
|
+
|
|
62
|
+
const errors: string[] = [];
|
|
63
|
+
if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
|
|
64
|
+
errors.push(`reasoningLevel must be one of: ${[...VALID_REASONING].join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
if (thinkingLevel !== undefined && !VALID_THINKING.has(thinkingLevel)) {
|
|
67
|
+
errors.push(`thinkingLevel must be one of: ${[...VALID_THINKING].join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
res.statusCode = 400;
|
|
72
|
+
res.setHeader("Content-Type", "application/json");
|
|
73
|
+
res.end(JSON.stringify({ error: errors.join("; ") }));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const settings: Record<string, string | undefined> = { reasoningLevel, thinkingLevel, modelRef };
|
|
78
|
+
if (modelRef) {
|
|
79
|
+
const split = splitModelRef(modelRef);
|
|
80
|
+
settings["providerOverride"] = split.provider;
|
|
81
|
+
settings["modelOverride"] = split.modelId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result = setSessionSettings(sessionKey, settings);
|
|
85
|
+
|
|
86
|
+
res.statusCode = 200;
|
|
87
|
+
res.setHeader("Content-Type", "application/json");
|
|
88
|
+
res.end(JSON.stringify({ ok: true, sessionKey, ...result }));
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { handleSseStream } from "./sse.js";
|
|
5
|
+
import { setFridayNextRuntime } from "../../runtime.js";
|
|
6
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
7
|
+
|
|
8
|
+
class MockReq extends EventEmitter {
|
|
9
|
+
method = "GET";
|
|
10
|
+
url = "/friday-next/events?deviceId=dev-a&lastEventId=3";
|
|
11
|
+
headers: Record<string, string> = {
|
|
12
|
+
authorization: "Bearer t1",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class MockRes extends EventEmitter {
|
|
17
|
+
statusCode = 0;
|
|
18
|
+
headers: Record<string, string> = {};
|
|
19
|
+
writes: string[] = [];
|
|
20
|
+
setHeader(name: string, value: string): void {
|
|
21
|
+
this.headers[name.toLowerCase()] = value;
|
|
22
|
+
}
|
|
23
|
+
flushHeaders(): void {
|
|
24
|
+
// no-op
|
|
25
|
+
}
|
|
26
|
+
write(chunk: string): boolean {
|
|
27
|
+
this.writes.push(chunk);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
end(body?: string): void {
|
|
31
|
+
if (body) this.writes.push(body);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("handleSseStream", () => {
|
|
36
|
+
it("returns 401 when bearer missing", async () => {
|
|
37
|
+
setFridayNextRuntime({
|
|
38
|
+
config: { loadConfig: () => ({ channels: { "friday-next": { authToken: "t1" } } }) },
|
|
39
|
+
} as never);
|
|
40
|
+
const req = new MockReq() as unknown as IncomingMessage;
|
|
41
|
+
(req as IncomingMessage).headers = {};
|
|
42
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
43
|
+
await handleSseStream(req, res);
|
|
44
|
+
expect((res as unknown as MockRes).statusCode).toBe(401);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("sets SSE headers and replays from Last-Event-ID", async () => {
|
|
48
|
+
setFridayNextRuntime({
|
|
49
|
+
config: {
|
|
50
|
+
loadConfig: () => ({
|
|
51
|
+
channels: {
|
|
52
|
+
"friday-next": {
|
|
53
|
+
authToken: "t1",
|
|
54
|
+
sse: { keepaliveSec: 30, backlogPerDevice: 20 },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
} as never);
|
|
60
|
+
const req = new MockReq() as unknown as IncomingMessage;
|
|
61
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
62
|
+
const spyReplay = vi.spyOn(sseEmitter, "replayBacklog").mockReturnValue(0);
|
|
63
|
+
await handleSseStream(req, res);
|
|
64
|
+
const headers = (res as unknown as MockRes).headers;
|
|
65
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
66
|
+
expect(headers["content-type"]).toContain("text/event-stream");
|
|
67
|
+
expect(spyReplay).toHaveBeenCalledWith("dev-a", 3);
|
|
68
|
+
(req as unknown as MockReq).emit("close");
|
|
69
|
+
spyReplay.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
3
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
4
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
5
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
6
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
7
|
+
|
|
8
|
+
function parseLastEventId(req: IncomingMessage, url: URL): number {
|
|
9
|
+
const query = Number.parseInt(url.searchParams.get("lastEventId") ?? "", 10);
|
|
10
|
+
if (Number.isFinite(query)) return query;
|
|
11
|
+
const header = Number.parseInt((req.headers["last-event-id"] as string) ?? "", 10);
|
|
12
|
+
return Number.isFinite(header) ? header : 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function handleSseStream(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
16
|
+
if (req.method !== "GET") {
|
|
17
|
+
res.statusCode = 405;
|
|
18
|
+
res.setHeader("Content-Type", "application/json");
|
|
19
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const token = extractBearerToken(req);
|
|
24
|
+
if (!token) {
|
|
25
|
+
res.statusCode = 401;
|
|
26
|
+
res.setHeader("Content-Type", "application/json");
|
|
27
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
32
|
+
const deviceId = (url.searchParams.get("deviceId") ?? "").trim();
|
|
33
|
+
if (!deviceId) {
|
|
34
|
+
res.statusCode = 400;
|
|
35
|
+
res.setHeader("Content-Type", "application/json");
|
|
36
|
+
res.end(JSON.stringify({ error: "Missing required query parameter: deviceId" }));
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
res.statusCode = 200;
|
|
41
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
42
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
43
|
+
res.setHeader("Connection", "keep-alive");
|
|
44
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
45
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
46
|
+
res.flushHeaders();
|
|
47
|
+
|
|
48
|
+
const conn = sseEmitter.addConnection(deviceId, res);
|
|
49
|
+
|
|
50
|
+
const normalized = deviceId.trim().toUpperCase();
|
|
51
|
+
const lastSeq = sseEmitter.latestSeqForDevice(normalized);
|
|
52
|
+
sseEmitter.broadcast(
|
|
53
|
+
{
|
|
54
|
+
type: "connected",
|
|
55
|
+
data: {
|
|
56
|
+
deviceId: normalized,
|
|
57
|
+
serverTime: Date.now(),
|
|
58
|
+
lastSeq,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
deviceId,
|
|
62
|
+
true,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const lastEventId = parseLastEventId(req, url);
|
|
66
|
+
if (lastEventId > 0) sseEmitter.replayBacklog(deviceId, lastEventId);
|
|
67
|
+
|
|
68
|
+
const config = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
69
|
+
const keepalive = setInterval(() => {
|
|
70
|
+
if (conn.isClosed) {
|
|
71
|
+
clearInterval(keepalive);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
conn.sendRaw(": keepalive\n\n");
|
|
75
|
+
}, config.sseKeepaliveSec * 1000);
|
|
76
|
+
keepalive.unref();
|
|
77
|
+
|
|
78
|
+
req.on("close", () => {
|
|
79
|
+
clearInterval(keepalive);
|
|
80
|
+
sseEmitter.removeConnection(deviceId, conn);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { handleStatus } from "./status.js";
|
|
5
|
+
import { clearFridayNextRuntime, setFridayNextRuntime } from "../../runtime.js";
|
|
6
|
+
|
|
7
|
+
class MockRes extends EventEmitter {
|
|
8
|
+
statusCode = 0;
|
|
9
|
+
headers: Record<string, string> = {};
|
|
10
|
+
body = "";
|
|
11
|
+
setHeader(name: string, value: string): void {
|
|
12
|
+
this.headers[name.toLowerCase()] = value;
|
|
13
|
+
}
|
|
14
|
+
end(body?: string): void {
|
|
15
|
+
if (body) this.body += body;
|
|
16
|
+
this.emit("finish");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("handleStatus", () => {
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
clearFridayNextRuntime();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("401 without bearer", async () => {
|
|
26
|
+
setFridayNextRuntime({
|
|
27
|
+
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
28
|
+
} as never);
|
|
29
|
+
const req = { method: "GET", headers: {} } as IncomingMessage;
|
|
30
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
31
|
+
await handleStatus(req, res);
|
|
32
|
+
expect((res as unknown as MockRes).statusCode).toBe(401);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns channel health payload", async () => {
|
|
36
|
+
setFridayNextRuntime({
|
|
37
|
+
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
38
|
+
} as never);
|
|
39
|
+
const req = {
|
|
40
|
+
method: "GET",
|
|
41
|
+
headers: { authorization: "Bearer tok" },
|
|
42
|
+
} as IncomingMessage;
|
|
43
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
44
|
+
const handled = await handleStatus(req, res);
|
|
45
|
+
expect(handled).toBe(true);
|
|
46
|
+
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
47
|
+
const body = JSON.parse((res as unknown as MockRes).body) as Record<string, unknown>;
|
|
48
|
+
expect(body.channel).toBe("friday-next");
|
|
49
|
+
expect(Array.isArray(body.activeRuns)).toBe(true);
|
|
50
|
+
expect(typeof body.activeRunCount).toBe("number");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
3
|
+
import { sseEmitter } from "../../sse/emitter.js";
|
|
4
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
+
|
|
6
|
+
export async function handleStatus(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
7
|
+
if (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
|
+
if (!extractBearerToken(req)) {
|
|
14
|
+
res.statusCode = 401;
|
|
15
|
+
res.setHeader("Content-Type", "application/json");
|
|
16
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
const activeRuns = getActiveRunIds();
|
|
20
|
+
res.statusCode = 200;
|
|
21
|
+
res.setHeader("Content-Type", "application/json");
|
|
22
|
+
res.end(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
ok: true,
|
|
25
|
+
channel: "friday-next",
|
|
26
|
+
version: "v2",
|
|
27
|
+
connections: sseEmitter.getConnectionCount(),
|
|
28
|
+
activeRuns,
|
|
29
|
+
activeRunCount: activeRuns.length,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { extractBearerToken } from "./auth.js";
|
|
4
|
+
import { setFridayNextRuntime } from "../../runtime.js";
|
|
5
|
+
|
|
6
|
+
function setConfig(config: unknown): void {
|
|
7
|
+
setFridayNextRuntime({
|
|
8
|
+
config: { loadConfig: () => config },
|
|
9
|
+
} as never);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function req(auth?: string): IncomingMessage {
|
|
13
|
+
return { headers: auth ? { authorization: auth } : {} } as IncomingMessage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("extractBearerToken", () => {
|
|
17
|
+
it("uses gateway.auth.token with highest priority", () => {
|
|
18
|
+
setConfig({
|
|
19
|
+
gateway: { auth: { token: "gateway-token" } },
|
|
20
|
+
channels: {
|
|
21
|
+
"friday-next": { authToken: "plugin-token" },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
expect(extractBearerToken(req("Bearer gateway-token"))).toBe("gateway-token");
|
|
25
|
+
expect(extractBearerToken(req("Bearer plugin-token"))).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("falls back to plugin authToken", () => {
|
|
29
|
+
setConfig({
|
|
30
|
+
channels: {
|
|
31
|
+
"friday-next": { authToken: "plugin-token" },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
expect(extractBearerToken(req("Bearer plugin-token"))).toBe("plugin-token");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns null for missing or malformed header", () => {
|
|
38
|
+
setConfig({
|
|
39
|
+
channels: {
|
|
40
|
+
"friday-next": { authToken: "x" },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
expect(extractBearerToken(req())).toBeNull();
|
|
44
|
+
expect(extractBearerToken(req("x y z"))).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
|
|
8
|
+
import type { IncomingMessage } from "node:http";
|
|
9
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
10
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
11
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract and validate bearer token from Authorization header.
|
|
15
|
+
* Returns the token only if it matches the gateway's configured auth token.
|
|
16
|
+
* Returns null if token is missing, malformed, or doesn't match.
|
|
17
|
+
*/
|
|
18
|
+
export function extractBearerToken(req: IncomingMessage): string | null {
|
|
19
|
+
const auth = req.headers.authorization;
|
|
20
|
+
if (!auth || typeof auth !== "string") return null;
|
|
21
|
+
const parts = auth.trim().split(/\s+/);
|
|
22
|
+
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") return null;
|
|
23
|
+
const token = parts[1];
|
|
24
|
+
|
|
25
|
+
// Validate token matches the gateway's configured auth token.
|
|
26
|
+
const cfg = getHostOpenClawConfigSnapshot(getFridayNextRuntime().config);
|
|
27
|
+
const runtimeConfig = resolveFridayNextConfig(cfg);
|
|
28
|
+
if (!runtimeConfig.authToken || token !== runtimeConfig.authToken) return null;
|
|
29
|
+
|
|
30
|
+
return token;
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { readJsonBody } from "./body.js";
|
|
4
|
+
|
|
5
|
+
describe("readJsonBody", () => {
|
|
6
|
+
it("parses valid json", async () => {
|
|
7
|
+
const req = new PassThrough();
|
|
8
|
+
const p = readJsonBody(req as never);
|
|
9
|
+
req.end(JSON.stringify({ ok: true }));
|
|
10
|
+
await expect(p).resolves.toEqual({ ok: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null for invalid json", async () => {
|
|
14
|
+
const req = new PassThrough();
|
|
15
|
+
const p = readJsonBody(req as never);
|
|
16
|
+
req.end("{");
|
|
17
|
+
await expect(p).resolves.toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns null for oversized body", async () => {
|
|
21
|
+
const req = new PassThrough();
|
|
22
|
+
const p = readJsonBody(req as never, 8);
|
|
23
|
+
req.write("123456789");
|
|
24
|
+
req.end();
|
|
25
|
+
await expect(p).resolves.toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
|
|
3
|
+
export async function readJsonBody(
|
|
4
|
+
req: IncomingMessage,
|
|
5
|
+
maxBytes = 2 * 1024 * 1024,
|
|
6
|
+
): Promise<Record<string, unknown> | null> {
|
|
7
|
+
return await new Promise((resolve) => {
|
|
8
|
+
const chunks: Buffer[] = [];
|
|
9
|
+
let total = 0;
|
|
10
|
+
req.on("data", (chunk: Buffer) => {
|
|
11
|
+
total += chunk.length;
|
|
12
|
+
if (total > maxBytes) {
|
|
13
|
+
resolve(null);
|
|
14
|
+
req.destroy();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
chunks.push(chunk);
|
|
18
|
+
});
|
|
19
|
+
req.on("end", () => {
|
|
20
|
+
try {
|
|
21
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")) as Record<string, unknown>);
|
|
22
|
+
} catch {
|
|
23
|
+
resolve(null);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
req.on("error", () => resolve(null));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { applyCorsHeaders } from "./cors.js";
|
|
3
|
+
import { setFridayNextRuntime } from "../../runtime.js";
|
|
4
|
+
|
|
5
|
+
class MockRes {
|
|
6
|
+
headers: Record<string, string> = {};
|
|
7
|
+
setHeader(name: string, value: string): void {
|
|
8
|
+
this.headers[name] = value;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("applyCorsHeaders", () => {
|
|
13
|
+
it("does nothing when cors disabled", () => {
|
|
14
|
+
setFridayNextRuntime({
|
|
15
|
+
config: { loadConfig: () => ({ channels: { "friday-next": { cors: { enabled: false } } } }) },
|
|
16
|
+
} as never);
|
|
17
|
+
const res = new MockRes();
|
|
18
|
+
applyCorsHeaders(res as never);
|
|
19
|
+
expect(Object.keys(res.headers)).toHaveLength(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("sets configured cors headers", () => {
|
|
23
|
+
setFridayNextRuntime({
|
|
24
|
+
config: {
|
|
25
|
+
loadConfig: () => ({
|
|
26
|
+
channels: {
|
|
27
|
+
"friday-next": {
|
|
28
|
+
cors: { enabled: true, allowOrigin: "https://app.local" },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
} as never);
|
|
34
|
+
const res = new MockRes();
|
|
35
|
+
applyCorsHeaders(res as never);
|
|
36
|
+
expect(res.headers["Access-Control-Allow-Origin"]).toBe("https://app.local");
|
|
37
|
+
expect(res.headers["Access-Control-Allow-Headers"]).toContain("Authorization");
|
|
38
|
+
expect(res.headers["Access-Control-Allow-Methods"]).toContain("OPTIONS");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
3
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
4
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
5
|
+
|
|
6
|
+
export function applyCorsHeaders(res: ServerResponse): void {
|
|
7
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
8
|
+
if (!cfg.corsEnabled) return;
|
|
9
|
+
res.setHeader("Access-Control-Allow-Origin", cfg.corsAllowOrigin || "*");
|
|
10
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID");
|
|
11
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
|
|
12
|
+
}
|