@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.
- package/dist/index.js +1 -1
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/subagent-registry.js +0 -3
- package/dist/src/channel-actions.js +3 -1
- package/dist/src/channel.js +0 -2
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.js +10 -4
- package/dist/src/http/handlers/cancel.js +4 -2
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.js +1 -1
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +25 -11
- package/dist/src/http/handlers/models-list.js +1 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/server.js +4 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +5 -4
- package/dist/src/skills-discovery.js +27 -22
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/tool-catalog.js +2 -3
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +3 -1
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +2 -3
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.ts +9 -2
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +10 -4
- package/src/channel-actions.ts +3 -1
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +121 -123
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +10 -3
- package/src/http/handlers/agent-config.ts +22 -8
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/cancel.test.ts +12 -3
- package/src/http/handlers/cancel.ts +4 -2
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +8 -2
- package/src/http/handlers/files.ts +21 -7
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +57 -19
- package/src/http/handlers/models-list.ts +14 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +9 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +72 -15
- package/src/link-preview/ssrf-guard.ts +2 -1
- package/src/media-fetch.test.ts +7 -2
- package/src/media-fetch.ts +1 -2
- package/src/openclaw.d.ts +16 -9
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +14 -10
- package/src/skills-discovery.ts +43 -27
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.ts +3 -1
- package/src/tool-catalog.ts +16 -7
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +5 -1
- 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
|
|
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:
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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> = {
|
|
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> = {
|
|
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([
|
|
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
|
|
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
|
|
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>(
|
|
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
|
|
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, {
|
|
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, {
|
|
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(
|
|
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(
|
|
23
|
-
|
|
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({
|
|
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
|
|
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(
|
|
32
|
-
|
|
31
|
+
function mockReq(
|
|
32
|
+
method: string,
|
|
33
|
+
headers: Record<string, string> = {},
|
|
34
|
+
): PassThrough & { method: string; headers: Record<string, string> } {
|
|
35
|
+
const stream = new PassThrough() as unknown as PassThrough & {
|
|
36
|
+
method: string;
|
|
37
|
+
headers: Record<string, string>;
|
|
38
|
+
};
|
|
33
39
|
stream.method = method;
|
|
34
40
|
stream.headers = headers;
|
|
35
41
|
return stream;
|
|
@@ -92,7 +98,10 @@ describe("handleDeviceApprove", () => {
|
|
|
92
98
|
});
|
|
93
99
|
|
|
94
100
|
it("returns 404 when listDevicePairing returns data without matching device", async () => {
|
|
95
|
-
mockList.mockResolvedValueOnce({
|
|
101
|
+
mockList.mockResolvedValueOnce({
|
|
102
|
+
pending: [{ requestId: "x", deviceId: "UNMATCHED" }],
|
|
103
|
+
paired: [],
|
|
104
|
+
});
|
|
96
105
|
|
|
97
106
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
98
107
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -67,21 +67,25 @@ export async function handleDeviceApprove(
|
|
|
67
67
|
if (pairedDevice) {
|
|
68
68
|
res.statusCode = 200;
|
|
69
69
|
res.setHeader("Content-Type", "application/json");
|
|
70
|
-
res.end(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
res.end(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
ok: true,
|
|
73
|
+
deviceId: normalizedDeviceId,
|
|
74
|
+
alreadyApproved: true,
|
|
75
|
+
approvedAtMs: (pairedDevice as any).approvedAtMs,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
76
78
|
return true;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
res.statusCode = 404;
|
|
80
82
|
res.setHeader("Content-Type", "application/json");
|
|
81
|
-
res.end(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
res.end(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
error: "No pending device found for this deviceId",
|
|
86
|
+
deviceId: normalizedDeviceId,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
85
89
|
return true;
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -95,10 +99,12 @@ export async function handleDeviceApprove(
|
|
|
95
99
|
log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
96
100
|
res.statusCode = 502;
|
|
97
101
|
res.setHeader("Content-Type", "application/json");
|
|
98
|
-
res.end(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
res.end(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
error: "Device approval failed",
|
|
105
|
+
detail: err instanceof Error ? err.message : "Unknown error",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
102
108
|
return true;
|
|
103
109
|
}
|
|
104
110
|
|
|
@@ -112,17 +118,23 @@ export async function handleDeviceApprove(
|
|
|
112
118
|
if (approved.status === "forbidden") {
|
|
113
119
|
res.statusCode = 403;
|
|
114
120
|
res.setHeader("Content-Type", "application/json");
|
|
115
|
-
res.end(
|
|
121
|
+
res.end(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
error: `Device approval forbidden: ${(approved as any).reason ?? "unknown"}`,
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
116
126
|
return true;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
res.statusCode = 200;
|
|
120
130
|
res.setHeader("Content-Type", "application/json");
|
|
121
|
-
res.end(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
res.end(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
ok: true,
|
|
134
|
+
deviceId: normalizedDeviceId,
|
|
135
|
+
requestId: approved.requestId,
|
|
136
|
+
approvedAtMs: (approved as any).device?.approvedAtMs,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
127
139
|
return true;
|
|
128
140
|
}
|
|
@@ -63,7 +63,10 @@ function tryDecodeURIComponent(segment: string): string | null {
|
|
|
63
63
|
*/
|
|
64
64
|
function contentDispositionInline(filename: string): string {
|
|
65
65
|
const base =
|
|
66
|
-
path
|
|
66
|
+
path
|
|
67
|
+
.basename(filename)
|
|
68
|
+
.replace(/[\r\n"]/g, "_")
|
|
69
|
+
.replace(/\\/g, "_") || "file";
|
|
67
70
|
const ascii = /^[\x20-\x7E]*$/.test(base) ? base : "file";
|
|
68
71
|
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base)}`;
|
|
69
72
|
}
|
|
@@ -82,9 +85,7 @@ function sendBuffer(
|
|
|
82
85
|
const disposition = contentDispositionInline(filename);
|
|
83
86
|
const rangeRaw = req.headers.range;
|
|
84
87
|
const range =
|
|
85
|
-
typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim())
|
|
86
|
-
? rangeRaw.trim()
|
|
87
|
-
: undefined;
|
|
88
|
+
typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim()) ? rangeRaw.trim() : undefined;
|
|
88
89
|
|
|
89
90
|
res.setHeader("Accept-Ranges", "bytes");
|
|
90
91
|
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
@@ -110,7 +111,7 @@ function sendBuffer(
|
|
|
110
111
|
let end = total - 1;
|
|
111
112
|
|
|
112
113
|
if (m[1] === "" && m[2] !== "") {
|
|
113
|
-
const suffixLen = parseInt(m[2]
|
|
114
|
+
const suffixLen = parseInt(m[2], 10);
|
|
114
115
|
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
|
|
115
116
|
res.statusCode = 200;
|
|
116
117
|
res.setHeader("Content-Length", String(total));
|
|
@@ -120,11 +121,11 @@ function sendBuffer(
|
|
|
120
121
|
start = Math.max(0, total - suffixLen);
|
|
121
122
|
end = total - 1;
|
|
122
123
|
} else if (m[1] !== "" && m[2] === "") {
|
|
123
|
-
start = parseInt(m[1]
|
|
124
|
+
start = parseInt(m[1], 10);
|
|
124
125
|
end = total - 1;
|
|
125
126
|
} else if (m[1] !== "" && m[2] !== "") {
|
|
126
|
-
start = parseInt(m[1]
|
|
127
|
-
end = parseInt(m[2]
|
|
127
|
+
start = parseInt(m[1], 10);
|
|
128
|
+
end = parseInt(m[2], 10);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
|
|
@@ -190,7 +191,13 @@ export async function handleFilesDownload(
|
|
|
190
191
|
// 1.2 Plugin-root attachments/ (survives gateway restarts; basename = URL token)
|
|
191
192
|
const fromAttachments = readAttachmentFileFromDisk(fileToken);
|
|
192
193
|
if (fromAttachments) {
|
|
193
|
-
sendBuffer(
|
|
194
|
+
sendBuffer(
|
|
195
|
+
req,
|
|
196
|
+
res,
|
|
197
|
+
fromAttachments.buffer,
|
|
198
|
+
fromAttachments.mimeType,
|
|
199
|
+
fromAttachments.filename,
|
|
200
|
+
);
|
|
194
201
|
return true;
|
|
195
202
|
}
|
|
196
203
|
|
|
@@ -211,10 +218,7 @@ export async function handleFilesDownload(
|
|
|
211
218
|
// fileId may include an extension (e.g. "uuid.png") — strip it to get the base id
|
|
212
219
|
const baseId = fileToken.replace(/\.[^.]+$/, "");
|
|
213
220
|
const mediaDir = path.join(os.homedir(), ".openclaw", "media", "inbound");
|
|
214
|
-
const candidates = [
|
|
215
|
-
path.join(mediaDir, baseId),
|
|
216
|
-
path.join(mediaDir, fileToken),
|
|
217
|
-
];
|
|
221
|
+
const candidates = [path.join(mediaDir, baseId), path.join(mediaDir, fileToken)];
|
|
218
222
|
|
|
219
223
|
for (const filePath of candidates) {
|
|
220
224
|
if (fs.existsSync(filePath)) {
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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): {
|
|
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
|
*/
|