ework-daemon 0.4.62 → 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 +40 -2
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
|
|
|
@@ -330,6 +331,23 @@ export function wakePolicySkips(
|
|
|
330
331
|
return null;
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
// Per-issue npm prefix: `npm install -g <pkg>` inside a session lands in the issue's
|
|
335
|
+
// workdir instead of the system global, so concurrent agents debugging different
|
|
336
|
+
// issues cannot clobber each other's global installs (nor poison the shared daemon env).
|
|
337
|
+
export function spawnEnvFor(
|
|
338
|
+
base: Record<string, string | undefined>,
|
|
339
|
+
hooks: Record<string, string | undefined>,
|
|
340
|
+
workdir: string,
|
|
341
|
+
): Record<string, string> {
|
|
342
|
+
const npmHome = `${workdir}/.npm-global`;
|
|
343
|
+
return {
|
|
344
|
+
...base,
|
|
345
|
+
...hooks,
|
|
346
|
+
NPM_CONFIG_PREFIX: npmHome,
|
|
347
|
+
PATH: `${npmHome}/bin:${base.PATH ?? ""}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
333
351
|
export class Engine {
|
|
334
352
|
private cfg: Config;
|
|
335
353
|
private store: Store;
|
|
@@ -1259,7 +1277,27 @@ export class Engine {
|
|
|
1259
1277
|
try { await tracker.setReaction(ref, msg.sourceCommentId, "eyes"); } catch { /* non-critical */ }
|
|
1260
1278
|
}
|
|
1261
1279
|
|
|
1262
|
-
const childEnv =
|
|
1280
|
+
const childEnv = spawnEnvFor(process.env, this.hookEnvFor(issue, session, workdir), workdir);
|
|
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
|
+
}
|
|
1263
1301
|
|
|
1264
1302
|
let exitCode: number | null = null;
|
|
1265
1303
|
|
|
@@ -1267,7 +1305,7 @@ export class Engine {
|
|
|
1267
1305
|
const handle = await backend.spawn(
|
|
1268
1306
|
{
|
|
1269
1307
|
workdir,
|
|
1270
|
-
prompt:
|
|
1308
|
+
prompt: spawnPrompt,
|
|
1271
1309
|
model: model || undefined,
|
|
1272
1310
|
resumeSessionId: resumeSessionId || undefined,
|
|
1273
1311
|
env: childEnv,
|