@syengup/friday-channel-next 0.1.30 → 0.1.37
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 +8 -4
- package/dist/index.js +1 -1
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/subagent-registry.js +0 -3
- package/dist/src/agent-forward-runtime.d.ts +15 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/agent-id.d.ts +8 -0
- package/dist/src/agent-id.js +21 -0
- package/dist/src/channel-actions.js +48 -15
- package/dist/src/channel.js +22 -3
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +188 -0
- package/dist/src/http/handlers/agent-files.d.ts +21 -0
- package/dist/src/http/handlers/agent-files.js +137 -0
- package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
- package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
- package/dist/src/http/handlers/agents-list.js +1 -19
- package/dist/src/http/handlers/cancel.js +14 -6
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +81 -13
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +33 -14
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +9 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +27 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +59 -0
- package/dist/src/skills-discovery.js +252 -0
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/thinking-levels.d.ts +21 -0
- package/dist/src/thinking-levels.js +48 -0
- package/dist/src/tool-catalog.d.ts +53 -0
- package/dist/src/tool-catalog.js +191 -0
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +4 -2
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +23 -8
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +30 -1
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +57 -4
- package/src/channel-actions.ts +41 -15
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +140 -120
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +212 -0
- package/src/http/handlers/agent-config.ts +232 -0
- package/src/http/handlers/agent-files.test.ts +136 -0
- package/src/http/handlers/agent-files.ts +149 -0
- package/src/http/handlers/agent-tools-catalog.ts +42 -0
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/agents-list.ts +1 -22
- package/src/http/handlers/cancel.test.ts +23 -4
- package/src/http/handlers/cancel.ts +14 -6
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +120 -0
- package/src/http/handlers/files.ts +115 -17
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +64 -21
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +26 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +33 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +78 -16
- package/src/link-preview/ssrf-guard.ts +7 -2
- package/src/media-fetch.test.ts +8 -3
- package/src/media-fetch.ts +5 -3
- package/src/openclaw.d.ts +41 -10
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +152 -0
- package/src/skills-discovery.ts +264 -0
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +70 -0
- package/src/tool-catalog.ts +261 -0
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +6 -2
- package/tsconfig.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import {
|
|
2
|
+
import { abortRunForSessionKey } from "../../agent/abort-run.js";
|
|
3
|
+
import { getRunRoute } from "../../run-metadata.js";
|
|
3
4
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
4
5
|
import { readJsonBody } from "../middleware/body.js";
|
|
5
6
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
@@ -20,16 +21,23 @@ export async function handleCancel(req: IncomingMessage, res: ServerResponse): P
|
|
|
20
21
|
}
|
|
21
22
|
const body = await readJsonBody(req);
|
|
22
23
|
const runId = typeof body?.runId === "string" ? body.runId.trim() : "";
|
|
23
|
-
|
|
24
|
+
// sessionKey is the primary identifier (one active run per session); runId is a
|
|
25
|
+
// back-compat fallback for older apps — resolve it to a sessionKey via the run route.
|
|
26
|
+
const sessionKey =
|
|
27
|
+
(typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "") ||
|
|
28
|
+
(runId ? (getRunRoute(runId)?.sessionKey?.trim() ?? "") : "");
|
|
29
|
+
if (!sessionKey && !runId) {
|
|
24
30
|
res.statusCode = 400;
|
|
25
31
|
res.setHeader("Content-Type", "application/json");
|
|
26
|
-
res.end(JSON.stringify({ error: "Missing runId" }));
|
|
32
|
+
res.end(JSON.stringify({ error: "Missing sessionKey or runId" }));
|
|
27
33
|
return true;
|
|
28
34
|
}
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
const result = sessionKey
|
|
36
|
+
? await abortRunForSessionKey(sessionKey)
|
|
37
|
+
: { aborted: false, drained: false };
|
|
38
|
+
if (runId) sseEmitter.untrackRun(runId);
|
|
31
39
|
res.statusCode = 200;
|
|
32
40
|
res.setHeader("Content-Type", "application/json");
|
|
33
|
-
res.end(JSON.stringify({ ok: true, runId, cancelled: true }));
|
|
41
|
+
res.end(JSON.stringify({ ok: true, sessionKey, runId, cancelled: true, ...result }));
|
|
34
42
|
return true;
|
|
35
43
|
}
|
|
@@ -28,8 +28,14 @@ class MockRes extends EventEmitter {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function mockReq(
|
|
32
|
-
|
|
31
|
+
function mockReq(
|
|
32
|
+
method: string,
|
|
33
|
+
headers: Record<string, string> = {},
|
|
34
|
+
): PassThrough & { method: string; headers: Record<string, string> } {
|
|
35
|
+
const stream = new PassThrough() as unknown as PassThrough & {
|
|
36
|
+
method: string;
|
|
37
|
+
headers: Record<string, string>;
|
|
38
|
+
};
|
|
33
39
|
stream.method = method;
|
|
34
40
|
stream.headers = headers;
|
|
35
41
|
return stream;
|
|
@@ -92,7 +98,10 @@ describe("handleDeviceApprove", () => {
|
|
|
92
98
|
});
|
|
93
99
|
|
|
94
100
|
it("returns 404 when listDevicePairing returns data without matching device", async () => {
|
|
95
|
-
mockList.mockResolvedValueOnce({
|
|
101
|
+
mockList.mockResolvedValueOnce({
|
|
102
|
+
pending: [{ requestId: "x", deviceId: "UNMATCHED" }],
|
|
103
|
+
paired: [],
|
|
104
|
+
});
|
|
96
105
|
|
|
97
106
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
98
107
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -67,21 +67,25 @@ export async function handleDeviceApprove(
|
|
|
67
67
|
if (pairedDevice) {
|
|
68
68
|
res.statusCode = 200;
|
|
69
69
|
res.setHeader("Content-Type", "application/json");
|
|
70
|
-
res.end(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
res.end(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
ok: true,
|
|
73
|
+
deviceId: normalizedDeviceId,
|
|
74
|
+
alreadyApproved: true,
|
|
75
|
+
approvedAtMs: (pairedDevice as any).approvedAtMs,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
76
78
|
return true;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
res.statusCode = 404;
|
|
80
82
|
res.setHeader("Content-Type", "application/json");
|
|
81
|
-
res.end(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
res.end(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
error: "No pending device found for this deviceId",
|
|
86
|
+
deviceId: normalizedDeviceId,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
85
89
|
return true;
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -95,10 +99,12 @@ export async function handleDeviceApprove(
|
|
|
95
99
|
log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
96
100
|
res.statusCode = 502;
|
|
97
101
|
res.setHeader("Content-Type", "application/json");
|
|
98
|
-
res.end(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
res.end(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
error: "Device approval failed",
|
|
105
|
+
detail: err instanceof Error ? err.message : "Unknown error",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
102
108
|
return true;
|
|
103
109
|
}
|
|
104
110
|
|
|
@@ -112,17 +118,23 @@ export async function handleDeviceApprove(
|
|
|
112
118
|
if (approved.status === "forbidden") {
|
|
113
119
|
res.statusCode = 403;
|
|
114
120
|
res.setHeader("Content-Type", "application/json");
|
|
115
|
-
res.end(
|
|
121
|
+
res.end(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
error: `Device approval forbidden: ${(approved as any).reason ?? "unknown"}`,
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
116
126
|
return true;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
res.statusCode = 200;
|
|
120
130
|
res.setHeader("Content-Type", "application/json");
|
|
121
|
-
res.end(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
res.end(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
ok: true,
|
|
134
|
+
deviceId: normalizedDeviceId,
|
|
135
|
+
requestId: approved.requestId,
|
|
136
|
+
approvedAtMs: (approved as any).device?.approvedAtMs,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
127
139
|
return true;
|
|
128
140
|
}
|
|
@@ -63,7 +63,10 @@ function tryDecodeURIComponent(segment: string): string | null {
|
|
|
63
63
|
*/
|
|
64
64
|
function contentDispositionInline(filename: string): string {
|
|
65
65
|
const base =
|
|
66
|
-
path
|
|
66
|
+
path
|
|
67
|
+
.basename(filename)
|
|
68
|
+
.replace(/[\r\n"]/g, "_")
|
|
69
|
+
.replace(/\\/g, "_") || "file";
|
|
67
70
|
const ascii = /^[\x20-\x7E]*$/.test(base) ? base : "file";
|
|
68
71
|
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base)}`;
|
|
69
72
|
}
|
|
@@ -82,9 +85,7 @@ function sendBuffer(
|
|
|
82
85
|
const disposition = contentDispositionInline(filename);
|
|
83
86
|
const rangeRaw = req.headers.range;
|
|
84
87
|
const range =
|
|
85
|
-
typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim())
|
|
86
|
-
? rangeRaw.trim()
|
|
87
|
-
: undefined;
|
|
88
|
+
typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim()) ? rangeRaw.trim() : undefined;
|
|
88
89
|
|
|
89
90
|
res.setHeader("Accept-Ranges", "bytes");
|
|
90
91
|
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
@@ -110,7 +111,7 @@ function sendBuffer(
|
|
|
110
111
|
let end = total - 1;
|
|
111
112
|
|
|
112
113
|
if (m[1] === "" && m[2] !== "") {
|
|
113
|
-
const suffixLen = parseInt(m[2]
|
|
114
|
+
const suffixLen = parseInt(m[2], 10);
|
|
114
115
|
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
|
|
115
116
|
res.statusCode = 200;
|
|
116
117
|
res.setHeader("Content-Length", String(total));
|
|
@@ -120,11 +121,11 @@ function sendBuffer(
|
|
|
120
121
|
start = Math.max(0, total - suffixLen);
|
|
121
122
|
end = total - 1;
|
|
122
123
|
} else if (m[1] !== "" && m[2] === "") {
|
|
123
|
-
start = parseInt(m[1]
|
|
124
|
+
start = parseInt(m[1], 10);
|
|
124
125
|
end = total - 1;
|
|
125
126
|
} else if (m[1] !== "" && m[2] !== "") {
|
|
126
|
-
start = parseInt(m[1]
|
|
127
|
-
end = parseInt(m[2]
|
|
127
|
+
start = parseInt(m[1], 10);
|
|
128
|
+
end = parseInt(m[2], 10);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
|
|
@@ -190,7 +191,13 @@ export async function handleFilesDownload(
|
|
|
190
191
|
// 1.2 Plugin-root attachments/ (survives gateway restarts; basename = URL token)
|
|
191
192
|
const fromAttachments = readAttachmentFileFromDisk(fileToken);
|
|
192
193
|
if (fromAttachments) {
|
|
193
|
-
sendBuffer(
|
|
194
|
+
sendBuffer(
|
|
195
|
+
req,
|
|
196
|
+
res,
|
|
197
|
+
fromAttachments.buffer,
|
|
198
|
+
fromAttachments.mimeType,
|
|
199
|
+
fromAttachments.filename,
|
|
200
|
+
);
|
|
194
201
|
return true;
|
|
195
202
|
}
|
|
196
203
|
|
|
@@ -211,10 +218,7 @@ export async function handleFilesDownload(
|
|
|
211
218
|
// fileId may include an extension (e.g. "uuid.png") — strip it to get the base id
|
|
212
219
|
const baseId = fileToken.replace(/\.[^.]+$/, "");
|
|
213
220
|
const mediaDir = path.join(os.homedir(), ".openclaw", "media", "inbound");
|
|
214
|
-
const candidates = [
|
|
215
|
-
path.join(mediaDir, baseId),
|
|
216
|
-
path.join(mediaDir, fileToken),
|
|
217
|
-
];
|
|
221
|
+
const candidates = [path.join(mediaDir, baseId), path.join(mediaDir, fileToken)];
|
|
218
222
|
|
|
219
223
|
for (const filePath of candidates) {
|
|
220
224
|
if (fs.existsSync(filePath)) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
clearFileIndexForTest,
|
|
7
|
+
fridayFilesPublicUrl,
|
|
8
|
+
readAttachmentFileFromDisk,
|
|
9
|
+
rememberInboundMediaName,
|
|
10
|
+
resolveMediaAttachment,
|
|
11
|
+
setAttachmentsDirForTest,
|
|
12
|
+
storeFile,
|
|
13
|
+
} from "./files.js";
|
|
14
|
+
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fn-files-"));
|
|
19
|
+
setAttachmentsDirForTest(tmpDir);
|
|
20
|
+
clearFileIndexForTest();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
setAttachmentsDirForTest(null);
|
|
25
|
+
clearFileIndexForTest();
|
|
26
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("attachment original filename survives a gateway restart", () => {
|
|
30
|
+
it("stores under a uuid token but recovers the original filename from disk", () => {
|
|
31
|
+
const stored = storeFile(
|
|
32
|
+
Buffer.from("%PDF-1.4 fake"),
|
|
33
|
+
"Quarterly Report.pdf",
|
|
34
|
+
"application/pdf",
|
|
35
|
+
);
|
|
36
|
+
expect(stored.urlToken).not.toBe("Quarterly Report.pdf");
|
|
37
|
+
|
|
38
|
+
const disk = readAttachmentFileFromDisk(stored.urlToken);
|
|
39
|
+
expect(disk).not.toBeNull();
|
|
40
|
+
// The display/download name is the original — not the on-disk uuid.
|
|
41
|
+
expect(disk!.filename).toBe("Quarterly Report.pdf");
|
|
42
|
+
expect(disk!.mimeType).toBe("application/pdf");
|
|
43
|
+
// The disk basename stays the uuid token so URLs keep pointing at the real file.
|
|
44
|
+
expect(disk!.diskName).toBe(stored.urlToken);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("resolveMediaAttachment keeps the original name after the in-memory index is cleared", () => {
|
|
48
|
+
const stored = storeFile(Buffer.from("notes body"), "meeting-notes.txt", "text/plain");
|
|
49
|
+
const url = `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
|
|
50
|
+
|
|
51
|
+
// Simulate a gateway restart: the in-memory index is gone, only disk remains.
|
|
52
|
+
clearFileIndexForTest();
|
|
53
|
+
|
|
54
|
+
const resolved = resolveMediaAttachment(url);
|
|
55
|
+
expect(resolved).not.toBeNull();
|
|
56
|
+
expect(resolved!.fileName).toBe("meeting-notes.txt");
|
|
57
|
+
expect(resolved!.url).toBe(url);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("fridayFilesPublicUrl still points at the on-disk token after a restart", () => {
|
|
61
|
+
const stored = storeFile(Buffer.from("x"), "doc.docx", "application/octet-stream");
|
|
62
|
+
clearFileIndexForTest();
|
|
63
|
+
|
|
64
|
+
const publicUrl = fridayFilesPublicUrl(
|
|
65
|
+
`/friday-next/files/${encodeURIComponent(stored.urlToken)}`,
|
|
66
|
+
);
|
|
67
|
+
expect(publicUrl).toBe(`/friday-next/files/${encodeURIComponent(stored.urlToken)}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("falls back to the on-disk basename for legacy files that have no sidecar", () => {
|
|
71
|
+
// A file stored before this fix: raw uuid token on disk, no .fnmeta sidecar.
|
|
72
|
+
const token = "11111111-2222-3333-4444-555555555555.pdf";
|
|
73
|
+
fs.writeFileSync(path.join(tmpDir, token), Buffer.from("legacy"));
|
|
74
|
+
|
|
75
|
+
const disk = readAttachmentFileFromDisk(token);
|
|
76
|
+
expect(disk).not.toBeNull();
|
|
77
|
+
expect(disk!.filename).toBe(token);
|
|
78
|
+
expect(disk!.diskName).toBe(token);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("never serves the sidecar file itself", () => {
|
|
82
|
+
const stored = storeFile(Buffer.from("y"), "report.pdf", "application/pdf");
|
|
83
|
+
expect(readAttachmentFileFromDisk(`${stored.urlToken}.fnmeta`)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("inbound user attachments (core media-store renames to a bare uuid)", () => {
|
|
88
|
+
it("recovers the original upload name from the remembered inbound sidecar on rebuild", () => {
|
|
89
|
+
// Core copies the upload to media/inbound/<uuid> with no extension and no name;
|
|
90
|
+
// the transcript records this path. We remembered the real name at send time.
|
|
91
|
+
const inboundUuid = "c950d280-55e5-4e06-aede-9f08653362a8";
|
|
92
|
+
const inboundPath = path.join(tmpDir, "..", "media", "inbound", inboundUuid);
|
|
93
|
+
fs.mkdirSync(path.dirname(inboundPath), { recursive: true });
|
|
94
|
+
fs.writeFileSync(inboundPath, Buffer.from("%PDF body"));
|
|
95
|
+
|
|
96
|
+
rememberInboundMediaName(inboundPath, "Project Brief.pdf", "application/pdf");
|
|
97
|
+
|
|
98
|
+
// History rebuild: resolveMediaAttachment is handed the raw inbound path.
|
|
99
|
+
const resolved = resolveMediaAttachment(inboundPath);
|
|
100
|
+
expect(resolved).not.toBeNull();
|
|
101
|
+
expect(resolved!.fileName).toBe("Project Brief.pdf");
|
|
102
|
+
// The copy carries the recovered extension so downloads open correctly.
|
|
103
|
+
expect(resolved!.url).toMatch(/\.pdf$/);
|
|
104
|
+
|
|
105
|
+
fs.rmSync(path.dirname(inboundPath), { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("falls back to the bare uuid when nothing was remembered (pre-fix messages)", () => {
|
|
109
|
+
const inboundUuid = "99999999-0000-1111-2222-333333333333";
|
|
110
|
+
const inboundPath = path.join(tmpDir, "..", "media", "inbound", inboundUuid);
|
|
111
|
+
fs.mkdirSync(path.dirname(inboundPath), { recursive: true });
|
|
112
|
+
fs.writeFileSync(inboundPath, Buffer.from("legacy"));
|
|
113
|
+
|
|
114
|
+
const resolved = resolveMediaAttachment(inboundPath);
|
|
115
|
+
expect(resolved).not.toBeNull();
|
|
116
|
+
expect(resolved!.fileName).toBe(inboundUuid);
|
|
117
|
+
|
|
118
|
+
fs.rmSync(path.dirname(inboundPath), { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -25,7 +25,9 @@ export function setAttachmentsDirForTest(dir: string | null): void {
|
|
|
25
25
|
/** Resolve `<historyDir>/../attachments`, mirroring the offline-queue layout. */
|
|
26
26
|
function resolveAttachmentsDir(): string {
|
|
27
27
|
try {
|
|
28
|
-
const cfg = resolveFridayNextConfig(
|
|
28
|
+
const cfg = resolveFridayNextConfig(
|
|
29
|
+
getHostOpenClawConfigSnapshot(getFridayNextRuntime().config),
|
|
30
|
+
);
|
|
29
31
|
return path.join(path.dirname(cfg.historyDir), "attachments");
|
|
30
32
|
} catch {
|
|
31
33
|
return path.join(os.homedir(), ".openclaw", "friday-next", "attachments");
|
|
@@ -72,6 +74,74 @@ function resolveStoredFile(key: string): StoredFile | undefined {
|
|
|
72
74
|
return fileIndex.get(key) ?? fileTokenIndex.get(key);
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/** Clear the in-memory file index. Test-only: simulates a gateway restart. */
|
|
78
|
+
export function clearFileIndexForTest(): void {
|
|
79
|
+
fileIndex.clear();
|
|
80
|
+
fileTokenIndex.clear();
|
|
81
|
+
externalFileSourceIndex.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The on-disk basename is a `<uuid>.<ext>` token, and the original filename lives only in
|
|
86
|
+
* the process-local `fileIndex`. To keep the real name after a gateway restart (when the
|
|
87
|
+
* index is empty) we persist it in a sidecar JSON file next to each stored attachment.
|
|
88
|
+
*/
|
|
89
|
+
interface AttachmentMetaSidecar {
|
|
90
|
+
filename: string;
|
|
91
|
+
mimeType: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const META_SIDECAR_SUFFIX = ".fnmeta";
|
|
95
|
+
|
|
96
|
+
function metaSidecarPath(urlToken: string): string {
|
|
97
|
+
return path.join(getAttachmentsDir(), `${urlToken}${META_SIDECAR_SUFFIX}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeAttachmentMetaSidecar(urlToken: string, filename: string, mimeType: string): void {
|
|
101
|
+
try {
|
|
102
|
+
fs.writeFileSync(metaSidecarPath(urlToken), JSON.stringify({ filename, mimeType }));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logger.warn(`writeAttachmentMetaSidecar failed for "${urlToken}": ${String(err)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remember the original upload filename for an inbound media file.
|
|
110
|
+
*
|
|
111
|
+
* When a user sends an attachment, core's media-store copies it to
|
|
112
|
+
* `~/.openclaw/media/inbound/<uuid>` — a bare uuid with no extension and no original
|
|
113
|
+
* name — and the transcript records THAT path. So on history rebuild the original name
|
|
114
|
+
* is unrecoverable. We stash it here (keyed by the inbound basename, reusing the sidecar
|
|
115
|
+
* scheme but inside our own attachments dir) at send time, while we still know it.
|
|
116
|
+
*/
|
|
117
|
+
export function rememberInboundMediaName(
|
|
118
|
+
inboundPath: string,
|
|
119
|
+
filename: string,
|
|
120
|
+
mimeType: string,
|
|
121
|
+
): void {
|
|
122
|
+
const key = path.basename(inboundPath);
|
|
123
|
+
const name = filename.trim();
|
|
124
|
+
if (!key || !name) return;
|
|
125
|
+
writeAttachmentMetaSidecar(key, name, mimeType);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readAttachmentMetaSidecar(urlToken: string): AttachmentMetaSidecar | null {
|
|
129
|
+
try {
|
|
130
|
+
const raw = fs.readFileSync(metaSidecarPath(urlToken), "utf8");
|
|
131
|
+
const parsed = JSON.parse(raw) as Partial<AttachmentMetaSidecar>;
|
|
132
|
+
if (parsed && typeof parsed.filename === "string" && parsed.filename) {
|
|
133
|
+
return {
|
|
134
|
+
filename: parsed.filename,
|
|
135
|
+
mimeType: typeof parsed.mimeType === "string" ? parsed.mimeType : "",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Missing or malformed sidecar (e.g. attachment stored before this fix) — caller
|
|
140
|
+
// falls back to the on-disk basename.
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
75
145
|
/**
|
|
76
146
|
* Read a file from `attachments/` by URL path token (disk basename).
|
|
77
147
|
* Used when the in-memory index was cleared after a gateway restart.
|
|
@@ -79,16 +149,26 @@ function resolveStoredFile(key: string): StoredFile | undefined {
|
|
|
79
149
|
export function readAttachmentFileFromDisk(fileToken: string): {
|
|
80
150
|
buffer: Buffer;
|
|
81
151
|
mimeType: string;
|
|
152
|
+
/** Original display/download filename (from sidecar; falls back to the on-disk basename). */
|
|
82
153
|
filename: string;
|
|
154
|
+
/** On-disk basename / urlToken — use this to build `/friday-next/files/{token}` URLs. */
|
|
155
|
+
diskName: string;
|
|
83
156
|
} | null {
|
|
84
157
|
const safe = path.basename(fileToken);
|
|
85
158
|
if (!safe || safe === "." || safe === "..") return null;
|
|
159
|
+
if (safe.endsWith(META_SIDECAR_SUFFIX)) return null;
|
|
86
160
|
const dir = getAttachmentsDir();
|
|
87
161
|
const full = path.join(dir, safe);
|
|
88
162
|
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return null;
|
|
89
163
|
try {
|
|
90
164
|
const buffer = fs.readFileSync(full);
|
|
91
|
-
|
|
165
|
+
const meta = readAttachmentMetaSidecar(safe);
|
|
166
|
+
return {
|
|
167
|
+
buffer,
|
|
168
|
+
mimeType: meta?.mimeType || guessMimeType(safe),
|
|
169
|
+
filename: meta?.filename || safe,
|
|
170
|
+
diskName: safe,
|
|
171
|
+
};
|
|
92
172
|
} catch {
|
|
93
173
|
return null;
|
|
94
174
|
}
|
|
@@ -117,14 +197,20 @@ export function normalizeAgentMediaPath(raw: string): string {
|
|
|
117
197
|
return s;
|
|
118
198
|
}
|
|
119
199
|
|
|
120
|
-
function copyLocalFileToAttachments(
|
|
200
|
+
function copyLocalFileToAttachments(
|
|
201
|
+
sourcePath: string,
|
|
202
|
+
originalFilename?: string,
|
|
203
|
+
): StoredFile | null {
|
|
121
204
|
const resolvedPath = normalizeAgentMediaPath(sourcePath);
|
|
122
|
-
const
|
|
205
|
+
const diskBasename = path.basename(resolvedPath);
|
|
206
|
+
// Prefer the caller-supplied original name (recovered from an inbound sidecar); fall
|
|
207
|
+
// back to the on-disk basename (which for core inbound media is a bare uuid).
|
|
208
|
+
const filename = originalFilename?.trim() || diskBasename;
|
|
123
209
|
if (!filename) return null;
|
|
124
210
|
try {
|
|
125
211
|
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) return null;
|
|
126
212
|
const id = crypto.randomUUID();
|
|
127
|
-
const ext = path.extname(filename);
|
|
213
|
+
const ext = path.extname(filename) || path.extname(diskBasename);
|
|
128
214
|
const urlToken = ext ? `${id}${ext}` : id;
|
|
129
215
|
const storedPath = path.join(getAttachmentsDir(), urlToken);
|
|
130
216
|
try {
|
|
@@ -134,7 +220,9 @@ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
|
|
|
134
220
|
// Fallback to read+write so attachment persistence still works.
|
|
135
221
|
const raw = fs.readFileSync(resolvedPath);
|
|
136
222
|
fs.writeFileSync(storedPath, raw);
|
|
137
|
-
logger.warn(
|
|
223
|
+
logger.warn(
|
|
224
|
+
`copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`,
|
|
225
|
+
);
|
|
138
226
|
}
|
|
139
227
|
const stat = fs.statSync(storedPath);
|
|
140
228
|
const mimeType = guessMimeType(filename);
|
|
@@ -148,6 +236,7 @@ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
|
|
|
148
236
|
createdAt: Date.now(),
|
|
149
237
|
};
|
|
150
238
|
registerStoredFile(file);
|
|
239
|
+
writeAttachmentMetaSidecar(urlToken, filename, mimeType);
|
|
151
240
|
return file;
|
|
152
241
|
} catch (err) {
|
|
153
242
|
logger.error(`copyLocalFileToAttachments failed for "${resolvedPath}": ${String(err)}`);
|
|
@@ -168,7 +257,7 @@ export function storeFile(buffer: Buffer, filename: string, mimeType: string): S
|
|
|
168
257
|
try {
|
|
169
258
|
fs.writeFileSync(storedPath, buffer);
|
|
170
259
|
} catch (err) {
|
|
171
|
-
throw new Error(`Failed to store file: ${String(err)}
|
|
260
|
+
throw new Error(`Failed to store file: ${String(err)}`, { cause: err });
|
|
172
261
|
}
|
|
173
262
|
|
|
174
263
|
const file: StoredFile = {
|
|
@@ -182,6 +271,7 @@ export function storeFile(buffer: Buffer, filename: string, mimeType: string): S
|
|
|
182
271
|
};
|
|
183
272
|
|
|
184
273
|
registerStoredFile(file);
|
|
274
|
+
writeAttachmentMetaSidecar(urlToken, safeFilename, mimeType);
|
|
185
275
|
return file;
|
|
186
276
|
}
|
|
187
277
|
|
|
@@ -218,7 +308,7 @@ export function fridayFilesPublicUrl(ref: string): string {
|
|
|
218
308
|
|
|
219
309
|
const disk = readAttachmentFileFromDisk(lookupKey);
|
|
220
310
|
if (disk) {
|
|
221
|
-
return `/friday-next/files/${encodeURIComponent(disk.
|
|
311
|
+
return `/friday-next/files/${encodeURIComponent(disk.diskName)}`;
|
|
222
312
|
}
|
|
223
313
|
|
|
224
314
|
const trimmed = ref.trim();
|
|
@@ -235,13 +325,17 @@ export function getExternalFileSourceByUrlToken(token: string): string | undefin
|
|
|
235
325
|
/**
|
|
236
326
|
* Read a file as a Buffer with its MIME type (by id or urlToken).
|
|
237
327
|
*/
|
|
238
|
-
export function readFile(id: string): {
|
|
328
|
+
export function readFile(id: string): {
|
|
329
|
+
buffer: Buffer | null;
|
|
330
|
+
mimeType: string;
|
|
331
|
+
filename?: string;
|
|
332
|
+
} {
|
|
239
333
|
const file = resolveStoredFile(id);
|
|
240
334
|
if (!file) return { buffer: null, mimeType: "application/octet-stream" };
|
|
241
335
|
try {
|
|
242
|
-
return { buffer: fs.readFileSync(file.path), mimeType: file.mimeType };
|
|
336
|
+
return { buffer: fs.readFileSync(file.path), mimeType: file.mimeType, filename: file.filename };
|
|
243
337
|
} catch {
|
|
244
|
-
return { buffer: null, mimeType: file.mimeType };
|
|
338
|
+
return { buffer: null, mimeType: file.mimeType, filename: file.filename };
|
|
245
339
|
}
|
|
246
340
|
}
|
|
247
341
|
|
|
@@ -294,19 +388,24 @@ export function resolveMediaAttachment(localPath: string): ResolvedAttachment |
|
|
|
294
388
|
return { fileName: fallback, url: localPath };
|
|
295
389
|
}
|
|
296
390
|
|
|
297
|
-
const
|
|
298
|
-
if (!
|
|
391
|
+
const basename = path.basename(localPath);
|
|
392
|
+
if (!basename) return null;
|
|
299
393
|
|
|
300
|
-
|
|
394
|
+
// Core inbound media is stored as a bare uuid (no name/extension). Recover the original
|
|
395
|
+
// upload name we stashed at send time, keyed by that uuid basename.
|
|
396
|
+
const remembered = readAttachmentMetaSidecar(basename)?.filename;
|
|
397
|
+
const originalName = remembered || basename;
|
|
398
|
+
|
|
399
|
+
const stored = copyLocalFileToAttachments(localPath, originalName);
|
|
301
400
|
if (!stored) {
|
|
302
401
|
// Best-effort fallback: still return a Friday URL so app can receive attachment event.
|
|
303
402
|
// Download handler will try reading external source path lazily by token.
|
|
304
403
|
const id = crypto.randomUUID();
|
|
305
|
-
const ext = path.extname(
|
|
404
|
+
const ext = path.extname(originalName);
|
|
306
405
|
const token = ext ? `${id}${ext}` : id;
|
|
307
406
|
externalFileSourceIndex.set(token, normalizeAgentMediaPath(localPath));
|
|
308
407
|
return {
|
|
309
|
-
fileName:
|
|
408
|
+
fileName: originalName,
|
|
310
409
|
url: `/friday-next/files/${encodeURIComponent(token)}`,
|
|
311
410
|
};
|
|
312
411
|
}
|
|
@@ -316,7 +415,6 @@ export function resolveMediaAttachment(localPath: string): ResolvedAttachment |
|
|
|
316
415
|
};
|
|
317
416
|
}
|
|
318
417
|
|
|
319
|
-
|
|
320
418
|
/**
|
|
321
419
|
* Guess MIME type from filename extension.
|
|
322
420
|
*/
|
|
@@ -23,7 +23,11 @@ class MockRes extends EventEmitter {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function mockReq(
|
|
26
|
+
function mockReq(
|
|
27
|
+
method: string,
|
|
28
|
+
url: string,
|
|
29
|
+
headers: Record<string, string> = {},
|
|
30
|
+
): IncomingMessage {
|
|
27
31
|
return { method, url, headers } as IncomingMessage;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -82,7 +86,17 @@ describe("handleHealth", () => {
|
|
|
82
86
|
{
|
|
83
87
|
nodeId: NODE_ID,
|
|
84
88
|
caps: ["location", "canvas"],
|
|
85
|
-
commands: [
|
|
89
|
+
commands: [
|
|
90
|
+
"location.get",
|
|
91
|
+
"canvas.present",
|
|
92
|
+
"canvas.hide",
|
|
93
|
+
"canvas.navigate",
|
|
94
|
+
"canvas.eval",
|
|
95
|
+
"canvas.snapshot",
|
|
96
|
+
"canvas.a2ui.push",
|
|
97
|
+
"canvas.a2ui.pushJSONL",
|
|
98
|
+
"canvas.a2ui.reset",
|
|
99
|
+
],
|
|
86
100
|
},
|
|
87
101
|
],
|
|
88
102
|
});
|
|
@@ -104,9 +118,7 @@ describe("handleHealth", () => {
|
|
|
104
118
|
it("returns degraded when node is missing required caps", async () => {
|
|
105
119
|
mockListNodePairing.mockResolvedValueOnce({
|
|
106
120
|
pending: [],
|
|
107
|
-
paired: [
|
|
108
|
-
{ nodeId: NODE_ID, caps: ["canvas"], commands: ["canvas.present"] },
|
|
109
|
-
],
|
|
121
|
+
paired: [{ nodeId: NODE_ID, caps: ["canvas"], commands: ["canvas.present"] }],
|
|
110
122
|
});
|
|
111
123
|
|
|
112
124
|
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
|
|
@@ -127,7 +139,10 @@ describe("handleHealth", () => {
|
|
|
127
139
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
128
140
|
paired: [],
|
|
129
141
|
});
|
|
130
|
-
mockApproveNodePairing.mockResolvedValueOnce({
|
|
142
|
+
mockApproveNodePairing.mockResolvedValueOnce({
|
|
143
|
+
requestId: REQUEST_ID,
|
|
144
|
+
node: { nodeId: NODE_ID },
|
|
145
|
+
});
|
|
131
146
|
|
|
132
147
|
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
133
148
|
authorization: "Bearer test-token",
|
|
@@ -186,7 +201,10 @@ describe("handleHealth", () => {
|
|
|
186
201
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
187
202
|
paired: [],
|
|
188
203
|
});
|
|
189
|
-
mockApproveNodePairing.mockResolvedValueOnce({
|
|
204
|
+
mockApproveNodePairing.mockResolvedValueOnce({
|
|
205
|
+
status: "forbidden",
|
|
206
|
+
missingScope: "operator.admin",
|
|
207
|
+
});
|
|
190
208
|
|
|
191
209
|
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
192
210
|
authorization: "Bearer test-token",
|
|
@@ -236,14 +254,28 @@ describe("handleHealth", () => {
|
|
|
236
254
|
{
|
|
237
255
|
nodeId: NODE_ID,
|
|
238
256
|
caps: ["location", "canvas"],
|
|
239
|
-
commands: [
|
|
257
|
+
commands: [
|
|
258
|
+
"location.get",
|
|
259
|
+
"canvas.present",
|
|
260
|
+
"canvas.hide",
|
|
261
|
+
"canvas.navigate",
|
|
262
|
+
"canvas.eval",
|
|
263
|
+
"canvas.snapshot",
|
|
264
|
+
"canvas.a2ui.push",
|
|
265
|
+
"canvas.a2ui.pushJSONL",
|
|
266
|
+
"canvas.a2ui.reset",
|
|
267
|
+
],
|
|
240
268
|
},
|
|
241
269
|
],
|
|
242
270
|
});
|
|
243
271
|
|
|
244
|
-
const req = mockReq(
|
|
245
|
-
|
|
246
|
-
|
|
272
|
+
const req = mockReq(
|
|
273
|
+
"GET",
|
|
274
|
+
`/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`,
|
|
275
|
+
{
|
|
276
|
+
authorization: "Bearer test-token",
|
|
277
|
+
},
|
|
278
|
+
);
|
|
247
279
|
const res = new MockRes() as unknown as ServerResponse;
|
|
248
280
|
await handleHealth(req, res);
|
|
249
281
|
const body = JSON.parse((res as unknown as MockRes).body);
|