@stigmer/runner 3.11.1-dev.20260812192248 → 3.12.1

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 (108) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
  3. package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  6. package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
  7. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.d.ts +6 -2
  9. package/dist/activities/execute-cursor/index.js +16 -32
  10. package/dist/activities/execute-cursor/index.js.map +1 -1
  11. package/dist/activities/execute-cursor/prompt-builder.d.ts +15 -11
  12. package/dist/activities/execute-cursor/prompt-builder.js +20 -10
  13. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +34 -8
  15. package/dist/activities/execute-deep-agent/attachment-injector.js +111 -106
  16. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/index.js +0 -15
  18. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
  20. package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
  21. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  22. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
  23. package/dist/activities/execute-deep-agent/prompt-builder.js +25 -8
  24. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
  26. package/dist/activities/execute-deep-agent/setup.js +13 -45
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/config.d.ts +6 -5
  29. package/dist/config.js.map +1 -1
  30. package/dist/middleware/path-normalization.d.ts +15 -2
  31. package/dist/middleware/path-normalization.js +39 -5
  32. package/dist/middleware/path-normalization.js.map +1 -1
  33. package/dist/shared/artifact-storage.d.ts +11 -0
  34. package/dist/shared/artifact-storage.js +2 -0
  35. package/dist/shared/artifact-storage.js.map +1 -1
  36. package/dist/shared/attachment-download-urls.d.ts +62 -0
  37. package/dist/shared/attachment-download-urls.js +79 -0
  38. package/dist/shared/attachment-download-urls.js.map +1 -0
  39. package/dist/shared/channel-attachment.d.ts +1 -1
  40. package/dist/shared/channel-attachment.js +1 -1
  41. package/dist/shared/conversation-attachment.d.ts +2 -3
  42. package/dist/shared/conversation-attachment.js +2 -3
  43. package/dist/shared/conversation-attachment.js.map +1 -1
  44. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  45. package/dist/shared/mcp-enabled-tools.js +6 -2
  46. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  47. package/dist/shared/mcp-transport-guard.d.ts +2 -2
  48. package/dist/shared/mcp-transport-guard.js +2 -2
  49. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  50. package/dist/shared/plan-mode-permissions.js +56 -12
  51. package/dist/shared/plan-mode-permissions.js.map +1 -1
  52. package/dist/shared/synthesized-attachment.d.ts +4 -4
  53. package/dist/shared/synthesized-attachment.js +4 -4
  54. package/dist/shared/zip-extract.d.ts +24 -6
  55. package/dist/shared/zip-extract.js +31 -90
  56. package/dist/shared/zip-extract.js.map +1 -1
  57. package/dist/shared/zip-structure.d.ts +61 -0
  58. package/dist/shared/zip-structure.js +128 -0
  59. package/dist/shared/zip-structure.js.map +1 -0
  60. package/package.json +2 -2
  61. package/src/__test-utils__/fake-artifact-storage.ts +10 -1
  62. package/src/__test-utils__/zip-fixtures.ts +206 -0
  63. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
  64. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +62 -29
  65. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
  66. package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
  67. package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
  68. package/src/activities/execute-cursor/index.ts +22 -37
  69. package/src/activities/execute-cursor/prompt-builder.ts +38 -22
  70. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +270 -126
  71. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
  72. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
  73. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
  74. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
  75. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  76. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +78 -2
  77. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
  78. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  79. package/src/activities/execute-deep-agent/attachment-injector.ts +166 -143
  80. package/src/activities/execute-deep-agent/index.ts +0 -19
  81. package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
  82. package/src/activities/execute-deep-agent/prompt-builder.ts +38 -16
  83. package/src/activities/execute-deep-agent/setup.ts +13 -68
  84. package/src/config.ts +6 -5
  85. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  86. package/src/middleware/path-normalization.ts +42 -5
  87. package/src/shared/__tests__/artifact-storage.test.ts +9 -0
  88. package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
  89. package/src/shared/__tests__/channel-attachment.test.ts +2 -2
  90. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  91. package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
  92. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  93. package/src/shared/artifact-storage.ts +11 -0
  94. package/src/shared/attachment-download-urls.ts +98 -0
  95. package/src/shared/channel-attachment.ts +1 -1
  96. package/src/shared/conversation-attachment.ts +2 -3
  97. package/src/shared/mcp-enabled-tools.ts +6 -2
  98. package/src/shared/mcp-transport-guard.ts +2 -2
  99. package/src/shared/plan-mode-permissions.ts +59 -12
  100. package/src/shared/synthesized-attachment.ts +4 -4
  101. package/src/shared/zip-extract.ts +35 -117
  102. package/src/shared/zip-structure.ts +181 -0
  103. package/dist/shared/datastore-attachment.d.ts +0 -99
  104. package/dist/shared/datastore-attachment.js +0 -184
  105. package/dist/shared/datastore-attachment.js.map +0 -1
  106. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
  107. package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
  108. package/src/shared/datastore-attachment.ts +0 -209
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Shared ZIP fixture builder for runner unit tests.
3
+ *
4
+ * Emits complete, real-shaped archives — local file headers (optionally
5
+ * streaming-style, the Go stdlib writer's default), payloads, central
6
+ * directory, and EOCD — because that is the only shape that can reach the
7
+ * runner: both editions' skill push gates validate artifacts with
8
+ * central-directory-based readers (see zip-extract.ts's module doc and
9
+ * design record 017). Earlier fixtures emitted local headers only, a
10
+ * shape no real ZIP writer produces, and were coupled to the old parser's
11
+ * front-to-back walk.
12
+ *
13
+ * Mirrors the conformance suite's `zipFilesStreaming()`
14
+ * (test/conformance/src/support/skills.ts) so unit fixtures and
15
+ * cross-edition fixtures share one shape.
16
+ *
17
+ * Lives in src/__test-utils__/ so `tsc --noEmit` covers it while
18
+ * tsconfig.build.json keeps it out of dist/.
19
+ */
20
+
21
+ import { deflateRawSync } from "node:zlib";
22
+
23
+ export interface ZipFixtureFile {
24
+ name: string;
25
+ /** Text content is UTF-8 encoded; pass bytes directly for binary payloads. */
26
+ content: string | Uint8Array;
27
+ /** Compression method for the entry. Defaults to stored. */
28
+ method?: "stored" | "deflated";
29
+ /**
30
+ * Emit the entry the way Go's archive/zip does by default: general-purpose
31
+ * flag bit 3 set, zeroed sizes in the local header, and a trailing data
32
+ * descriptor (with the conventional signature) carrying the real values.
33
+ */
34
+ streaming?: boolean;
35
+ /**
36
+ * Lie about the entry's uncompressed size everywhere the writer would
37
+ * declare it (local header, data descriptor, central directory). Models a
38
+ * crafted archive whose declarations disagree with its actual payload —
39
+ * the input class the attachment injector's declared-size enforcement
40
+ * exists to reject (issue #567).
41
+ */
42
+ declaredUncompressedSize?: number;
43
+ }
44
+
45
+ export interface ZipFixtureOptions {
46
+ /** Trailing archive comment appended after the EOCD record. */
47
+ comment?: string;
48
+ /**
49
+ * Drop the central directory and EOCD, leaving only local headers and
50
+ * payloads. No real ZIP writer produces this shape — it models a download
51
+ * truncated before the archive's index.
52
+ */
53
+ omitCentralDirectory?: boolean;
54
+ }
55
+
56
+ /** Build a ZIP archive from path + content pairs. */
57
+ export function buildZip(files: ZipFixtureFile[], options?: ZipFixtureOptions): Uint8Array {
58
+ const out = new ByteWriter();
59
+
60
+ interface WrittenEntry {
61
+ nameBytes: Uint8Array;
62
+ payload: Uint8Array;
63
+ declaredUncompressed: number;
64
+ crc: number;
65
+ flags: number;
66
+ method: number;
67
+ localHeaderOffset: number;
68
+ }
69
+ const written: WrittenEntry[] = [];
70
+
71
+ for (const file of files) {
72
+ const contentBytes =
73
+ typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content;
74
+ const deflated = file.method === "deflated";
75
+ const payload = deflated ? new Uint8Array(deflateRawSync(contentBytes)) : contentBytes;
76
+ const entry: WrittenEntry = {
77
+ nameBytes: new TextEncoder().encode(file.name),
78
+ payload,
79
+ declaredUncompressed: file.declaredUncompressedSize ?? contentBytes.length,
80
+ crc: crc32(contentBytes),
81
+ flags: file.streaming ? 0x0008 : 0,
82
+ method: deflated ? 8 : 0,
83
+ localHeaderOffset: out.length,
84
+ };
85
+ written.push(entry);
86
+
87
+ out.u32(0x04034b50); // local file header signature
88
+ out.u16(20); // version needed to extract
89
+ out.u16(entry.flags);
90
+ out.u16(entry.method);
91
+ out.u16(0); // mod time
92
+ out.u16(0); // mod date
93
+ out.u32(file.streaming ? 0 : entry.crc);
94
+ out.u32(file.streaming ? 0 : payload.length);
95
+ out.u32(file.streaming ? 0 : entry.declaredUncompressed);
96
+ out.u16(entry.nameBytes.length);
97
+ out.u16(0); // extra field length
98
+ out.raw(entry.nameBytes);
99
+ out.raw(payload);
100
+
101
+ if (file.streaming) {
102
+ out.u32(0x08074b50); // data descriptor signature (Go writes it)
103
+ out.u32(entry.crc);
104
+ out.u32(payload.length);
105
+ out.u32(entry.declaredUncompressed);
106
+ }
107
+ }
108
+
109
+ if (options?.omitCentralDirectory) {
110
+ return out.toUint8Array();
111
+ }
112
+
113
+ const centralDirectoryOffset = out.length;
114
+ for (const entry of written) {
115
+ out.u32(0x02014b50); // central directory record signature
116
+ out.u16(20); // version made by
117
+ out.u16(20); // version needed to extract
118
+ out.u16(entry.flags);
119
+ out.u16(entry.method);
120
+ out.u16(0); // mod time
121
+ out.u16(0); // mod date
122
+ out.u32(entry.crc);
123
+ out.u32(entry.payload.length);
124
+ out.u32(entry.declaredUncompressed);
125
+ out.u16(entry.nameBytes.length);
126
+ out.u16(0); // extra field length
127
+ out.u16(0); // comment length
128
+ out.u16(0); // disk number start
129
+ out.u16(0); // internal attributes
130
+ out.u32(0); // external attributes
131
+ out.u32(entry.localHeaderOffset);
132
+ out.raw(entry.nameBytes);
133
+ }
134
+ const centralDirectorySize = out.length - centralDirectoryOffset;
135
+
136
+ const commentBytes = new TextEncoder().encode(options?.comment ?? "");
137
+ out.u32(0x06054b50); // EOCD signature
138
+ out.u16(0); // disk number
139
+ out.u16(0); // disk with central directory
140
+ out.u16(written.length); // entries on this disk
141
+ out.u16(written.length); // total entries
142
+ out.u32(centralDirectorySize);
143
+ out.u32(centralDirectoryOffset);
144
+ out.u16(commentBytes.length);
145
+ out.raw(commentBytes);
146
+
147
+ return out.toUint8Array();
148
+ }
149
+
150
+ // Chunked assembly instead of a number[]-per-byte accumulator: fixtures at
151
+ // the injector's 100 MB zip-bomb limit are built from payload-sized chunks,
152
+ // which a per-byte spread-push cannot survive (argument-count overflow).
153
+ class ByteWriter {
154
+ private readonly chunks: Uint8Array[] = [];
155
+ private size = 0;
156
+
157
+ get length(): number {
158
+ return this.size;
159
+ }
160
+
161
+ u16(v: number): void {
162
+ this.raw(new Uint8Array([v & 0xff, (v >>> 8) & 0xff]));
163
+ }
164
+
165
+ u32(v: number): void {
166
+ this.raw(new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]));
167
+ }
168
+
169
+ raw(b: Uint8Array): void {
170
+ this.chunks.push(b);
171
+ this.size += b.length;
172
+ }
173
+
174
+ toUint8Array(): Uint8Array {
175
+ const result = new Uint8Array(this.size);
176
+ let offset = 0;
177
+ for (const chunk of this.chunks) {
178
+ result.set(chunk, offset);
179
+ offset += chunk.length;
180
+ }
181
+ return result;
182
+ }
183
+ }
184
+
185
+ // Standard CRC-32 (IEEE 802.3, the ZIP checksum), table-driven so fixtures
186
+ // at the injector's 100 MB limit stay cheap to build. Semantically identical
187
+ // to the conformance suite's inline bit-loop helper.
188
+ const CRC_TABLE = (() => {
189
+ const table = new Uint32Array(256);
190
+ for (let n = 0; n < 256; n++) {
191
+ let c = n;
192
+ for (let bit = 0; bit < 8; bit++) {
193
+ c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
194
+ }
195
+ table[n] = c;
196
+ }
197
+ return table;
198
+ })();
199
+
200
+ function crc32(data: Uint8Array): number {
201
+ let crc = 0xffffffff;
202
+ for (let i = 0; i < data.length; i++) {
203
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ data[i]!) & 0xff]!;
204
+ }
205
+ return (crc ^ 0xffffffff) >>> 0;
206
+ }
@@ -78,7 +78,11 @@ describe("resolveAttachments", () => {
78
78
  const result = await resolveAttachments([makeAttachment()], options({ storage }));
79
79
 
80
80
  expect(result).toEqual([
81
- { filename: "plan.md", relativePath: ".stigmer/inputs/plan.md" },
81
+ {
82
+ filename: "plan.md",
83
+ relativePath: ".stigmer/inputs/plan.md",
84
+ downloadUrl: "mem://attachments/01ABC/plan.md",
85
+ },
82
86
  ]);
83
87
  expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
84
88
  });
