@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.
Files changed (71) hide show
  1. package/README.md +8 -4
  2. package/dist/src/agent/abort-run.d.ts +12 -1
  3. package/dist/src/agent/abort-run.js +24 -9
  4. package/dist/src/agent/media-bridge.d.ts +8 -1
  5. package/dist/src/agent/media-bridge.js +23 -2
  6. package/dist/src/agent-forward-runtime.d.ts +15 -0
  7. package/dist/src/agent-forward-runtime.js +2 -0
  8. package/dist/src/agent-id.d.ts +8 -0
  9. package/dist/src/agent-id.js +21 -0
  10. package/dist/src/channel-actions.js +45 -14
  11. package/dist/src/channel.js +22 -1
  12. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  13. package/dist/src/http/handlers/agent-config.js +182 -0
  14. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  15. package/dist/src/http/handlers/agent-files.js +137 -0
  16. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  17. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  18. package/dist/src/http/handlers/agents-list.js +1 -19
  19. package/dist/src/http/handlers/cancel.js +12 -6
  20. package/dist/src/http/handlers/files.d.ts +16 -0
  21. package/dist/src/http/handlers/files.js +80 -12
  22. package/dist/src/http/handlers/messages.js +8 -3
  23. package/dist/src/http/handlers/models-list.d.ts +5 -0
  24. package/dist/src/http/handlers/models-list.js +8 -0
  25. package/dist/src/http/handlers/sessions-settings.js +15 -10
  26. package/dist/src/http/server.js +23 -0
  27. package/dist/src/link-preview/ssrf-guard.js +6 -2
  28. package/dist/src/media-fetch.js +4 -1
  29. package/dist/src/skills-discovery.d.ts +58 -0
  30. package/dist/src/skills-discovery.js +247 -0
  31. package/dist/src/thinking-levels.d.ts +21 -0
  32. package/dist/src/thinking-levels.js +48 -0
  33. package/dist/src/tool-catalog.d.ts +53 -0
  34. package/dist/src/tool-catalog.js +192 -0
  35. package/dist/src/version.js +1 -1
  36. package/package.json +1 -1
  37. package/src/agent/abort-run.ts +24 -8
  38. package/src/agent/media-bridge.test.ts +71 -0
  39. package/src/agent/media-bridge.ts +23 -1
  40. package/src/agent-forward-runtime.ts +11 -0
  41. package/src/agent-id.ts +24 -0
  42. package/src/channel-actions.test.ts +47 -0
  43. package/src/channel-actions.ts +38 -14
  44. package/src/channel.lifecycle.test.ts +41 -0
  45. package/src/channel.ts +23 -1
  46. package/src/http/handlers/agent-config.test.ts +205 -0
  47. package/src/http/handlers/agent-config.ts +218 -0
  48. package/src/http/handlers/agent-files.test.ts +136 -0
  49. package/src/http/handlers/agent-files.ts +149 -0
  50. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  51. package/src/http/handlers/agents-list.ts +1 -22
  52. package/src/http/handlers/cancel.test.ts +12 -2
  53. package/src/http/handlers/cancel.ts +12 -6
  54. package/src/http/handlers/files.test.ts +114 -0
  55. package/src/http/handlers/files.ts +97 -13
  56. package/src/http/handlers/messages.ts +7 -2
  57. package/src/http/handlers/models-list.test.ts +114 -0
  58. package/src/http/handlers/models-list.ts +12 -0
  59. package/src/http/handlers/sessions-settings.ts +16 -11
  60. package/src/http/server.ts +24 -0
  61. package/src/link-preview/ssrf-guard.test.ts +7 -2
  62. package/src/link-preview/ssrf-guard.ts +5 -1
  63. package/src/media-fetch.test.ts +1 -1
  64. package/src/media-fetch.ts +4 -1
  65. package/src/openclaw.d.ts +25 -1
  66. package/src/skills-discovery.test.ts +148 -0
  67. package/src/skills-discovery.ts +248 -0
  68. package/src/thinking-levels.test.ts +143 -0
  69. package/src/thinking-levels.ts +68 -0
  70. package/src/tool-catalog.ts +252 -0
  71. 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
- return { buffer, mimeType: guessMimeType(safe), filename: safe };
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 filename = path.basename(resolvedPath);
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.filename)}`;
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 filename = path.basename(localPath);
298
- if (!filename) return null;
376
+ const basename = path.basename(localPath);
377
+ if (!basename) return null;
299
378
 
300
- const stored = copyLocalFileToAttachments(localPath);
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(filename);
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: 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 && !VALID_THINKING.has(thinkingLevel)) {
69
- errors.push(`thinkingLevel must be one of: ${[...VALID_THINKING].join(", ")}`);
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);
@@ -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://[fc00::1]/"))).toThrow(BlockedUrlError);
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", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.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
- if (head.startsWith("fc") || head.startsWith("fd")) return true; // fc00::/7 ULA
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
  }
@@ -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(64 * 1024 * 1024) },
62
+ headers: { "content-type": "image/png", "content-length": String(128 * 1024 * 1024) },
63
63
  }),
64
64
  ),
65
65
  );
@@ -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
- const MAX_REMOTE_MEDIA_BYTES = 25 * 1024 * 1024; // 25MB
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 {