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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.62",
3
+ "version": "0.4.64",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -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 = { ...process.env, ...this.hookEnvFor(issue, session, workdir) };
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: msg.content,
1308
+ prompt: spawnPrompt,
1271
1309
  model: model || undefined,
1272
1310
  resumeSessionId: resumeSessionId || undefined,
1273
1311
  env: childEnv,