ework-daemon 0.4.63 → 0.4.64
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/package.json +1 -1
- package/src/attachments.ts +102 -0
- package/src/opencode.ts +22 -1
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ework-web attachment links: `[name](/attachments/<uuid>)` in issue bodies
|
|
5
|
+
* and comments. The web requires auth on that route, but the daemon's Gitea
|
|
6
|
+
* PAT is accepted (same auth surface as cookies), so the daemon downloads
|
|
7
|
+
* attachments for the agent instead of teaching it to curl with tokens.
|
|
8
|
+
*/
|
|
9
|
+
export const ATTACHMENT_LINK_RE = /\/attachments\/([0-9a-fA-F-]{36})/g;
|
|
10
|
+
|
|
11
|
+
export interface DownloadedAttachment {
|
|
12
|
+
uuid: string;
|
|
13
|
+
filename: string;
|
|
14
|
+
size: number;
|
|
15
|
+
/** Reason the referenced attachment was not downloaded, if any. */
|
|
16
|
+
skipped?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
function sanitizeFilename(raw: string, fallback: string): string {
|
|
22
|
+
const cleaned = raw
|
|
23
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
24
|
+
.replace(/[/\\]/g, "_")
|
|
25
|
+
.replace(/^\.+/, "")
|
|
26
|
+
.trim();
|
|
27
|
+
return cleaned || fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Download every /attachments/<uuid> referenced in `content` into
|
|
32
|
+
* `<workdir>/attachments/` using the daemon's Gitea PAT. Best-effort:
|
|
33
|
+
* failures are reported per-attachment via `skipped`, never thrown.
|
|
34
|
+
*/
|
|
35
|
+
export async function downloadIssueAttachments(
|
|
36
|
+
content: string,
|
|
37
|
+
baseUrl: string,
|
|
38
|
+
token: string,
|
|
39
|
+
workdir: string,
|
|
40
|
+
): Promise<DownloadedAttachment[]> {
|
|
41
|
+
const uuids = new Set<string>();
|
|
42
|
+
for (const m of content.matchAll(ATTACHMENT_LINK_RE)) {
|
|
43
|
+
if (m[1]) uuids.add(m[1]);
|
|
44
|
+
}
|
|
45
|
+
if (uuids.size === 0) return [];
|
|
46
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
47
|
+
const out: DownloadedAttachment[] = [];
|
|
48
|
+
for (const uuid of uuids) {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${base}/attachments/${uuid}`, {
|
|
51
|
+
headers: { authorization: `token ${token}` },
|
|
52
|
+
signal: AbortSignal.timeout(120_000),
|
|
53
|
+
// Auth failures surface as 302 -> login; treat redirects as failures
|
|
54
|
+
// instead of following them to the login page.
|
|
55
|
+
redirect: "manual",
|
|
56
|
+
});
|
|
57
|
+
if (res.status >= 300 && res.status < 400) {
|
|
58
|
+
out.push({ uuid, filename: "", size: 0, skipped: `HTTP ${res.status} (auth)` });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
out.push({ uuid, filename: "", size: 0, skipped: `HTTP ${res.status}` });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const lenHeader = Number(res.headers.get("content-length") ?? "0");
|
|
66
|
+
if (lenHeader > MAX_ATTACHMENT_BYTES) {
|
|
67
|
+
out.push({ uuid, filename: "", size: lenHeader, skipped: "too large" });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const cd = res.headers.get("content-disposition") ?? "";
|
|
71
|
+
const nameMatch = cd.match(/filename="([^"]*)"/);
|
|
72
|
+
const filename = sanitizeFilename(nameMatch?.[1] ?? "", `${uuid}.bin`);
|
|
73
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
74
|
+
if (buf.length > MAX_ATTACHMENT_BYTES) {
|
|
75
|
+
out.push({ uuid, filename, size: buf.length, skipped: "too large" });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const dir = `${workdir}/attachments`;
|
|
79
|
+
await mkdir(dir, { recursive: true });
|
|
80
|
+
await writeFile(`${dir}/${filename}`, buf);
|
|
81
|
+
out.push({ uuid, filename, size: buf.length });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
out.push({
|
|
84
|
+
uuid,
|
|
85
|
+
filename: "",
|
|
86
|
+
size: 0,
|
|
87
|
+
skipped: e instanceof Error ? e.message : "download error",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function attachmentNote(atts: DownloadedAttachment[]): string {
|
|
95
|
+
if (atts.length === 0) return "";
|
|
96
|
+
const lines = atts.map((a) =>
|
|
97
|
+
a.skipped
|
|
98
|
+
? `- ${a.filename || a.uuid}: 未能下载(${a.skipped})`
|
|
99
|
+
: `- attachments/${a.filename}(${(a.size / 1024).toFixed(1)} KB)`,
|
|
100
|
+
);
|
|
101
|
+
return `\n\n[system] 本条消息引用的附件已由系统代为下载到工作目录的 attachments/ 目录:\n${lines.join("\n")}\n请直接用文件工具读取分析(日志类文件建议分段/grep 查看)。`;
|
|
102
|
+
}
|
package/src/opencode.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { formatKey, parseKey } from "./trackers/types";
|
|
|
10
10
|
import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
|
|
11
11
|
import { OpencodeBackend } from "./runtime/opencode-backend";
|
|
12
12
|
import { PiBackend } from "./runtime/pi-backend";
|
|
13
|
+
import { downloadIssueAttachments, attachmentNote } from "./attachments";
|
|
13
14
|
|
|
14
15
|
// ─── Types ───
|
|
15
16
|
|
|
@@ -1278,13 +1279,33 @@ export class Engine {
|
|
|
1278
1279
|
|
|
1279
1280
|
const childEnv = spawnEnvFor(process.env, this.hookEnvFor(issue, session, workdir), workdir);
|
|
1280
1281
|
|
|
1282
|
+
let spawnPrompt = msg.content;
|
|
1283
|
+
try {
|
|
1284
|
+
const atts = await downloadIssueAttachments(
|
|
1285
|
+
msg.content,
|
|
1286
|
+
this.cfg.gitea.url,
|
|
1287
|
+
this.cfg.gitea.token,
|
|
1288
|
+
workdir,
|
|
1289
|
+
);
|
|
1290
|
+
if (atts.length > 0) {
|
|
1291
|
+
log.info(
|
|
1292
|
+
`engine: attachments for ${k}: ${atts
|
|
1293
|
+
.map((a) => `${a.filename || a.uuid}${a.skipped ? ` (skip: ${a.skipped})` : ""}`)
|
|
1294
|
+
.join(", ")}`,
|
|
1295
|
+
);
|
|
1296
|
+
spawnPrompt += attachmentNote(atts);
|
|
1297
|
+
}
|
|
1298
|
+
} catch {
|
|
1299
|
+
// Best-effort: the agent still has the raw message without files.
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1281
1302
|
let exitCode: number | null = null;
|
|
1282
1303
|
|
|
1283
1304
|
try {
|
|
1284
1305
|
const handle = await backend.spawn(
|
|
1285
1306
|
{
|
|
1286
1307
|
workdir,
|
|
1287
|
-
prompt:
|
|
1308
|
+
prompt: spawnPrompt,
|
|
1288
1309
|
model: model || undefined,
|
|
1289
1310
|
resumeSessionId: resumeSessionId || undefined,
|
|
1290
1311
|
env: childEnv,
|