@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.
Files changed (154) hide show
  1. package/README.md +8 -4
  2. package/dist/index.js +1 -1
  3. package/dist/src/agent/abort-run.d.ts +12 -1
  4. package/dist/src/agent/abort-run.js +24 -9
  5. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  6. package/dist/src/agent/media-bridge.d.ts +8 -1
  7. package/dist/src/agent/media-bridge.js +23 -2
  8. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  9. package/dist/src/agent/node-pairing-bridge.js +6 -2
  10. package/dist/src/agent/subagent-registry.js +0 -3
  11. package/dist/src/agent-forward-runtime.d.ts +15 -0
  12. package/dist/src/agent-forward-runtime.js +2 -0
  13. package/dist/src/agent-id.d.ts +8 -0
  14. package/dist/src/agent-id.js +21 -0
  15. package/dist/src/channel-actions.js +48 -15
  16. package/dist/src/channel.js +22 -3
  17. package/dist/src/collect-message-media-paths.js +10 -1
  18. package/dist/src/friday-session.js +34 -10
  19. package/dist/src/history/normalize-message.js +22 -8
  20. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  21. package/dist/src/http/handlers/agent-config.js +188 -0
  22. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  23. package/dist/src/http/handlers/agent-files.js +137 -0
  24. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  25. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  26. package/dist/src/http/handlers/agents-list.js +1 -19
  27. package/dist/src/http/handlers/cancel.js +14 -6
  28. package/dist/src/http/handlers/device-approve.js +3 -1
  29. package/dist/src/http/handlers/files-download.js +6 -8
  30. package/dist/src/http/handlers/files.d.ts +16 -0
  31. package/dist/src/http/handlers/files.js +81 -13
  32. package/dist/src/http/handlers/health.js +18 -4
  33. package/dist/src/http/handlers/history-messages.js +1 -1
  34. package/dist/src/http/handlers/history-sessions.js +5 -3
  35. package/dist/src/http/handlers/messages.js +33 -14
  36. package/dist/src/http/handlers/models-list.d.ts +5 -0
  37. package/dist/src/http/handlers/models-list.js +9 -1
  38. package/dist/src/http/handlers/nodes-approve.js +1 -6
  39. package/dist/src/http/handlers/plugin-info.js +1 -1
  40. package/dist/src/http/handlers/sessions-settings.js +15 -10
  41. package/dist/src/http/server.js +27 -2
  42. package/dist/src/link-preview/og-parse.js +3 -1
  43. package/dist/src/link-preview/ssrf-guard.js +6 -2
  44. package/dist/src/media-fetch.js +4 -1
  45. package/dist/src/plugin-install-info.js +4 -1
  46. package/dist/src/session/session-manager.js +9 -3
  47. package/dist/src/session-usage-store.js +3 -1
  48. package/dist/src/skills-discovery.d.ts +59 -0
  49. package/dist/src/skills-discovery.js +252 -0
  50. package/dist/src/sse/offline-queue.js +4 -1
  51. package/dist/src/thinking-levels.d.ts +21 -0
  52. package/dist/src/thinking-levels.js +48 -0
  53. package/dist/src/tool-catalog.d.ts +53 -0
  54. package/dist/src/tool-catalog.js +191 -0
  55. package/dist/src/upgrade-runtime.d.ts +1 -1
  56. package/dist/src/version.js +4 -2
  57. package/index.ts +43 -35
  58. package/install.js +131 -43
  59. package/package.json +10 -1
  60. package/src/agent/abort-run.ts +23 -8
  61. package/src/agent/dispatch-bridge.ts +2 -1
  62. package/src/agent/media-bridge.test.ts +71 -0
  63. package/src/agent/media-bridge.ts +30 -1
  64. package/src/agent/node-pairing-bridge.ts +29 -15
  65. package/src/agent/run-usage-accumulator.ts +4 -2
  66. package/src/agent/subagent-registry.ts +0 -4
  67. package/src/agent-forward-runtime.ts +11 -0
  68. package/src/agent-id.ts +24 -0
  69. package/src/agent-run-context-bridge.ts +3 -1
  70. package/src/channel-actions.test.ts +57 -4
  71. package/src/channel-actions.ts +41 -15
  72. package/src/channel.lifecycle.test.ts +41 -0
  73. package/src/channel.outbound.test.ts +18 -4
  74. package/src/channel.ts +140 -120
  75. package/src/collect-message-media-paths.ts +15 -6
  76. package/src/config.ts +1 -4
  77. package/src/e2e/agents-list.e2e.test.ts +9 -2
  78. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  79. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  80. package/src/e2e/auto-approve.integration.test.ts +13 -7
  81. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  82. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  83. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  84. package/src/e2e/send-text.e2e.test.ts +11 -2
  85. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  86. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  87. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  88. package/src/e2e/subagent.e2e.test.ts +136 -53
  89. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  90. package/src/friday-session.forward-agent.test.ts +44 -12
  91. package/src/friday-session.ts +44 -20
  92. package/src/history/normalize-message.test.ts +35 -8
  93. package/src/history/normalize-message.ts +24 -12
  94. package/src/history/read-transcript.ts +1 -4
  95. package/src/http/handlers/agent-config.test.ts +212 -0
  96. package/src/http/handlers/agent-config.ts +232 -0
  97. package/src/http/handlers/agent-files.test.ts +136 -0
  98. package/src/http/handlers/agent-files.ts +149 -0
  99. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  100. package/src/http/handlers/agents-list.test.ts +1 -5
  101. package/src/http/handlers/agents-list.ts +1 -22
  102. package/src/http/handlers/cancel.test.ts +23 -4
  103. package/src/http/handlers/cancel.ts +14 -6
  104. package/src/http/handlers/device-approve.test.ts +12 -3
  105. package/src/http/handlers/device-approve.ts +33 -21
  106. package/src/http/handlers/files-download.ts +17 -13
  107. package/src/http/handlers/files.test.ts +120 -0
  108. package/src/http/handlers/files.ts +115 -17
  109. package/src/http/handlers/health.test.ts +43 -11
  110. package/src/http/handlers/health.ts +22 -6
  111. package/src/http/handlers/history-messages.test.ts +51 -9
  112. package/src/http/handlers/history-messages.ts +4 -1
  113. package/src/http/handlers/history-sessions.test.ts +46 -9
  114. package/src/http/handlers/history-sessions.ts +5 -3
  115. package/src/http/handlers/history-set-title.test.ts +14 -5
  116. package/src/http/handlers/link-preview.test.ts +57 -16
  117. package/src/http/handlers/link-preview.ts +4 -1
  118. package/src/http/handlers/messages.test.ts +12 -8
  119. package/src/http/handlers/messages.ts +64 -21
  120. package/src/http/handlers/models-list.test.ts +114 -0
  121. package/src/http/handlers/models-list.ts +26 -8
  122. package/src/http/handlers/nodes-approve.test.ts +15 -4
  123. package/src/http/handlers/nodes-approve.ts +38 -40
  124. package/src/http/handlers/plugin-info.ts +5 -6
  125. package/src/http/handlers/plugin-upgrade.ts +4 -1
  126. package/src/http/handlers/sessions-settings.ts +16 -11
  127. package/src/http/handlers/sse.ts +3 -1
  128. package/src/http/server.ts +33 -6
  129. package/src/link-preview/og-parse.test.ts +6 -2
  130. package/src/link-preview/og-parse.ts +10 -3
  131. package/src/link-preview/preview-service.ts +4 -1
  132. package/src/link-preview/ssrf-guard.test.ts +78 -16
  133. package/src/link-preview/ssrf-guard.ts +7 -2
  134. package/src/media-fetch.test.ts +8 -3
  135. package/src/media-fetch.ts +5 -3
  136. package/src/openclaw.d.ts +41 -10
  137. package/src/plugin-install-info.ts +20 -9
  138. package/src/run-metadata.ts +2 -1
  139. package/src/session/session-manager.ts +19 -11
  140. package/src/session-usage-snapshot.ts +3 -1
  141. package/src/session-usage-store.ts +3 -1
  142. package/src/skills-discovery.test.ts +152 -0
  143. package/src/skills-discovery.ts +264 -0
  144. package/src/sse/emitter.test.ts +1 -1
  145. package/src/sse/emitter.ts +9 -3
  146. package/src/sse/offline-queue.ts +17 -8
  147. package/src/test-support/app-simulator.ts +17 -3
  148. package/src/test-support/mock-dispatch.ts +17 -4
  149. package/src/thinking-levels.test.ts +143 -0
  150. package/src/thinking-levels.ts +70 -0
  151. package/src/tool-catalog.ts +261 -0
  152. package/src/upgrade-runtime.ts +4 -2
  153. package/src/version.ts +6 -2
  154. package/tsconfig.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { abortRun } from "../../agent/abort-run.js";
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
- if (!runId) {
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
- await abortRun(runId);
30
- sseEmitter.untrackRun(runId);
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(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
32
- const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
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({ pending: [{ requestId: "x", deviceId: "UNMATCHED" }], paired: [] });
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(JSON.stringify({
71
- ok: true,
72
- deviceId: normalizedDeviceId,
73
- alreadyApproved: true,
74
- approvedAtMs: (pairedDevice as any).approvedAtMs,
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(JSON.stringify({
82
- error: "No pending device found for this deviceId",
83
- deviceId: normalizedDeviceId,
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(JSON.stringify({
99
- error: "Device approval failed",
100
- detail: err instanceof Error ? err.message : "Unknown error",
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(JSON.stringify({ error: `Device approval forbidden: ${(approved as any).reason ?? "unknown"}` }));
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(JSON.stringify({
122
- ok: true,
123
- deviceId: normalizedDeviceId,
124
- requestId: approved.requestId,
125
- approvedAtMs: (approved as any).device?.approvedAtMs,
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.basename(filename).replace(/[\r\n"]/g, "_").replace(/\\/g, "_") || "file";
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]!, 10);
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]!, 10);
124
+ start = parseInt(m[1], 10);
124
125
  end = total - 1;
125
126
  } else if (m[1] !== "" && m[2] !== "") {
126
- start = parseInt(m[1]!, 10);
127
- end = parseInt(m[2]!, 10);
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(req, res, fromAttachments.buffer, fromAttachments.mimeType, fromAttachments.filename);
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(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
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
- return { buffer, mimeType: guessMimeType(safe), filename: safe };
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(sourcePath: string): StoredFile | null {
200
+ function copyLocalFileToAttachments(
201
+ sourcePath: string,
202
+ originalFilename?: string,
203
+ ): StoredFile | null {
121
204
  const resolvedPath = normalizeAgentMediaPath(sourcePath);
122
- const filename = path.basename(resolvedPath);
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(`copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`);
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.filename)}`;
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): { buffer: Buffer | null; mimeType: 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 filename = path.basename(localPath);
298
- if (!filename) return null;
391
+ const basename = path.basename(localPath);
392
+ if (!basename) return null;
299
393
 
300
- const stored = copyLocalFileToAttachments(localPath);
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(filename);
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: 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(method: string, url: string, headers: Record<string, string> = {}): IncomingMessage {
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: ["location.get", "canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset"],
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({ requestId: REQUEST_ID, node: { nodeId: NODE_ID } });
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({ status: "forbidden", missingScope: "operator.admin" });
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: ["location.get", "canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset"],
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("GET", `/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`, {
245
- authorization: "Bearer test-token",
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);