@syengup/friday-channel-next 0.1.30 → 0.1.36
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/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- 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 +45 -14
- package/dist/src/channel.js +22 -1
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +182 -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 +12 -6
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +80 -12
- package/dist/src/http/handlers/messages.js +8 -3
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +8 -0
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +23 -0
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/skills-discovery.d.ts +58 -0
- package/dist/src/skills-discovery.js +247 -0
- 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 +192 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/src/agent/abort-run.ts +24 -8
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +23 -1
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/channel-actions.test.ts +47 -0
- package/src/channel-actions.ts +38 -14
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.ts +23 -1
- package/src/http/handlers/agent-config.test.ts +205 -0
- package/src/http/handlers/agent-config.ts +218 -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.ts +1 -22
- package/src/http/handlers/cancel.test.ts +12 -2
- package/src/http/handlers/cancel.ts +12 -6
- package/src/http/handlers/files.test.ts +114 -0
- package/src/http/handlers/files.ts +97 -13
- package/src/http/handlers/messages.ts +7 -2
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +12 -0
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/server.ts +24 -0
- package/src/link-preview/ssrf-guard.test.ts +7 -2
- package/src/link-preview/ssrf-guard.ts +5 -1
- package/src/media-fetch.test.ts +1 -1
- package/src/media-fetch.ts +4 -1
- package/src/openclaw.d.ts +25 -1
- package/src/skills-discovery.test.ts +148 -0
- package/src/skills-discovery.ts +248 -0
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +68 -0
- package/src/tool-catalog.ts +252 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
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(Buffer.from("%PDF-1.4 fake"), "Quarterly Report.pdf", "application/pdf");
|
|
32
|
+
expect(stored.urlToken).not.toBe("Quarterly Report.pdf");
|
|
33
|
+
|
|
34
|
+
const disk = readAttachmentFileFromDisk(stored.urlToken);
|
|
35
|
+
expect(disk).not.toBeNull();
|
|
36
|
+
// The display/download name is the original — not the on-disk uuid.
|
|
37
|
+
expect(disk!.filename).toBe("Quarterly Report.pdf");
|
|
38
|
+
expect(disk!.mimeType).toBe("application/pdf");
|
|
39
|
+
// The disk basename stays the uuid token so URLs keep pointing at the real file.
|
|
40
|
+
expect(disk!.diskName).toBe(stored.urlToken);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("resolveMediaAttachment keeps the original name after the in-memory index is cleared", () => {
|
|
44
|
+
const stored = storeFile(Buffer.from("notes body"), "meeting-notes.txt", "text/plain");
|
|
45
|
+
const url = `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
|
|
46
|
+
|
|
47
|
+
// Simulate a gateway restart: the in-memory index is gone, only disk remains.
|
|
48
|
+
clearFileIndexForTest();
|
|
49
|
+
|
|
50
|
+
const resolved = resolveMediaAttachment(url);
|
|
51
|
+
expect(resolved).not.toBeNull();
|
|
52
|
+
expect(resolved!.fileName).toBe("meeting-notes.txt");
|
|
53
|
+
expect(resolved!.url).toBe(url);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("fridayFilesPublicUrl still points at the on-disk token after a restart", () => {
|
|
57
|
+
const stored = storeFile(Buffer.from("x"), "doc.docx", "application/octet-stream");
|
|
58
|
+
clearFileIndexForTest();
|
|
59
|
+
|
|
60
|
+
const publicUrl = fridayFilesPublicUrl(`/friday-next/files/${encodeURIComponent(stored.urlToken)}`);
|
|
61
|
+
expect(publicUrl).toBe(`/friday-next/files/${encodeURIComponent(stored.urlToken)}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("falls back to the on-disk basename for legacy files that have no sidecar", () => {
|
|
65
|
+
// A file stored before this fix: raw uuid token on disk, no .fnmeta sidecar.
|
|
66
|
+
const token = "11111111-2222-3333-4444-555555555555.pdf";
|
|
67
|
+
fs.writeFileSync(path.join(tmpDir, token), Buffer.from("legacy"));
|
|
68
|
+
|
|
69
|
+
const disk = readAttachmentFileFromDisk(token);
|
|
70
|
+
expect(disk).not.toBeNull();
|
|
71
|
+
expect(disk!.filename).toBe(token);
|
|
72
|
+
expect(disk!.diskName).toBe(token);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("never serves the sidecar file itself", () => {
|
|
76
|
+
const stored = storeFile(Buffer.from("y"), "report.pdf", "application/pdf");
|
|
77
|
+
expect(readAttachmentFileFromDisk(`${stored.urlToken}.fnmeta`)).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("inbound user attachments (core media-store renames to a bare uuid)", () => {
|
|
82
|
+
it("recovers the original upload name from the remembered inbound sidecar on rebuild", () => {
|
|
83
|
+
// Core copies the upload to media/inbound/<uuid> with no extension and no name;
|
|
84
|
+
// the transcript records this path. We remembered the real name at send time.
|
|
85
|
+
const inboundUuid = "c950d280-55e5-4e06-aede-9f08653362a8";
|
|
86
|
+
const inboundPath = path.join(tmpDir, "..", "media", "inbound", inboundUuid);
|
|
87
|
+
fs.mkdirSync(path.dirname(inboundPath), { recursive: true });
|
|
88
|
+
fs.writeFileSync(inboundPath, Buffer.from("%PDF body"));
|
|
89
|
+
|
|
90
|
+
rememberInboundMediaName(inboundPath, "Project Brief.pdf", "application/pdf");
|
|
91
|
+
|
|
92
|
+
// History rebuild: resolveMediaAttachment is handed the raw inbound path.
|
|
93
|
+
const resolved = resolveMediaAttachment(inboundPath);
|
|
94
|
+
expect(resolved).not.toBeNull();
|
|
95
|
+
expect(resolved!.fileName).toBe("Project Brief.pdf");
|
|
96
|
+
// The copy carries the recovered extension so downloads open correctly.
|
|
97
|
+
expect(resolved!.url).toMatch(/\.pdf$/);
|
|
98
|
+
|
|
99
|
+
fs.rmSync(path.dirname(inboundPath), { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("falls back to the bare uuid when nothing was remembered (pre-fix messages)", () => {
|
|
103
|
+
const inboundUuid = "99999999-0000-1111-2222-333333333333";
|
|
104
|
+
const inboundPath = path.join(tmpDir, "..", "media", "inbound", inboundUuid);
|
|
105
|
+
fs.mkdirSync(path.dirname(inboundPath), { recursive: true });
|
|
106
|
+
fs.writeFileSync(inboundPath, Buffer.from("legacy"));
|
|
107
|
+
|
|
108
|
+
const resolved = resolveMediaAttachment(inboundPath);
|
|
109
|
+
expect(resolved).not.toBeNull();
|
|
110
|
+
expect(resolved!.fileName).toBe(inboundUuid);
|
|
111
|
+
|
|
112
|
+
fs.rmSync(path.dirname(inboundPath), { recursive: true, force: true });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -72,6 +72,70 @@ function resolveStoredFile(key: string): StoredFile | undefined {
|
|
|
72
72
|
return fileIndex.get(key) ?? fileTokenIndex.get(key);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Clear the in-memory file index. Test-only: simulates a gateway restart. */
|
|
76
|
+
export function clearFileIndexForTest(): void {
|
|
77
|
+
fileIndex.clear();
|
|
78
|
+
fileTokenIndex.clear();
|
|
79
|
+
externalFileSourceIndex.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The on-disk basename is a `<uuid>.<ext>` token, and the original filename lives only in
|
|
84
|
+
* the process-local `fileIndex`. To keep the real name after a gateway restart (when the
|
|
85
|
+
* index is empty) we persist it in a sidecar JSON file next to each stored attachment.
|
|
86
|
+
*/
|
|
87
|
+
interface AttachmentMetaSidecar {
|
|
88
|
+
filename: string;
|
|
89
|
+
mimeType: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const META_SIDECAR_SUFFIX = ".fnmeta";
|
|
93
|
+
|
|
94
|
+
function metaSidecarPath(urlToken: string): string {
|
|
95
|
+
return path.join(getAttachmentsDir(), `${urlToken}${META_SIDECAR_SUFFIX}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeAttachmentMetaSidecar(urlToken: string, filename: string, mimeType: string): void {
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(metaSidecarPath(urlToken), JSON.stringify({ filename, mimeType }));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.warn(`writeAttachmentMetaSidecar failed for "${urlToken}": ${String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remember the original upload filename for an inbound media file.
|
|
108
|
+
*
|
|
109
|
+
* When a user sends an attachment, core's media-store copies it to
|
|
110
|
+
* `~/.openclaw/media/inbound/<uuid>` — a bare uuid with no extension and no original
|
|
111
|
+
* name — and the transcript records THAT path. So on history rebuild the original name
|
|
112
|
+
* is unrecoverable. We stash it here (keyed by the inbound basename, reusing the sidecar
|
|
113
|
+
* scheme but inside our own attachments dir) at send time, while we still know it.
|
|
114
|
+
*/
|
|
115
|
+
export function rememberInboundMediaName(inboundPath: string, filename: string, mimeType: string): void {
|
|
116
|
+
const key = path.basename(inboundPath);
|
|
117
|
+
const name = filename.trim();
|
|
118
|
+
if (!key || !name) return;
|
|
119
|
+
writeAttachmentMetaSidecar(key, name, mimeType);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readAttachmentMetaSidecar(urlToken: string): AttachmentMetaSidecar | null {
|
|
123
|
+
try {
|
|
124
|
+
const raw = fs.readFileSync(metaSidecarPath(urlToken), "utf8");
|
|
125
|
+
const parsed = JSON.parse(raw) as Partial<AttachmentMetaSidecar>;
|
|
126
|
+
if (parsed && typeof parsed.filename === "string" && parsed.filename) {
|
|
127
|
+
return {
|
|
128
|
+
filename: parsed.filename,
|
|
129
|
+
mimeType: typeof parsed.mimeType === "string" ? parsed.mimeType : "",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Missing or malformed sidecar (e.g. attachment stored before this fix) — caller
|
|
134
|
+
// falls back to the on-disk basename.
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
75
139
|
/**
|
|
76
140
|
* Read a file from `attachments/` by URL path token (disk basename).
|
|
77
141
|
* Used when the in-memory index was cleared after a gateway restart.
|
|
@@ -79,16 +143,26 @@ function resolveStoredFile(key: string): StoredFile | undefined {
|
|
|
79
143
|
export function readAttachmentFileFromDisk(fileToken: string): {
|
|
80
144
|
buffer: Buffer;
|
|
81
145
|
mimeType: string;
|
|
146
|
+
/** Original display/download filename (from sidecar; falls back to the on-disk basename). */
|
|
82
147
|
filename: string;
|
|
148
|
+
/** On-disk basename / urlToken — use this to build `/friday-next/files/{token}` URLs. */
|
|
149
|
+
diskName: string;
|
|
83
150
|
} | null {
|
|
84
151
|
const safe = path.basename(fileToken);
|
|
85
152
|
if (!safe || safe === "." || safe === "..") return null;
|
|
153
|
+
if (safe.endsWith(META_SIDECAR_SUFFIX)) return null;
|
|
86
154
|
const dir = getAttachmentsDir();
|
|
87
155
|
const full = path.join(dir, safe);
|
|
88
156
|
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return null;
|
|
89
157
|
try {
|
|
90
158
|
const buffer = fs.readFileSync(full);
|
|
91
|
-
|
|
159
|
+
const meta = readAttachmentMetaSidecar(safe);
|
|
160
|
+
return {
|
|
161
|
+
buffer,
|
|
162
|
+
mimeType: meta?.mimeType || guessMimeType(safe),
|
|
163
|
+
filename: meta?.filename || safe,
|
|
164
|
+
diskName: safe,
|
|
165
|
+
};
|
|
92
166
|
} catch {
|
|
93
167
|
return null;
|
|
94
168
|
}
|
|
@@ -117,14 +191,17 @@ export function normalizeAgentMediaPath(raw: string): string {
|
|
|
117
191
|
return s;
|
|
118
192
|
}
|
|
119
193
|
|
|
120
|
-
function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
|
|
194
|
+
function copyLocalFileToAttachments(sourcePath: string, originalFilename?: string): StoredFile | null {
|
|
121
195
|
const resolvedPath = normalizeAgentMediaPath(sourcePath);
|
|
122
|
-
const
|
|
196
|
+
const diskBasename = path.basename(resolvedPath);
|
|
197
|
+
// Prefer the caller-supplied original name (recovered from an inbound sidecar); fall
|
|
198
|
+
// back to the on-disk basename (which for core inbound media is a bare uuid).
|
|
199
|
+
const filename = originalFilename?.trim() || diskBasename;
|
|
123
200
|
if (!filename) return null;
|
|
124
201
|
try {
|
|
125
202
|
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) return null;
|
|
126
203
|
const id = crypto.randomUUID();
|
|
127
|
-
const ext = path.extname(filename);
|
|
204
|
+
const ext = path.extname(filename) || path.extname(diskBasename);
|
|
128
205
|
const urlToken = ext ? `${id}${ext}` : id;
|
|
129
206
|
const storedPath = path.join(getAttachmentsDir(), urlToken);
|
|
130
207
|
try {
|
|
@@ -148,6 +225,7 @@ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
|
|
|
148
225
|
createdAt: Date.now(),
|
|
149
226
|
};
|
|
150
227
|
registerStoredFile(file);
|
|
228
|
+
writeAttachmentMetaSidecar(urlToken, filename, mimeType);
|
|
151
229
|
return file;
|
|
152
230
|
} catch (err) {
|
|
153
231
|
logger.error(`copyLocalFileToAttachments failed for "${resolvedPath}": ${String(err)}`);
|
|
@@ -182,6 +260,7 @@ export function storeFile(buffer: Buffer, filename: string, mimeType: string): S
|
|
|
182
260
|
};
|
|
183
261
|
|
|
184
262
|
registerStoredFile(file);
|
|
263
|
+
writeAttachmentMetaSidecar(urlToken, safeFilename, mimeType);
|
|
185
264
|
return file;
|
|
186
265
|
}
|
|
187
266
|
|
|
@@ -218,7 +297,7 @@ export function fridayFilesPublicUrl(ref: string): string {
|
|
|
218
297
|
|
|
219
298
|
const disk = readAttachmentFileFromDisk(lookupKey);
|
|
220
299
|
if (disk) {
|
|
221
|
-
return `/friday-next/files/${encodeURIComponent(disk.
|
|
300
|
+
return `/friday-next/files/${encodeURIComponent(disk.diskName)}`;
|
|
222
301
|
}
|
|
223
302
|
|
|
224
303
|
const trimmed = ref.trim();
|
|
@@ -235,13 +314,13 @@ export function getExternalFileSourceByUrlToken(token: string): string | undefin
|
|
|
235
314
|
/**
|
|
236
315
|
* Read a file as a Buffer with its MIME type (by id or urlToken).
|
|
237
316
|
*/
|
|
238
|
-
export function readFile(id: string): { buffer: Buffer | null; mimeType: string } {
|
|
317
|
+
export function readFile(id: string): { buffer: Buffer | null; mimeType: string; filename?: string } {
|
|
239
318
|
const file = resolveStoredFile(id);
|
|
240
319
|
if (!file) return { buffer: null, mimeType: "application/octet-stream" };
|
|
241
320
|
try {
|
|
242
|
-
return { buffer: fs.readFileSync(file.path), mimeType: file.mimeType };
|
|
321
|
+
return { buffer: fs.readFileSync(file.path), mimeType: file.mimeType, filename: file.filename };
|
|
243
322
|
} catch {
|
|
244
|
-
return { buffer: null, mimeType: file.mimeType };
|
|
323
|
+
return { buffer: null, mimeType: file.mimeType, filename: file.filename };
|
|
245
324
|
}
|
|
246
325
|
}
|
|
247
326
|
|
|
@@ -294,19 +373,24 @@ export function resolveMediaAttachment(localPath: string): ResolvedAttachment |
|
|
|
294
373
|
return { fileName: fallback, url: localPath };
|
|
295
374
|
}
|
|
296
375
|
|
|
297
|
-
const
|
|
298
|
-
if (!
|
|
376
|
+
const basename = path.basename(localPath);
|
|
377
|
+
if (!basename) return null;
|
|
299
378
|
|
|
300
|
-
|
|
379
|
+
// Core inbound media is stored as a bare uuid (no name/extension). Recover the original
|
|
380
|
+
// upload name we stashed at send time, keyed by that uuid basename.
|
|
381
|
+
const remembered = readAttachmentMetaSidecar(basename)?.filename;
|
|
382
|
+
const originalName = remembered || basename;
|
|
383
|
+
|
|
384
|
+
const stored = copyLocalFileToAttachments(localPath, originalName);
|
|
301
385
|
if (!stored) {
|
|
302
386
|
// Best-effort fallback: still return a Friday URL so app can receive attachment event.
|
|
303
387
|
// Download handler will try reading external source path lazily by token.
|
|
304
388
|
const id = crypto.randomUUID();
|
|
305
|
-
const ext = path.extname(
|
|
389
|
+
const ext = path.extname(originalName);
|
|
306
390
|
const token = ext ? `${id}${ext}` : id;
|
|
307
391
|
externalFileSourceIndex.set(token, normalizeAgentMediaPath(localPath));
|
|
308
392
|
return {
|
|
309
|
-
fileName:
|
|
393
|
+
fileName: originalName,
|
|
310
394
|
url: `/friday-next/files/${encodeURIComponent(token)}`,
|
|
311
395
|
};
|
|
312
396
|
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
fridayAttachmentLookupKey,
|
|
44
44
|
fridayFilesPublicUrl,
|
|
45
45
|
readFile,
|
|
46
|
+
rememberInboundMediaName,
|
|
46
47
|
resolveMediaAttachment,
|
|
47
48
|
resolveMediaUrl,
|
|
48
49
|
} from "./files.js";
|
|
@@ -377,11 +378,15 @@ async function buildBodyForAgentWithAttachments(text: string, attachmentIds: str
|
|
|
377
378
|
|
|
378
379
|
const mediaRefs: string[] = [];
|
|
379
380
|
for (const id of attachmentIds) {
|
|
380
|
-
const { buffer, mimeType } = readFile(fridayAttachmentLookupKey(id));
|
|
381
|
+
const { buffer, mimeType, filename } = readFile(fridayAttachmentLookupKey(id));
|
|
381
382
|
if (!buffer) continue;
|
|
382
383
|
|
|
383
|
-
const saved = await saveInboundMediaBuffer(buffer, mimeType);
|
|
384
|
+
const saved = await saveInboundMediaBuffer(buffer, mimeType, filename);
|
|
384
385
|
if (saved.id && saved.path) {
|
|
386
|
+
// Core's media-store renames inbound files to a bare uuid (no name/extension) and
|
|
387
|
+
// the transcript records that path — stash the original name now so history rebuild
|
|
388
|
+
// can restore it instead of surfacing the uuid.
|
|
389
|
+
if (filename) rememberInboundMediaName(saved.path, filename, mimeType);
|
|
385
390
|
mediaRefs.push(`[media attached: file://${saved.path}]`);
|
|
386
391
|
}
|
|
387
392
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { handleModelsList } from "./models-list.js";
|
|
4
|
+
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
5
|
+
import {
|
|
6
|
+
setFridayAgentForwardRuntime,
|
|
7
|
+
resetFridayAgentForwardRuntimeForTest,
|
|
8
|
+
} from "../../agent-forward-runtime.js";
|
|
9
|
+
|
|
10
|
+
class MockRes extends EventEmitter {
|
|
11
|
+
statusCode = 0;
|
|
12
|
+
headers: Record<string, string> = {};
|
|
13
|
+
body = "";
|
|
14
|
+
setHeader(name: string, value: string): void {
|
|
15
|
+
this.headers[name.toLowerCase()] = value;
|
|
16
|
+
}
|
|
17
|
+
end(body?: string): void {
|
|
18
|
+
if (body) this.body += body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeReq(headers: Record<string, string> = {}, method = "GET"): any {
|
|
23
|
+
return { method, url: "/friday-next/models", headers };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const AUTH = { authorization: "Bearer test-token" };
|
|
27
|
+
|
|
28
|
+
/** Inject config + an optional per-model thinking-policy resolver into the forward runtime. */
|
|
29
|
+
function setRuntime(
|
|
30
|
+
config: unknown,
|
|
31
|
+
resolveThinkingPolicy?: (params: { provider?: string | null; model?: string | null }) => {
|
|
32
|
+
levels: Array<{ id: string; label: string }>;
|
|
33
|
+
defaultLevel?: string | null;
|
|
34
|
+
},
|
|
35
|
+
): void {
|
|
36
|
+
setFridayAgentForwardRuntime({
|
|
37
|
+
runtime: {
|
|
38
|
+
agent: {
|
|
39
|
+
session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
|
|
40
|
+
...(resolveThinkingPolicy ? { resolveThinkingPolicy } : {}),
|
|
41
|
+
},
|
|
42
|
+
config: { current: () => config },
|
|
43
|
+
},
|
|
44
|
+
} as never);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CONFIG = {
|
|
48
|
+
models: {
|
|
49
|
+
providers: {
|
|
50
|
+
openai: { models: [{ id: "gpt-5.4", name: "GPT-5.4", reasoning: true }] },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
agents: { defaults: { models: { "openai/gpt-5.4": {} }, model: "openai/gpt-5.4" } },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("handleModelsList thinking levels", () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
setMockRuntime();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("attaches the per-model thinking levels + default resolved from the runtime", async () => {
|
|
66
|
+
setRuntime(CONFIG, ({ provider, model }) => {
|
|
67
|
+
expect(provider).toBe("openai");
|
|
68
|
+
expect(model).toBe("gpt-5.4");
|
|
69
|
+
return {
|
|
70
|
+
levels: [
|
|
71
|
+
{ id: "off", label: "off" },
|
|
72
|
+
{ id: "low", label: "low" },
|
|
73
|
+
{ id: "medium", label: "medium" },
|
|
74
|
+
{ id: "high", label: "high" },
|
|
75
|
+
{ id: "xhigh", label: "xhigh" },
|
|
76
|
+
],
|
|
77
|
+
defaultLevel: "high",
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const res = new MockRes();
|
|
82
|
+
await handleModelsList(makeReq(AUTH), res as any);
|
|
83
|
+
|
|
84
|
+
expect(res.statusCode).toBe(200);
|
|
85
|
+
const body = JSON.parse(res.body);
|
|
86
|
+
const model = body.models.find((m: any) => m.id === "openai/gpt-5.4");
|
|
87
|
+
expect(model.thinkingLevels.map((l: any) => l.id)).toEqual([
|
|
88
|
+
"off",
|
|
89
|
+
"low",
|
|
90
|
+
"medium",
|
|
91
|
+
"high",
|
|
92
|
+
"xhigh",
|
|
93
|
+
]);
|
|
94
|
+
expect(model.thinkingDefault).toBe("high");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls back to the base five levels and omits thinkingDefault on a legacy gateway", async () => {
|
|
98
|
+
setRuntime(CONFIG); // no resolveThinkingPolicy
|
|
99
|
+
|
|
100
|
+
const res = new MockRes();
|
|
101
|
+
await handleModelsList(makeReq(AUTH), res as any);
|
|
102
|
+
|
|
103
|
+
const body = JSON.parse(res.body);
|
|
104
|
+
const model = body.models.find((m: any) => m.id === "openai/gpt-5.4");
|
|
105
|
+
expect(model.thinkingLevels.map((l: any) => l.id)).toEqual([
|
|
106
|
+
"off",
|
|
107
|
+
"minimal",
|
|
108
|
+
"low",
|
|
109
|
+
"medium",
|
|
110
|
+
"high",
|
|
111
|
+
]);
|
|
112
|
+
expect(model.thinkingDefault).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
|
|
3
3
|
import { splitModelRef } from "../../session/session-manager.js";
|
|
4
|
+
import { resolveModelThinking, type ThinkingLevelOption } from "../../thinking-levels.js";
|
|
4
5
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
6
|
|
|
6
7
|
export interface FridayModelEntry {
|
|
@@ -10,6 +11,10 @@ export interface FridayModelEntry {
|
|
|
10
11
|
reasoning?: boolean;
|
|
11
12
|
contextWindow?: number;
|
|
12
13
|
maxTokens?: number;
|
|
14
|
+
/** Thinking levels this model supports (varies per model). Omitted when only the base set applies. */
|
|
15
|
+
thinkingLevels?: ThinkingLevelOption[];
|
|
16
|
+
/** Provider/model default thinking level, when the gateway reports one. */
|
|
17
|
+
thinkingDefault?: string;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
interface ResolvedModels {
|
|
@@ -89,6 +94,13 @@ function resolveConfiguredModels(): ResolvedModels {
|
|
|
89
94
|
});
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const split = splitModelRef(entry.id);
|
|
99
|
+
const thinking = resolveModelThinking(entry.provider || split.provider, split.modelId);
|
|
100
|
+
entry.thinkingLevels = thinking.levels;
|
|
101
|
+
if (thinking.default) entry.thinkingDefault = thinking.default;
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
return { models: entries, defaultModel };
|
|
93
105
|
}
|
|
94
106
|
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
} from "../../session/session-manager.js";
|
|
9
9
|
import { readJsonBody } from "../middleware/body.js";
|
|
10
10
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
11
|
+
import { resolveModelThinkingForRef } from "../../thinking-levels.js";
|
|
11
12
|
|
|
12
13
|
const VALID_REASONING = new Set(["on", "off", "stream"]);
|
|
13
|
-
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high"]);
|
|
14
14
|
|
|
15
15
|
export async function handleSessionsSettings(
|
|
16
16
|
req: IncomingMessage,
|
|
@@ -61,12 +61,25 @@ export async function handleSessionsSettings(
|
|
|
61
61
|
const thinkingLevel = typeof body?.thinkingLevel === "string" ? body.thinkingLevel : undefined;
|
|
62
62
|
const modelRef = typeof body?.modelRef === "string" ? body.modelRef.trim() : undefined;
|
|
63
63
|
|
|
64
|
+
// The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
|
|
65
|
+
// default and write it as an *explicit* override, identical in shape to any other selection — so
|
|
66
|
+
// the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
|
|
67
|
+
// clear the override here: the session entry is shared with the OpenClaw core, which stamps it
|
|
68
|
+
// with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
|
|
69
|
+
// three fields leaves those dangling and the core mis-resolves to a fallback model.
|
|
70
|
+
const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
|
|
71
|
+
|
|
64
72
|
const errors: string[] = [];
|
|
65
73
|
if (reasoningLevel !== undefined && !VALID_REASONING.has(reasoningLevel)) {
|
|
66
74
|
errors.push(`reasoningLevel must be one of: ${[...VALID_REASONING].join(", ")}`);
|
|
67
75
|
}
|
|
68
|
-
if (thinkingLevel !== undefined
|
|
69
|
-
|
|
76
|
+
if (thinkingLevel !== undefined) {
|
|
77
|
+
// Thinking levels vary per model, so validate against the levels the *effective* model supports
|
|
78
|
+
// (resolved from the running gateway). Falls back to the base five levels when unresolvable.
|
|
79
|
+
const supported = resolveModelThinkingForRef(effectiveModelRef).levels.map((l) => l.id);
|
|
80
|
+
if (!supported.includes(thinkingLevel)) {
|
|
81
|
+
errors.push(`thinkingLevel must be one of: ${supported.join(", ")}`);
|
|
82
|
+
}
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
if (errors.length > 0) {
|
|
@@ -76,14 +89,6 @@ export async function handleSessionsSettings(
|
|
|
76
89
|
return true;
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
// The app omits (or empties) modelRef to mean "use the agent's default model". Resolve that
|
|
80
|
-
// default and write it as an *explicit* override, identical in shape to any other selection — so
|
|
81
|
-
// the agent runs the default exactly the way it runs an explicitly-picked model. Do NOT just
|
|
82
|
-
// clear the override here: the session entry is shared with the OpenClaw core, which stamps it
|
|
83
|
-
// with provenance fields (`modelOverrideSource`, `model`, `modelProvider`); deleting only our
|
|
84
|
-
// three fields leaves those dangling and the core mis-resolves to a fallback model.
|
|
85
|
-
const effectiveModelRef = modelRef || resolveAgentDefaults(sessionKey).model;
|
|
86
|
-
|
|
87
92
|
const settings: FridaySessionSettingsUpdate = { reasoningLevel, thinkingLevel };
|
|
88
93
|
if (effectiveModelRef) {
|
|
89
94
|
const split = splitModelRef(effectiveModelRef);
|
package/src/http/server.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { handleNodesApprove } from "./handlers/nodes-approve.js";
|
|
|
16
16
|
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
17
17
|
import { handleModelsList } from "./handlers/models-list.js";
|
|
18
18
|
import { handleAgentsList } from "./handlers/agents-list.js";
|
|
19
|
+
import { handleAgentConfig } from "./handlers/agent-config.js";
|
|
20
|
+
import { handleAgentFiles } from "./handlers/agent-files.js";
|
|
21
|
+
import { handleAgentToolsCatalog } from "./handlers/agent-tools-catalog.js";
|
|
19
22
|
import { handleHistorySessions } from "./handlers/history-sessions.js";
|
|
20
23
|
import { handleHistoryMessages } from "./handlers/history-messages.js";
|
|
21
24
|
import { handleHistorySetTitle } from "./handlers/history-set-title.js";
|
|
@@ -88,6 +91,27 @@ async function handleFridayNextRoute(
|
|
|
88
91
|
return await handleAgentsList(req, res);
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
// Routes: GET/PUT /friday-next/agents/{id}/config
|
|
95
|
+
// GET /friday-next/agents/{id}/files
|
|
96
|
+
// GET/PUT /friday-next/agents/{id}/files/{name}
|
|
97
|
+
if (pathname.startsWith("/friday-next/agents/")) {
|
|
98
|
+
const segs = pathname
|
|
99
|
+
.slice("/friday-next/agents/".length)
|
|
100
|
+
.split("/")
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.map((s) => decodeURIComponent(s));
|
|
103
|
+
const [id, sub, name] = segs;
|
|
104
|
+
if (id && sub === "config" && segs.length === 2) {
|
|
105
|
+
return await handleAgentConfig(req, res, id);
|
|
106
|
+
}
|
|
107
|
+
if (id && sub === "files" && (segs.length === 2 || segs.length === 3)) {
|
|
108
|
+
return await handleAgentFiles(req, res, id, name);
|
|
109
|
+
}
|
|
110
|
+
if (id && sub === "tools" && name === "catalog" && segs.length === 3) {
|
|
111
|
+
return await handleAgentToolsCatalog(req, res, id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
if (req.method === "GET" && pathname === "/friday-next/status") {
|
|
92
116
|
return await handleStatus(req, res);
|
|
93
117
|
}
|
|
@@ -66,7 +66,7 @@ describe("assertPublicHttpUrl", () => {
|
|
|
66
66
|
expect(() => assertPublicHttpUrl(new URL("http://127.0.0.1/"))).toThrow(BlockedUrlError);
|
|
67
67
|
expect(() => assertPublicHttpUrl(new URL("http://10.0.0.5/"))).toThrow(BlockedUrlError);
|
|
68
68
|
expect(() => assertPublicHttpUrl(new URL("http://[::1]/"))).toThrow(BlockedUrlError);
|
|
69
|
-
expect(() => assertPublicHttpUrl(new URL("http://[
|
|
69
|
+
expect(() => assertPublicHttpUrl(new URL("http://[fd00::1]/"))).toThrow(BlockedUrlError);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it("allows public IP literals", () => {
|
|
@@ -106,11 +106,16 @@ describe("isPrivateAddress", () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
it("flags private/reserved IPv6 and mapped IPv4", () => {
|
|
109
|
-
for (const ip of ["::1", "::", "
|
|
109
|
+
for (const ip of ["::1", "::", "fd00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1"]) {
|
|
110
110
|
expect(isPrivateAddress(ip), ip).toBe(true);
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
it("passes fc00::/8 (fake-IP DNS v6 range used by clash/mihomo tun mode)", () => {
|
|
115
|
+
expect(isPrivateAddress("fc00::1e7")).toBe(false);
|
|
116
|
+
expect(isPrivateAddress("fc00::ffff:ffff")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
114
119
|
it("passes public IPv6 and mapped public IPv4", () => {
|
|
115
120
|
expect(isPrivateAddress("2606:2800:220:1:248:1893:25c8:1946")).toBe(false);
|
|
116
121
|
expect(isPrivateAddress("::ffff:93.184.216.34")).toBe(false);
|
|
@@ -90,7 +90,11 @@ function isPrivateIPv6(ip: string): boolean {
|
|
|
90
90
|
if (mapped) return isPrivateIPv4(mapped[1]);
|
|
91
91
|
if (lower === "::" || lower === "::1") return true; // unspecified / loopback
|
|
92
92
|
const head = lower.split(":")[0];
|
|
93
|
-
|
|
93
|
+
// fc00::/8 (the reserved, never-assigned half of ULA fc00::/7) intentionally NOT blocked:
|
|
94
|
+
// RFC 4193 requires locally-assigned ULA to set the L bit, so real LAN services live in
|
|
95
|
+
// fd00::/8, while fake-IP DNS setups (mihomo/clash tun mode with IPv6, common on gateway
|
|
96
|
+
// hosts) resolve EVERY domain into fc00::/18. Same rationale as 198.18/15 on the IPv4 side.
|
|
97
|
+
if (head.startsWith("fd")) return true; // fd00::/8 locally-assigned ULA
|
|
94
98
|
if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
|
|
95
99
|
return false;
|
|
96
100
|
}
|
package/src/media-fetch.test.ts
CHANGED
|
@@ -59,7 +59,7 @@ describe("downloadRemoteMedia", () => {
|
|
|
59
59
|
async () =>
|
|
60
60
|
new Response(new Uint8Array([1, 2, 3]), {
|
|
61
61
|
status: 200,
|
|
62
|
-
headers: { "content-type": "image/png", "content-length": String(
|
|
62
|
+
headers: { "content-type": "image/png", "content-length": String(128 * 1024 * 1024) },
|
|
63
63
|
}),
|
|
64
64
|
),
|
|
65
65
|
);
|
package/src/media-fetch.ts
CHANGED
|
@@ -10,7 +10,10 @@ import { guessMimeType } from "./http/handlers/files.js";
|
|
|
10
10
|
* download story uniform (always talks to the trusted gateway host with a bearer token).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Aligns with openclaw's own remote-media ceiling (DEFAULT_FETCH_MEDIA_MAX_BYTES =
|
|
14
|
+
// MAX_DOCUMENT_BYTES = 100MB). The real per-kind limit is enforced downstream by
|
|
15
|
+
// saveMediaBuffer (via resolveMediaMaxBytes); this is just the download ceiling.
|
|
16
|
+
const MAX_REMOTE_MEDIA_BYTES = 100 * 1024 * 1024; // 100MB
|
|
14
17
|
const REMOTE_MEDIA_TIMEOUT_MS = 20_000;
|
|
15
18
|
|
|
16
19
|
export function isHttpUrl(value: string): boolean {
|