@@ -130,11 +134,16 @@ describe("resolveAttachments", () => {
130
134
  );
131
135
 
132
136
  expect(result).toEqual([
133
- { filename: "report.pdf", relativePath: ".stigmer/inputs/report.pdf" },
137
+ {
138
+ filename: "report.pdf",
139
+ relativePath: ".stigmer/inputs/report.pdf",
140
+ downloadUrl: "mem://attachments/01AAA/report.pdf",
141
+ },
134
142
  {
135
143
  filename: "report-2.pdf",
136
144
  relativePath: ".stigmer/inputs/report-2.pdf",
137
145
  renamedFrom: "report.pdf",
146
+ downloadUrl: "mem://attachments/01BBB/report.pdf",
138
147
  },
139
148
  ]);
140
149
  expect(readFileSync(join(platformDir, "inputs", "report.pdf"), "utf-8")).toBe("first bytes");
@@ -155,12 +164,15 @@ describe("resolveAttachments", () => {
155
164
  options({ storage }),
156
165
  );
157
166
 
167
+ // The key-less local file lists no URL; the storage twin does — the
168
+ // per-file split the prompt renders.
158
169
  expect(result).toEqual([
159
170
  { filename: "notes.md", relativePath: ".stigmer/inputs/notes.md" },
160
171
  {
161
172
  filename: "notes-2.md",
162
173
  relativePath: ".stigmer/inputs/notes-2.md",
163
174
  renamedFrom: "notes.md",
175
+ downloadUrl: "mem://attachments/01ABC/notes.md",
164
176
  },
165
177
  ]);
166
178
  expect(readFileSync(join(platformDir, "inputs", "notes.md"), "utf-8")).toBe("local copy");
@@ -225,7 +237,11 @@ describe("resolveAttachments", () => {
225
237
  );
226
238
 
227
239
  expect(result).toEqual([
228
- { filename: "evil.md", relativePath: ".stigmer/inputs/evil.md" },
240
+ {
241
+ filename: "evil.md",
242
+ relativePath: ".stigmer/inputs/evil.md",
243
+ downloadUrl: "mem://attachments/01ABC/x",
244
+ },
229
245
  ]);
230
246
  expect(readFileSync(join(platformDir, "inputs", "evil.md"), "utf-8")).toBe("contained");
231
247
  // Nothing escaped two levels up (where `../../evil.md` would have landed).
@@ -247,6 +263,50 @@ describe("resolveAttachments", () => {
247
263
  expect(readFileSync(join(platformDir, "inputs", "evil.txt"), "utf-8")).toBe("local-contained");
248
264
  });
249
265
 
266
+ // ── Download-URL hand-off (issue #532) ───────────────────────────────────
267
+ // The URL is strictly additive (shared/attachment-download-urls.ts): every
268
+ // case below also asserts the file materialized exactly as it would
269
+ // without one.
270
+
271
+ describe("download-URL minting during resolution", () => {
272
+ it("degrades to no URL when the mint fails — file still materialized, turn proceeds", async () => {
273
+ const { storage } = makeInMemoryArtifactStorage();
274
+ await storage.upload("attachments/01ABC/plan.md", Buffer.from("# The Plan"), "text/markdown");
275
+ storage.getDownloadUrl.mockRejectedValueOnce(new Error("presign endpoint unreachable"));
276
+
277
+ const result = await resolveAttachments([makeAttachment()], options({ storage }));
278
+
279
+ expect(result).toEqual([
280
+ { filename: "plan.md", relativePath: ".stigmer/inputs/plan.md" },
281
+ ]);
282
+ expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
283
+ });
284
+
285
+ it("mints a URL on the localPath fast path when an uploaded copy also exists", async () => {
286
+ // The fast path skips storage for the BYTES; the mint rule is
287
+ // branch-independent, so the uploaded copy still backs the URL.
288
+ const srcPath = join(workspaceDir, "src.csv");
289
+ writeFileSync(srcPath, "a,b,c");
290
+ const { storage } = makeInMemoryArtifactStorage();
291
+
292
+ const result = await resolveAttachments(
293
+ [makeAttachment({ filename: "data.csv", storageKey: "attachments/01ABC/data.csv", localPath: srcPath })],
294
+ options({ storage }),
295
+ );
296
+
297
+ expect(result).toEqual([
298
+ {
299
+ filename: "data.csv",
300
+ relativePath: ".stigmer/inputs/data.csv",
301
+ downloadUrl: "mem://attachments/01ABC/data.csv",
302
+ },
303
+ ]);
304
+ // Bytes came off local disk, not storage — the fast path is intact.
305
+ expect(storage.download).not.toHaveBeenCalled();
306
+ expect(readFileSync(join(platformDir, "inputs", "data.csv"), "utf-8")).toBe("a,b,c");
307
+ });
308
+ });
309
+
250
310
  // ── Vision selection (T04) ────────────────────────────────────────────────
251
311
  // Vision is strictly additive: every case below also asserts the file
252
312
  // materialized exactly as it would without a budget.
@@ -9,10 +9,8 @@
9
9
 
10
10
  import { describe, it, expect } from "vitest";
11
11
  import { create } from "@bufbuild/protobuf";
12
- import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
13
12
  import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
14
13
  import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
15
- import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
16
14
 
17
15
  import { appendStructuredOutputDirective, buildPrompt, isHitlReinvocation, primarySendCarriesImages } from "../index.js";
18
16
  import type { BuildPromptInput } from "../index.js";
@@ -89,33 +87,6 @@ describe("buildPrompt", () => {
89
87
  expect(prompt).toContain(USER_MESSAGE);
90
88
  });
91
89
 
92
- it("renders the <available_datastores> section on the first execution (T05)", () => {
93
- const prompt = buildPrompt(
94
- input({
95
- resolution: resolution("local", "created_first_execution"),
96
- datastoreUsages: [
97
- create(DatastoreUsageSchema, {
98
- datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
99
- }),
100
- ],
101
- }),
102
- );
103
- expect(prompt).toContain("<available_datastores>");
104
- expect(prompt).toContain("- clinic");
105
- expect(prompt).toContain("describe_datastore");
106
- // The standing failure-disclosure instruction (issue #325) is this
107
- // harness's ONLY outage coverage: the Cursor SDK connects MCP itself,
108
- // so the runner can never reconcile the live roster here.
109
- expect(prompt).toContain("do not answer from memory");
110
- });
111
-
112
- it("omits the datastores section when the agent uses no datastores", () => {
113
- const prompt = buildPrompt(
114
- input({ resolution: resolution("local", "created_first_execution") }),
115
- );
116
- expect(prompt).not.toContain("<available_datastores>");
117
- });
118
-
119
90
  it("carries the rollover context bridge on the first execution (DD-013)", () => {
120
91
  const prompt = buildPrompt(
121
92
  input({
@@ -454,6 +425,68 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
454
425
  expect(prompt).toContain("Attached inline and visible to you, in order: 1. a.jpg");
455
426
  });
456
427
 
428
+ it("lists a minted download URL beside its file with the presigned hand-off line (issue #532)", () => {
429
+ const prompt = buildPrompt(
430
+ input({
431
+ ...RESUMED,
432
+ attachments: [
433
+ { path: ".stigmer/inputs/lease.pdf", downloadUrl: "https://r2.example/lease?sig=abc" },
434
+ { path: ".stigmer/inputs/notes.md" },
435
+ ],
436
+ downloadUrlKind: "presigned",
437
+ }),
438
+ );
439
+ expect(prompt).toContain(
440
+ "- `.stigmer/inputs/lease.pdf` — download URL: https://r2.example/lease?sig=abc",
441
+ );
442
+ // The URL-less file keeps a clean entry.
443
+ expect(prompt).toContain("- `.stigmer/inputs/notes.md`\n");
444
+ expect(prompt).toContain("These URLs are time-limited");
445
+ });
446
+
447
+ it("words a local-serve URL honestly — reachable only from this machine", () => {
448
+ const prompt = buildPrompt(
449
+ input({
450
+ ...RESUMED,
451
+ attachments: [
452
+ { path: ".stigmer/inputs/lease.pdf", downloadUrl: "http://localhost:7235/attachments/01A/lease.pdf" },
453
+ ],
454
+ downloadUrlKind: "local-serve",
455
+ }),
456
+ );
457
+ expect(prompt).toContain("download URL: http://localhost:7235/attachments/01A/lease.pdf");
458
+ expect(prompt).toContain("reachable only from this machine");
459
+ expect(prompt).not.toContain("time-limited");
460
+ });
461
+
462
+ it("renders no hand-off line when no listed file carries a URL (kind alone is not enough)", () => {
463
+ const prompt = buildPrompt(
464
+ input({
465
+ ...RESUMED,
466
+ attachments: [{ path: ".stigmer/inputs/local-only.csv" }],
467
+ downloadUrlKind: "presigned",
468
+ }),
469
+ );
470
+ expect(prompt).toContain("<input_files>");
471
+ expect(prompt).not.toContain("download URL");
472
+ expect(prompt).not.toContain("time-limited");
473
+ });
474
+
475
+ it("renders the download URL inside the enhanced first-turn prompt too", () => {
476
+ const prompt = buildPrompt(
477
+ input({
478
+ resolution: resolution("local", "created_first_execution"),
479
+ attachments: [
480
+ { path: ".stigmer/inputs/lease.pdf", downloadUrl: "https://r2.example/lease?sig=abc" },
481
+ ],
482
+ downloadUrlKind: "presigned",
483
+ }),
484
+ );
485
+ expect(prompt).toContain("<input_files>");
486
+ expect(prompt).toContain("download URL: https://r2.example/lease?sig=abc");
487
+ expect(prompt).toContain("These URLs are time-limited");
488
+ });
489
+
457
490
  it("a HITL re-invocation carries no input-files section — its prompt has no user message at all", () => {
458
491
  const prompt = buildPrompt(
459
492
  input({
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { resolveSkills } from "../skill-resolver.js";
6
+ import { buildZip } from "../../../__test-utils__/zip-fixtures.js";
6
7
 
7
8
  // ─── Helpers ─────────────────────────────────────────────────────────────
8
9
 
@@ -10,46 +11,6 @@ function makeTempDir(prefix: string): string {
10
11
  return mkdtempSync(join(tmpdir(), prefix));
11
12
  }
12
13
 
13
- /**
14
- * Build a minimal stored (method 0) ZIP archive for testing.
15
- */
16
- function buildStoredZip(files: { name: string; content: string }[]): Uint8Array {
17
- const parts: Uint8Array[] = [];
18
-
19
- for (const file of files) {
20
- const nameBytes = new TextEncoder().encode(file.name);
21
- const contentBytes = new TextEncoder().encode(file.content);
22
- const isDir = file.name.endsWith("/");
23
-
24
- const header = new ArrayBuffer(30);
25
- const view = new DataView(header);
26
- view.setUint32(0, 0x04034b50, true);
27
- view.setUint16(4, 20, true);
28
- view.setUint16(6, 0, true);
29
- view.setUint16(8, 0, true);
30
- view.setUint16(10, 0, true);
31
- view.setUint16(12, 0, true);
32
- view.setUint32(14, 0, true);
33
- view.setUint32(18, isDir ? 0 : contentBytes.length, true);
34
- view.setUint32(22, isDir ? 0 : contentBytes.length, true);
35
- view.setUint16(26, nameBytes.length, true);
36
- view.setUint16(28, 0, true);
37
-
38
- parts.push(new Uint8Array(header));
39
- parts.push(nameBytes);
40
- if (!isDir) parts.push(contentBytes);
41
- }
42
-
43
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
44
- const result = new Uint8Array(totalLength);
45
- let offset = 0;
46
- for (const part of parts) {
47
- result.set(part, offset);
48
- offset += part.length;
49
- }
50
- return result;
51
- }
52
-
53
14
  function makeSkillProto(overrides: {
54
15
  name?: string;
55
16
  slug?: string;
@@ -141,7 +102,7 @@ describe("resolveSkills — artifact extraction", () => {
141
102
  const skillMd = "# Garden Design Makeover\n\nSee [references/database-schema.md](references/database-schema.md)";
142
103
  const schemaContent = "# Database Schema\n\nTable definitions here.";
143
104
 
144
- const artifact = buildStoredZip([
105
+ const artifact = buildZip([
145
106
  { name: "SKILL.md", content: skillMd },
146
107
  { name: "references/", content: "" },
147
108
  { name: "references/database-schema.md", content: schemaContent },
@@ -232,7 +193,7 @@ describe("resolveSkills — artifact extraction", () => {
232
193
  const specContent = "# Authoritative SKILL.md from spec";
233
194
  const zipContent = "# Stale SKILL.md from ZIP";
234
195
 
235
- const artifact = buildStoredZip([
196
+ const artifact = buildZip([
236
197
  { name: "SKILL.md", content: zipContent },
237
198
  { name: "references/data.md", content: "data" },
238
199
  ]);
@@ -40,6 +40,7 @@ import { mkdir, copyFile, readFile, stat, writeFile } from "node:fs/promises";
40
40
  import { join, basename } from "node:path";
41
41
  import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
42
42
  import type { ArtifactStorage } from "../../shared/artifact-storage.js";
43
+ import { mintAttachmentDownloadUrl } from "../../shared/attachment-download-urls.js";
43
44
  import { allocateUniqueName } from "../../shared/attachment-naming.js";
44
45
  import {
45
46
  isVisionCandidate,
@@ -73,6 +74,17 @@ export interface ResolvedAttachment {
73
74
  * Attachments that were never image-shaped carry neither field.
74
75
  */
75
76
  visionDegraded?: VisionDegradedReason;
77
+ /**
78
+ * A download URL for the attachment's stored object, listed in the prompt's
79
+ * `<input_files>` section so the agent can hand the file to a tool whose
80
+ * backend cannot read the sandbox filesystem (issue #532). Minted whenever
81
+ * the attachment has a `storageKey` and a usable storage — including on the
82
+ * localPath fast path when an uploaded copy also exists. Absent when there
83
+ * is nothing to mint or the mint failed (non-fatal — the policy, the
84
+ * degrade log, and the prompt wording all live in
85
+ * shared/attachment-download-urls.ts).
86
+ */
87
+ downloadUrl?: string;
76
88
  }
77
89
 
78
90
  export interface AttachmentResolverOptions {
@@ -165,11 +177,18 @@ async function resolveAttachment(
165
177
  `${err instanceof Error ? err.message : String(err)}`,
166
178
  );
167
179
  }
180
+ // The fast path skips storage for the BYTES, but an uploaded copy (an
181
+ // attachment carrying both localPath and storageKey) still supports the
182
+ // URL hand-off — the mint rule is branch-independent.
183
+ const downloadUrl = await mintAttachmentDownloadUrl(
184
+ options.storage, attachment.storageKey, filename,
185
+ );
168
186
  return {
169
187
  filename,
170
188
  relativePath: join(STIGMER_LOCAL_STATE_DIR, INPUTS_SUBDIR, filename),
171
189
  ...(renamedFrom !== undefined ? { renamedFrom } : {}),
172
190
  ...visionOutcomeFields(vision),
191
+ ...(downloadUrl !== undefined ? { downloadUrl } : {}),
173
192
  };
174
193
  }
175
194
 
@@ -209,11 +228,16 @@ async function resolveAttachment(
209
228
  // no pre-filter needed on this branch).
210
229
  const vision = options.visionBudget?.offer(filename, attachment.contentType, content);
211
230
 
231
+ const downloadUrl = await mintAttachmentDownloadUrl(
232
+ options.storage, attachment.storageKey, filename,
233
+ );
234
+
212
235
  return {
213
236
  filename,
214
237
  relativePath: join(STIGMER_LOCAL_STATE_DIR, INPUTS_SUBDIR, filename),
215
238
  ...(renamedFrom !== undefined ? { renamedFrom } : {}),
216
239
  ...visionOutcomeFields(vision),
240
+ ...(downloadUrl !== undefined ? { downloadUrl } : {}),
217
241
  };
218
242
  }
219
243
 
@@ -14,7 +14,7 @@
14
14
  import { resolve } from "node:path";
15
15
  import type { StigmerClient } from "../../client/stigmer-client.js";
16
16
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
17
- import type { AgentSpec, DatastoreUsage, McpServerUsage, SubAgent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
17
+ import type { AgentSpec, McpServerUsage, SubAgent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
18
18
  import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
19
19
  import type { SessionSpec } from "@stigmer/protos/ai/stigmer/agentic/session/v1/spec_pb";
20
20
  import type { WorkspaceEntry } from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
@@ -46,14 +46,6 @@ export interface ResolvedBlueprint {
46
46
  subAgents: SubAgent[];
47
47
  mergedMcpServerUsages: McpServerUsage[];
48
48
  mergedSkillRefs: ApiResourceReference[];
49
- /**
50
- * The agent's datastore usages (AgentSpec only — sessions carry no
51
- * datastore overlay by design: the usage edge is authorization-bearing
52
- * for the server's reach chain, DD-006, so it binds per-blueprint).
53
- * Non-empty triggers the synthesized records attachment
54
- * (shared/datastore-attachment.ts).
55
- */
56
- datastoreUsages: DatastoreUsage[];
57
49
  workspaceDirs: string[];
58
50
  cloudRepos: CloudRepo[];
59
51
  }
@@ -98,7 +90,6 @@ export async function resolveBlueprint(
98
90
  subAgents: agentSpec.subAgents,
99
91
  mergedMcpServerUsages,
100
92
  mergedSkillRefs,
101
- datastoreUsages: agentSpec.datastoreUsages,
102
93
  workspaceDirs,
103
94
  cloudRepos,
104
95
  };