@syengup/friday-channel-next 0.1.36 → 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 (120) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  3. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  4. package/dist/src/agent/node-pairing-bridge.js +6 -2
  5. package/dist/src/agent/subagent-registry.js +0 -3
  6. package/dist/src/channel-actions.js +3 -1
  7. package/dist/src/channel.js +0 -2
  8. package/dist/src/collect-message-media-paths.js +10 -1
  9. package/dist/src/friday-session.js +34 -10
  10. package/dist/src/history/normalize-message.js +22 -8
  11. package/dist/src/http/handlers/agent-config.js +10 -4
  12. package/dist/src/http/handlers/cancel.js +4 -2
  13. package/dist/src/http/handlers/device-approve.js +3 -1
  14. package/dist/src/http/handlers/files-download.js +6 -8
  15. package/dist/src/http/handlers/files.js +1 -1
  16. package/dist/src/http/handlers/health.js +18 -4
  17. package/dist/src/http/handlers/history-messages.js +1 -1
  18. package/dist/src/http/handlers/history-sessions.js +5 -3
  19. package/dist/src/http/handlers/messages.js +25 -11
  20. package/dist/src/http/handlers/models-list.js +1 -1
  21. package/dist/src/http/handlers/nodes-approve.js +1 -6
  22. package/dist/src/http/handlers/plugin-info.js +1 -1
  23. package/dist/src/http/server.js +4 -2
  24. package/dist/src/link-preview/og-parse.js +3 -1
  25. package/dist/src/plugin-install-info.js +4 -1
  26. package/dist/src/session/session-manager.js +9 -3
  27. package/dist/src/session-usage-store.js +3 -1
  28. package/dist/src/skills-discovery.d.ts +5 -4
  29. package/dist/src/skills-discovery.js +27 -22
  30. package/dist/src/sse/offline-queue.js +4 -1
  31. package/dist/src/tool-catalog.js +2 -3
  32. package/dist/src/upgrade-runtime.d.ts +1 -1
  33. package/dist/src/version.js +3 -1
  34. package/index.ts +43 -35
  35. package/install.js +131 -43
  36. package/package.json +10 -1
  37. package/src/agent/abort-run.ts +2 -3
  38. package/src/agent/dispatch-bridge.ts +2 -1
  39. package/src/agent/media-bridge.ts +9 -2
  40. package/src/agent/node-pairing-bridge.ts +29 -15
  41. package/src/agent/run-usage-accumulator.ts +4 -2
  42. package/src/agent/subagent-registry.ts +0 -4
  43. package/src/agent-run-context-bridge.ts +3 -1
  44. package/src/channel-actions.test.ts +10 -4
  45. package/src/channel-actions.ts +3 -1
  46. package/src/channel.outbound.test.ts +18 -4
  47. package/src/channel.ts +121 -123
  48. package/src/collect-message-media-paths.ts +15 -6
  49. package/src/config.ts +1 -4
  50. package/src/e2e/agents-list.e2e.test.ts +9 -2
  51. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  52. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  53. package/src/e2e/auto-approve.integration.test.ts +13 -7
  54. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  55. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  56. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  57. package/src/e2e/send-text.e2e.test.ts +11 -2
  58. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  59. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  60. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  61. package/src/e2e/subagent.e2e.test.ts +136 -53
  62. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  63. package/src/friday-session.forward-agent.test.ts +44 -12
  64. package/src/friday-session.ts +44 -20
  65. package/src/history/normalize-message.test.ts +35 -8
  66. package/src/history/normalize-message.ts +24 -12
  67. package/src/history/read-transcript.ts +1 -4
  68. package/src/http/handlers/agent-config.test.ts +10 -3
  69. package/src/http/handlers/agent-config.ts +22 -8
  70. package/src/http/handlers/agents-list.test.ts +1 -5
  71. package/src/http/handlers/cancel.test.ts +12 -3
  72. package/src/http/handlers/cancel.ts +4 -2
  73. package/src/http/handlers/device-approve.test.ts +12 -3
  74. package/src/http/handlers/device-approve.ts +33 -21
  75. package/src/http/handlers/files-download.ts +17 -13
  76. package/src/http/handlers/files.test.ts +8 -2
  77. package/src/http/handlers/files.ts +21 -7
  78. package/src/http/handlers/health.test.ts +43 -11
  79. package/src/http/handlers/health.ts +22 -6
  80. package/src/http/handlers/history-messages.test.ts +51 -9
  81. package/src/http/handlers/history-messages.ts +4 -1
  82. package/src/http/handlers/history-sessions.test.ts +46 -9
  83. package/src/http/handlers/history-sessions.ts +5 -3
  84. package/src/http/handlers/history-set-title.test.ts +14 -5
  85. package/src/http/handlers/link-preview.test.ts +57 -16
  86. package/src/http/handlers/link-preview.ts +4 -1
  87. package/src/http/handlers/messages.test.ts +12 -8
  88. package/src/http/handlers/messages.ts +57 -19
  89. package/src/http/handlers/models-list.ts +14 -8
  90. package/src/http/handlers/nodes-approve.test.ts +15 -4
  91. package/src/http/handlers/nodes-approve.ts +38 -40
  92. package/src/http/handlers/plugin-info.ts +5 -6
  93. package/src/http/handlers/plugin-upgrade.ts +4 -1
  94. package/src/http/handlers/sse.ts +3 -1
  95. package/src/http/server.ts +9 -6
  96. package/src/link-preview/og-parse.test.ts +6 -2
  97. package/src/link-preview/og-parse.ts +10 -3
  98. package/src/link-preview/preview-service.ts +4 -1
  99. package/src/link-preview/ssrf-guard.test.ts +72 -15
  100. package/src/link-preview/ssrf-guard.ts +2 -1
  101. package/src/media-fetch.test.ts +7 -2
  102. package/src/media-fetch.ts +1 -2
  103. package/src/openclaw.d.ts +16 -9
  104. package/src/plugin-install-info.ts +20 -9
  105. package/src/run-metadata.ts +2 -1
  106. package/src/session/session-manager.ts +19 -11
  107. package/src/session-usage-snapshot.ts +3 -1
  108. package/src/session-usage-store.ts +3 -1
  109. package/src/skills-discovery.test.ts +14 -10
  110. package/src/skills-discovery.ts +43 -27
  111. package/src/sse/emitter.test.ts +1 -1
  112. package/src/sse/emitter.ts +9 -3
  113. package/src/sse/offline-queue.ts +17 -8
  114. package/src/test-support/app-simulator.ts +17 -3
  115. package/src/test-support/mock-dispatch.ts +17 -4
  116. package/src/thinking-levels.ts +3 -1
  117. package/src/tool-catalog.ts +16 -7
  118. package/src/upgrade-runtime.ts +4 -2
  119. package/src/version.ts +5 -1
  120. package/tsconfig.json +1 -1
@@ -1,8 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import {
3
- normalizeHistoryMessage,
4
- normalizeHistoryMessages,
5
- } from "./normalize-message.js";
2
+ import { normalizeHistoryMessage, normalizeHistoryMessages } from "./normalize-message.js";
6
3
 
7
4
  function meta(id?: string, seq = 1, extra: Record<string, unknown> = {}) {
8
5
  return { __openclaw: { ...(id ? { id } : {}), seq, recordTimestampMs: 1700000000000, ...extra } };
@@ -124,7 +121,7 @@ describe("normalizeHistoryMessage", () => {
124
121
  expect(out?.toolResult?.images).toBeUndefined();
125
122
  });
126
123
 
127
- it("keeps image blocks on non-canvas toolResults", () => {
124
+ it("keeps image blocks on image-producing (image_generation) toolResults", () => {
128
125
  const out = normalizeHistoryMessage(
129
126
  {
130
127
  role: "toolResult",
@@ -138,11 +135,34 @@ describe("normalizeHistoryMessage", () => {
138
135
  expect(out?.toolResult?.images).toEqual([{ mimeType: "image/png", data: "REALIMG" }]);
139
136
  });
140
137
 
138
+ it("drops inline image blocks on read toolResults (agent visual input, not an attachment)", () => {
139
+ // The `read` tool returns the file it fed to the model as an inline base64
140
+ // image so the agent can "see" it. That is NOT a user-facing attachment —
141
+ // surfacing it spawned phantom corrupt image bubbles on history rebuild for
142
+ // turns where the agent only LOOKED at a file and sent nothing.
143
+ const out = normalizeHistoryMessage(
144
+ {
145
+ role: "toolResult",
146
+ toolCallId: "tc-read",
147
+ toolName: "read",
148
+ content: [
149
+ { type: "text", text: "Read image file [image/jpeg]" },
150
+ { type: "image", mimeType: "image/jpeg", data: "AGENTVISUALINPUT" },
151
+ ],
152
+ ...meta("entry-read", 7),
153
+ },
154
+ 0,
155
+ );
156
+ expect(out?.toolResult?.images).toBeUndefined();
157
+ expect(out?.toolResult?.text).toBe("Read image file [image/jpeg]");
158
+ });
159
+
141
160
  it("strips MEDIA: lines from text into mediaPaths", () => {
142
161
  const out = normalizeHistoryMessage(
143
162
  {
144
163
  role: "assistant",
145
- content: "Here is the serene landscape 🌅\nMEDIA:/Users/me/.openclaw/media/tool-image-generation/x.png",
164
+ content:
165
+ "Here is the serene landscape 🌅\nMEDIA:/Users/me/.openclaw/media/tool-image-generation/x.png",
146
166
  ...meta("a1", 1),
147
167
  },
148
168
  0,
@@ -158,13 +178,20 @@ describe("normalizeHistoryMessage", () => {
158
178
  );
159
179
  expect(out?.text).toBe("two files");
160
180
  expect(out?.mediaPaths).toEqual(["/a/x.png", "/a/y.mp4"]);
161
- const plain = normalizeHistoryMessage({ role: "user", content: "no media here", ...meta("u", 1) }, 0);
181
+ const plain = normalizeHistoryMessage(
182
+ { role: "user", content: "no media here", ...meta("u", 1) },
183
+ 0,
184
+ );
162
185
  expect(plain?.mediaPaths).toBeUndefined();
163
186
  });
164
187
 
165
188
  it("flags compaction records via __openclaw.kind", () => {
166
189
  const out = normalizeHistoryMessage(
167
- { role: "system", content: [{ type: "text", text: "Compaction" }], ...meta("c1", 9, { kind: "compaction" }) },
190
+ {
191
+ role: "system",
192
+ content: [{ type: "text", text: "Compaction" }],
193
+ ...meta("c1", 9, { kind: "compaction" }),
194
+ },
168
195
  0,
169
196
  );
170
197
  expect(out?.kind).toBe("compaction");
@@ -104,6 +104,17 @@ function splitMediaLines(text: string): { text: string; paths: string[] } {
104
104
  return { text: cleaned, paths };
105
105
  }
106
106
 
107
+ /**
108
+ * Tools whose `toolResult` carries a user-facing PRODUCED image, which stays a
109
+ * chat attachment. Every OTHER tool's inline image block is the agent's visual
110
+ * INPUT — a file the `read` tool fed to the model, a `canvas` snapshot, a
111
+ * browser screenshot — and must NOT surface as an attachment on history rebuild
112
+ * (it spawns phantom, often-corrupt bubbles for turns where the agent never
113
+ * sent a file). Keep this a whitelist so any new image-CONSUMING tool is safe by
114
+ * default; add new image-PRODUCING tools here explicitly.
115
+ */
116
+ const IMAGE_PRODUCING_TOOLS = new Set(["image_generation"]);
117
+
107
118
  const MEDIA_MARKER_RE = /\[media attached:\s*([^\]]+)\]/gi;
108
119
 
109
120
  /** Pull `[media attached: <url>]` markers out of free text into image refs. */
@@ -219,10 +230,7 @@ function normalizeRole(raw: unknown): FridayHistoryRole {
219
230
  * Normalize one raw transcript message. `index` is the position in the returned
220
231
  * batch, used only to synthesize a stable-ish id when upstream omits one.
221
232
  */
222
- export function normalizeHistoryMessage(
223
- raw: unknown,
224
- index: number,
225
- ): FridayHistoryMessage | null {
233
+ export function normalizeHistoryMessage(raw: unknown, index: number): FridayHistoryMessage | null {
226
234
  const record = asRecord(raw);
227
235
  if (!record) return null;
228
236
 
@@ -254,14 +262,18 @@ export function normalizeHistoryMessage(
254
262
  if (role === "toolResult") {
255
263
  const split = splitMediaLines(parsed.text);
256
264
  const toolName = readString(record.toolName);
257
- // Canvas snapshots come back as base64 image blocks on the `canvas` tool result so the *agent*
258
- // can "see" the rendered page they must never surface as chat attachments on history rebuild.
259
- // The streaming deliver path already drops the temp-file form (see isCanvasSnapshotMediaPath in
260
- // http/handlers/messages.ts); this is the transcript-rebuild counterpart. The canvas tool has no
261
- // other image-returning action, so all images on a canvas result are snapshots.
262
- const isCanvasResult = toolName === "canvas";
263
- const images = isCanvasResult ? [] : parsed.images;
264
- const mediaPaths = isCanvasResult ? [] : split.paths;
265
+ // Inline image blocks on a toolResult are almost always the agent's visual
266
+ // INPUT a file the `read` tool fed to the model, a `canvas` snapshot (so the
267
+ // agent can "see" the rendered page), a browser screenshot NOT a user-facing
268
+ // attachment. Surfacing them spawns phantom, often-corrupt attachment bubbles on
269
+ // history rebuild for turns where the agent never sent a file. Only tools that
270
+ // PRODUCE a user-facing image keep their blocks. (This was a `canvas`-only
271
+ // blacklist, which still leaked `read`/screenshot images.) The streaming deliver
272
+ // path drops the canvas temp-file form separately (isCanvasSnapshotMediaPath in
273
+ // http/handlers/messages.ts); this is the transcript-rebuild counterpart.
274
+ const keepInlineImages = toolName ? IMAGE_PRODUCING_TOOLS.has(toolName) : false;
275
+ const images = keepInlineImages ? parsed.images : [];
276
+ const mediaPaths = toolName === "canvas" ? [] : split.paths;
265
277
  const toolResult: FridayHistoryToolResult = {
266
278
  ...(readString(record.toolCallId) ? { toolCallId: readString(record.toolCallId) } : {}),
267
279
  ...(toolName ? { toolName } : {}),
@@ -40,10 +40,7 @@ function resolveEntry(store: Record<string, unknown>, sessionKey: string): unkno
40
40
  return undefined;
41
41
  }
42
42
 
43
- export function resolveTranscriptPath(
44
- entry: unknown,
45
- storePath: string,
46
- ): string | undefined {
43
+ export function resolveTranscriptPath(entry: unknown, storePath: string): string | undefined {
47
44
  const sessionFile = entryString(entry, "sessionFile");
48
45
  if (sessionFile) {
49
46
  return path.isAbsolute(sessionFile)
@@ -123,7 +123,9 @@ describe("handleAgentConfig", () => {
123
123
  });
124
124
 
125
125
  it("PUT sets the model on an existing entry", async () => {
126
- const config: Record<string, unknown> = { agents: { list: [{ id: "main", model: "old/model" }] } };
126
+ const config: Record<string, unknown> = {
127
+ agents: { list: [{ id: "main", model: "old/model" }] },
128
+ };
127
129
  setRuntimes(config);
128
130
  const res = new MockRes();
129
131
  await handleAgentConfig(makeReq(AUTH, "PUT", { model: "openai/gpt-5" }), res as any, "main");
@@ -133,7 +135,9 @@ describe("handleAgentConfig", () => {
133
135
  });
134
136
 
135
137
  it("PUT model:null deletes the field so it inherits defaults", async () => {
136
- const config: Record<string, unknown> = { agents: { list: [{ id: "main", model: "old/model" }] } };
138
+ const config: Record<string, unknown> = {
139
+ agents: { list: [{ id: "main", model: "old/model" }] },
140
+ };
137
141
  setRuntimes(config);
138
142
  const res = new MockRes();
139
143
  await handleAgentConfig(makeReq(AUTH, "PUT", { model: null }), res as any, "main");
@@ -197,7 +201,10 @@ describe("handleAgentConfig", () => {
197
201
  setRuntimes({ agents: { list: [{ id: "main" }] } }, workspace);
198
202
  const res = new MockRes();
199
203
  await handleAgentConfig(makeReq(AUTH), res as any, "main");
200
- expect(JSON.parse(res.body).availableSkills.map((s: { id: string }) => s.id)).toEqual(["deep-research", "verify"]);
204
+ expect(JSON.parse(res.body).availableSkills.map((s: { id: string }) => s.id)).toEqual([
205
+ "deep-research",
206
+ "verify",
207
+ ]);
201
208
  } finally {
202
209
  fs.rmSync(workspace, { recursive: true, force: true });
203
210
  }
@@ -60,7 +60,9 @@ function readString(value: unknown): string | undefined {
60
60
 
61
61
  function readStringArray(value: unknown): string[] | undefined {
62
62
  if (!Array.isArray(value)) return undefined;
63
- const out = value.filter((v): v is string => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
63
+ const out = value
64
+ .filter((v): v is string => typeof v === "string" && v.trim().length > 0)
65
+ .map((v) => v.trim());
64
66
  return out;
65
67
  }
66
68
 
@@ -81,7 +83,9 @@ function readToolsConfig(value: unknown): AgentToolsConfig | undefined {
81
83
 
82
84
  /** Locate the configured `agents.list[]` entry whose normalized id matches `agentId`. */
83
85
  function findAgentEntry(cfg: unknown, agentId: string): Record<string, unknown> | undefined {
84
- const agents = (cfg as Record<string, unknown> | undefined)?.agents as Record<string, unknown> | undefined;
86
+ const agents = (cfg as Record<string, unknown> | undefined)?.agents as
87
+ | Record<string, unknown>
88
+ | undefined;
85
89
  const list = agents?.list as Array<Record<string, unknown>> | undefined;
86
90
  if (!Array.isArray(list)) return undefined;
87
91
  return list.find((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
@@ -108,7 +112,11 @@ function buildConfigView(agentId: string): AgentConfigView {
108
112
  /** A field present in the body: `undefined` = not sent (keep), `null` = clear, else new value. */
109
113
  type Patch<T> = { sent: boolean; clear: boolean; value?: T };
110
114
 
111
- function readPatch<T>(body: Record<string, unknown>, key: string, coerce: (raw: unknown) => T | undefined): Patch<T> {
115
+ function readPatch<T>(
116
+ body: Record<string, unknown>,
117
+ key: string,
118
+ coerce: (raw: unknown) => T | undefined,
119
+ ): Patch<T> {
112
120
  if (!(key in body)) return { sent: false, clear: false };
113
121
  const raw = body[key];
114
122
  if (raw === null) return { sent: true, clear: true };
@@ -117,7 +125,7 @@ function readPatch<T>(body: Record<string, unknown>, key: string, coerce: (raw:
117
125
  return { sent: true, clear: false, value };
118
126
  }
119
127
 
120
- function coerceModel(raw: unknown): unknown | undefined {
128
+ function coerceModel(raw: unknown): unknown {
121
129
  if (typeof raw === "string") return raw.trim() || undefined;
122
130
  if (raw && typeof raw === "object") {
123
131
  const primary = readString((raw as Record<string, unknown>).primary);
@@ -134,7 +142,7 @@ function coerceTools(raw: unknown): AgentToolsConfig | undefined {
134
142
 
135
143
  /** Skills: array (incl. empty = disable all) only; non-arrays are rejected upstream. */
136
144
  function coerceSkills(raw: unknown): string[] | undefined {
137
- return Array.isArray(raw) ? readStringArray(raw) ?? [] : undefined;
145
+ return Array.isArray(raw) ? (readStringArray(raw) ?? []) : undefined;
138
146
  }
139
147
 
140
148
  // --- handler -----------------------------------------------------------------
@@ -167,10 +175,14 @@ export async function handleAgentConfig(
167
175
  const skills = readPatch(body, "skills", coerceSkills);
168
176
 
169
177
  if ("skills" in body && body.skills !== null && !Array.isArray(body.skills)) {
170
- return json(res, 400, { error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults" });
178
+ return json(res, 400, {
179
+ error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults",
180
+ });
171
181
  }
172
182
  if (!model.sent && !thinkingDefault.sent && !tools.sent && !skills.sent) {
173
- return json(res, 400, { error: "No editable fields provided (model, thinkingDefault, tools, skills)" });
183
+ return json(res, 400, {
184
+ error: "No editable fields provided (model, thinkingDefault, tools, skills)",
185
+ });
174
186
  }
175
187
 
176
188
  const upgrade = getUpgradeRuntime();
@@ -184,7 +196,9 @@ export async function handleAgentConfig(
184
196
  const draft = draftRaw as Record<string, unknown>;
185
197
  const agents = (draft.agents ??= {}) as Record<string, unknown>;
186
198
  const list = (agents.list ??= []) as Array<Record<string, unknown>>;
187
- let entry = list.find((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
199
+ let entry = list.find(
200
+ (a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId,
201
+ );
188
202
  if (!entry) {
189
203
  // Implicit agent (e.g. "main") with no list entry yet — create a bare one.
190
204
  // Never set `default: true`: that would change default-agent resolution.
@@ -141,11 +141,7 @@ describe("handleAgentsList", () => {
141
141
  it("defaults to the first entry when none is marked default and dedups ids", async () => {
142
142
  setConfig({
143
143
  agents: {
144
- list: [
145
- { id: "alpha" },
146
- { id: "alpha", name: "dup" },
147
- { id: "beta" },
148
- ],
144
+ list: [{ id: "alpha" }, { id: "alpha", name: "dup" }, { id: "beta" }],
149
145
  },
150
146
  });
151
147
  const res = new MockRes();
@@ -19,8 +19,14 @@ class MockRes extends EventEmitter {
19
19
  }
20
20
  }
21
21
 
22
- function mockReq(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
23
- const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
22
+ function mockReq(
23
+ method: string,
24
+ headers: Record<string, string> = {},
25
+ ): PassThrough & { method: string; headers: Record<string, string> } {
26
+ const stream = new PassThrough() as unknown as PassThrough & {
27
+ method: string;
28
+ headers: Record<string, string>;
29
+ };
24
30
  stream.method = method;
25
31
  stream.headers = headers;
26
32
  return stream;
@@ -63,7 +69,10 @@ describe("handleCancel", () => {
63
69
  req.end(JSON.stringify({ sessionKey: "sk-1" }));
64
70
  await p;
65
71
  expect((res as unknown as MockRes).statusCode).toBe(200);
66
- expect(JSON.parse((res as unknown as MockRes).body)).toMatchObject({ ok: true, sessionKey: "sk-1" });
72
+ expect(JSON.parse((res as unknown as MockRes).body)).toMatchObject({
73
+ ok: true,
74
+ sessionKey: "sk-1",
75
+ });
67
76
  });
68
77
 
69
78
  it("untracks run by runId fallback under Vitest (abort skipped)", async () => {
@@ -25,14 +25,16 @@ export async function handleCancel(req: IncomingMessage, res: ServerResponse): P
25
25
  // back-compat fallback for older apps — resolve it to a sessionKey via the run route.
26
26
  const sessionKey =
27
27
  (typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "") ||
28
- (runId ? getRunRoute(runId)?.sessionKey?.trim() ?? "" : "");
28
+ (runId ? (getRunRoute(runId)?.sessionKey?.trim() ?? "") : "");
29
29
  if (!sessionKey && !runId) {
30
30
  res.statusCode = 400;
31
31
  res.setHeader("Content-Type", "application/json");
32
32
  res.end(JSON.stringify({ error: "Missing sessionKey or runId" }));
33
33
  return true;
34
34
  }
35
- const result = sessionKey ? await abortRunForSessionKey(sessionKey) : { aborted: false, drained: false };
35
+ const result = sessionKey
36
+ ? await abortRunForSessionKey(sessionKey)
37
+ : { aborted: false, drained: false };
36
38
  if (runId) sseEmitter.untrackRun(runId);
37
39
  res.statusCode = 200;
38
40
  res.setHeader("Content-Type", "application/json");
@@ -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)) {
@@ -28,7 +28,11 @@ afterEach(() => {
28
28
 
29
29
  describe("attachment original filename survives a gateway restart", () => {
30
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");
31
+ const stored = storeFile(
32
+ Buffer.from("%PDF-1.4 fake"),
33
+ "Quarterly Report.pdf",
34
+ "application/pdf",
35
+ );
32
36
  expect(stored.urlToken).not.toBe("Quarterly Report.pdf");
33
37
 
34
38
  const disk = readAttachmentFileFromDisk(stored.urlToken);
@@ -57,7 +61,9 @@ describe("attachment original filename survives a gateway restart", () => {
57
61
  const stored = storeFile(Buffer.from("x"), "doc.docx", "application/octet-stream");
58
62
  clearFileIndexForTest();
59
63
 
60
- const publicUrl = fridayFilesPublicUrl(`/friday-next/files/${encodeURIComponent(stored.urlToken)}`);
64
+ const publicUrl = fridayFilesPublicUrl(
65
+ `/friday-next/files/${encodeURIComponent(stored.urlToken)}`,
66
+ );
61
67
  expect(publicUrl).toBe(`/friday-next/files/${encodeURIComponent(stored.urlToken)}`);
62
68
  });
63
69
 
@@ -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");
@@ -112,7 +114,11 @@ function writeAttachmentMetaSidecar(urlToken: string, filename: string, mimeType
112
114
  * is unrecoverable. We stash it here (keyed by the inbound basename, reusing the sidecar
113
115
  * scheme but inside our own attachments dir) at send time, while we still know it.
114
116
  */
115
- export function rememberInboundMediaName(inboundPath: string, filename: string, mimeType: string): void {
117
+ export function rememberInboundMediaName(
118
+ inboundPath: string,
119
+ filename: string,
120
+ mimeType: string,
121
+ ): void {
116
122
  const key = path.basename(inboundPath);
117
123
  const name = filename.trim();
118
124
  if (!key || !name) return;
@@ -191,7 +197,10 @@ export function normalizeAgentMediaPath(raw: string): string {
191
197
  return s;
192
198
  }
193
199
 
194
- function copyLocalFileToAttachments(sourcePath: string, originalFilename?: string): StoredFile | null {
200
+ function copyLocalFileToAttachments(
201
+ sourcePath: string,
202
+ originalFilename?: string,
203
+ ): StoredFile | null {
195
204
  const resolvedPath = normalizeAgentMediaPath(sourcePath);
196
205
  const diskBasename = path.basename(resolvedPath);
197
206
  // Prefer the caller-supplied original name (recovered from an inbound sidecar); fall
@@ -211,7 +220,9 @@ function copyLocalFileToAttachments(sourcePath: string, originalFilename?: strin
211
220
  // Fallback to read+write so attachment persistence still works.
212
221
  const raw = fs.readFileSync(resolvedPath);
213
222
  fs.writeFileSync(storedPath, raw);
214
- logger.warn(`copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`);
223
+ logger.warn(
224
+ `copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`,
225
+ );
215
226
  }
216
227
  const stat = fs.statSync(storedPath);
217
228
  const mimeType = guessMimeType(filename);
@@ -246,7 +257,7 @@ export function storeFile(buffer: Buffer, filename: string, mimeType: string): S
246
257
  try {
247
258
  fs.writeFileSync(storedPath, buffer);
248
259
  } catch (err) {
249
- throw new Error(`Failed to store file: ${String(err)}`);
260
+ throw new Error(`Failed to store file: ${String(err)}`, { cause: err });
250
261
  }
251
262
 
252
263
  const file: StoredFile = {
@@ -314,7 +325,11 @@ export function getExternalFileSourceByUrlToken(token: string): string | undefin
314
325
  /**
315
326
  * Read a file as a Buffer with its MIME type (by id or urlToken).
316
327
  */
317
- export function readFile(id: string): { buffer: Buffer | null; mimeType: string; filename?: string } {
328
+ export function readFile(id: string): {
329
+ buffer: Buffer | null;
330
+ mimeType: string;
331
+ filename?: string;
332
+ } {
318
333
  const file = resolveStoredFile(id);
319
334
  if (!file) return { buffer: null, mimeType: "application/octet-stream" };
320
335
  try {
@@ -400,7 +415,6 @@ export function resolveMediaAttachment(localPath: string): ResolvedAttachment |
400
415
  };
401
416
  }
402
417
 
403
-
404
418
  /**
405
419
  * Guess MIME type from filename extension.
406
420
  */