@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
@@ -1,89 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { deflateRawSync } from "node:zlib";
3
2
  import { extractZipFileEntries } from "../zip-extract.js";
4
-
5
- // ─── Helpers ─────────────────────────────────────────────────────────────
6
-
7
- /**
8
- * Build a minimal valid ZIP archive from an array of { name, content } entries.
9
- * Uses stored (method 0) compression for simplicity. Produces local file
10
- * headers only (no central directory) — sufficient for our parser.
11
- */
12
- function buildStoredZip(files: { name: string; content: string }[]): Uint8Array {
13
- const parts: Uint8Array[] = [];
14
-
15
- for (const file of files) {
16
- const nameBytes = new TextEncoder().encode(file.name);
17
- const contentBytes = new TextEncoder().encode(file.content);
18
- const isDir = file.name.endsWith("/");
19
-
20
- // Local file header: 30 bytes
21
- const header = new ArrayBuffer(30);
22
- const view = new DataView(header);
23
- view.setUint32(0, 0x04034b50, true); // signature
24
- view.setUint16(4, 20, true); // version needed
25
- view.setUint16(6, 0, true); // general purpose flags
26
- view.setUint16(8, 0, true); // compression method (stored)
27
- view.setUint16(10, 0, true); // last mod time
28
- view.setUint16(12, 0, true); // last mod date
29
- view.setUint32(14, 0, true); // crc-32 (unused for our purposes)
30
- view.setUint32(18, isDir ? 0 : contentBytes.length, true); // compressed size
31
- view.setUint32(22, isDir ? 0 : contentBytes.length, true); // uncompressed size
32
- view.setUint16(26, nameBytes.length, true); // file name length
33
- view.setUint16(28, 0, true); // extra field length
34
-
35
- parts.push(new Uint8Array(header));
36
- parts.push(nameBytes);
37
- if (!isDir) {
38
- parts.push(contentBytes);
39
- }
40
- }
41
-
42
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
43
- const result = new Uint8Array(totalLength);
44
- let offset = 0;
45
- for (const part of parts) {
46
- result.set(part, offset);
47
- offset += part.length;
48
- }
49
- return result;
50
- }
51
-
52
- /**
53
- * Build a ZIP archive with a single deflated (method 8) entry.
54
- */
55
- function buildDeflatedZip(name: string, content: string): Uint8Array {
56
- const nameBytes = new TextEncoder().encode(name);
57
- const contentBytes = new TextEncoder().encode(content);
58
- const compressed = deflateRawSync(contentBytes);
59
-
60
- const header = new ArrayBuffer(30);
61
- const view = new DataView(header);
62
- view.setUint32(0, 0x04034b50, true);
63
- view.setUint16(4, 20, true);
64
- view.setUint16(6, 0, true);
65
- view.setUint16(8, 8, true); // deflated
66
- view.setUint16(10, 0, true);
67
- view.setUint16(12, 0, true);
68
- view.setUint32(14, 0, true);
69
- view.setUint32(18, compressed.length, true);
70
- view.setUint32(22, contentBytes.length, true);
71
- view.setUint16(26, nameBytes.length, true);
72
- view.setUint16(28, 0, true);
73
-
74
- const totalLength = 30 + nameBytes.length + compressed.length;
75
- const result = new Uint8Array(totalLength);
76
- result.set(new Uint8Array(header), 0);
77
- result.set(nameBytes, 30);
78
- result.set(new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.length), 30 + nameBytes.length);
79
- return result;
80
- }
3
+ import { buildZip } from "../../__test-utils__/zip-fixtures.js";
81
4
 
82
5
  // ─── extractZipFileEntries ───────────────────────────────────────────────
83
6
 
84
7
  describe("extractZipFileEntries", () => {
85
8
  it("extracts stored files from a ZIP archive", async () => {
86
- const zip = buildStoredZip([
9
+ const zip = buildZip([
87
10
  { name: "SKILL.md", content: "# My Skill" },
88
11
  { name: "references/schema.md", content: "# Schema\n\nTable definitions." },
89
12
  ]);
@@ -96,7 +19,7 @@ describe("extractZipFileEntries", () => {
96
19
 
97
20
  it("extracts deflated files", async () => {
98
21
  const content = "This content is compressed with deflate.";
99
- const zip = buildDeflatedZip("notes.txt", content);
22
+ const zip = buildZip([{ name: "notes.txt", content, method: "deflated" }]);
100
23
 
101
24
  const entries = await extractZipFileEntries(zip);
102
25
  expect(entries).toHaveLength(1);
@@ -104,7 +27,7 @@ describe("extractZipFileEntries", () => {
104
27
  });
105
28
 
106
29
  it("skips directory entries", async () => {
107
- const zip = buildStoredZip([
30
+ const zip = buildZip([
108
31
  { name: "references/", content: "" },
109
32
  { name: "references/data.md", content: "data" },
110
33
  ]);
@@ -115,7 +38,7 @@ describe("extractZipFileEntries", () => {
115
38
  });
116
39
 
117
40
  it("excludes files by basename", async () => {
118
- const zip = buildStoredZip([
41
+ const zip = buildZip([
119
42
  { name: "SKILL.md", content: "# Skill" },
120
43
  { name: "references/schema.md", content: "# Schema" },
121
44
  { name: "scripts/run.py", content: "print('hi')" },
@@ -127,7 +50,7 @@ describe("extractZipFileEntries", () => {
127
50
  });
128
51
 
129
52
  it("excludes files by full path", async () => {
130
- const zip = buildStoredZip([
53
+ const zip = buildZip([
131
54
  { name: "a.md", content: "a" },
132
55
  { name: "nested/a.md", content: "nested" },
133
56
  ]);
@@ -138,7 +61,7 @@ describe("extractZipFileEntries", () => {
138
61
  });
139
62
 
140
63
  it("excludes entries matching basename even when nested", async () => {
141
- const zip = buildStoredZip([
64
+ const zip = buildZip([
142
65
  { name: "SKILL.md", content: "root" },
143
66
  { name: "sub/SKILL.md", content: "sub" },
144
67
  { name: "data.md", content: "data" },
@@ -166,7 +89,7 @@ describe("extractZipFileEntries", () => {
166
89
  });
167
90
 
168
91
  it("handles multiple files with nested directories", async () => {
169
- const zip = buildStoredZip([
92
+ const zip = buildZip([
170
93
  { name: "SKILL.md", content: "# Skill" },
171
94
  { name: "references/", content: "" },
172
95
  { name: "references/database-schema.md", content: "# Schema" },
@@ -184,7 +107,7 @@ describe("extractZipFileEntries", () => {
184
107
  });
185
108
 
186
109
  it("returns all entries when no exclude option is provided", async () => {
187
- const zip = buildStoredZip([
110
+ const zip = buildZip([
188
111
  { name: "a.txt", content: "aaa" },
189
112
  { name: "b.txt", content: "bbb" },
190
113
  ]);
@@ -194,11 +117,105 @@ describe("extractZipFileEntries", () => {
194
117
  });
195
118
 
196
119
  it("returns all entries when exclude list is empty", async () => {
197
- const zip = buildStoredZip([
198
- { name: "a.txt", content: "aaa" },
199
- ]);
120
+ const zip = buildZip([{ name: "a.txt", content: "aaa" }]);
200
121
 
201
122
  const entries = await extractZipFileEntries(zip, { exclude: [] });
202
123
  expect(entries).toHaveLength(1);
203
124
  });
125
+
126
+ // ── Streaming entries (issue #450) ─────────────────────────────────────
127
+
128
+ it("extracts Go-default streaming archives (deflated, data descriptors)", async () => {
129
+ const zip = buildZip([
130
+ { name: "SKILL.md", content: "# Streamed Skill", method: "deflated", streaming: true },
131
+ { name: "references/notes.md", content: "streamed notes", method: "deflated", streaming: true },
132
+ ]);
133
+
134
+ const entries = await extractZipFileEntries(zip);
135
+ expect(entries).toEqual([
136
+ { path: "SKILL.md", content: "# Streamed Skill" },
137
+ { path: "references/notes.md", content: "streamed notes" },
138
+ ]);
139
+ });
140
+
141
+ it("extracts a stored streaming entry whose payload embeds the data-descriptor signature", async () => {
142
+ // The four bytes of the descriptor signature (0x08074b50, little-endian
143
+ // "PK\x07\x08") planted mid-content, followed by twelve bytes a
144
+ // descriptor-scanning parser would misread as CRC and sizes. The old
145
+ // local-header walk truncated this entry at the planted signature and
146
+ // desynchronized everything after it — the exact defect of issue #450.
147
+ const poisoned = "before PK\u0007\u0008AAAABBBBCCCC after — full content survives";
148
+ const zip = buildZip([
149
+ { name: "poison.md", content: poisoned, streaming: true },
150
+ { name: "after.md", content: "the entry after the poisoned one" },
151
+ ]);
152
+
153
+ const entries = await extractZipFileEntries(zip);
154
+ expect(entries).toEqual([
155
+ { path: "poison.md", content: poisoned },
156
+ { path: "after.md", content: "the entry after the poisoned one" },
157
+ ]);
158
+ });
159
+
160
+ it("uses central-directory sizes when local header sizes are zeroed", async () => {
161
+ const content = "sizes live only in the central directory";
162
+ const zip = buildZip([{ name: "cd-sizes.txt", content, streaming: true }]);
163
+
164
+ const entries = await extractZipFileEntries(zip);
165
+ expect(entries).toEqual([{ path: "cd-sizes.txt", content }]);
166
+ });
167
+
168
+ // ── Central directory edge cases ───────────────────────────────────────
169
+
170
+ it("locates the EOCD behind a trailing archive comment", async () => {
171
+ const zip = buildZip([{ name: "a.txt", content: "aaa" }], {
172
+ comment: "release archive — built by tooling",
173
+ });
174
+
175
+ const entries = await extractZipFileEntries(zip);
176
+ expect(entries).toEqual([{ path: "a.txt", content: "aaa" }]);
177
+ });
178
+
179
+ it("is not fooled by EOCD signature bytes inside the archive comment", async () => {
180
+ // "PK\x05\x06" inside the comment is a decoy EOCD; validation must
181
+ // reject it (its "fields" are comment text) and keep scanning backward
182
+ // to the real record.
183
+ const zip = buildZip([{ name: "a.txt", content: "aaa" }], {
184
+ comment: "decoy: PK\u0005\u0006 not a real record",
185
+ });
186
+
187
+ const entries = await extractZipFileEntries(zip);
188
+ expect(entries).toEqual([{ path: "a.txt", content: "aaa" }]);
189
+ });
190
+
191
+ it("returns empty array when the central directory is missing", async () => {
192
+ // Local headers and payloads only — a download truncated before the
193
+ // archive's index. The parser must not fall back to guessing from
194
+ // local headers (design record 017).
195
+ const zip = buildZip(
196
+ [
197
+ { name: "a.txt", content: "aaa" },
198
+ { name: "b.txt", content: "bbb" },
199
+ ],
200
+ { omitCentralDirectory: true },
201
+ );
202
+
203
+ const entries = await extractZipFileEntries(zip);
204
+ expect(entries).toEqual([]);
205
+ });
206
+
207
+ it("returns empty array for an archive with entries but a truncated tail", async () => {
208
+ const zip = buildZip([{ name: "a.txt", content: "aaa" }]);
209
+ const truncated = zip.subarray(0, zip.length - 10); // clips into the EOCD
210
+
211
+ const entries = await extractZipFileEntries(truncated);
212
+ expect(entries).toEqual([]);
213
+ });
214
+
215
+ it("extracts an empty archive (EOCD only) as no entries", async () => {
216
+ const zip = buildZip([]);
217
+
218
+ const entries = await extractZipFileEntries(zip);
219
+ expect(entries).toEqual([]);
220
+ });
204
221
  });
@@ -31,6 +31,15 @@ import { fetchWithRetry, type FetchRetryPolicy } from "./http-retry.js";
31
31
  // ── Interface ────────────────────────────────────────────────────────
32
32
 
33
33
  export interface ArtifactStorage {
34
+ /**
35
+ * What kind of URL {@link getDownloadUrl} mints — self-described by the
36
+ * backend so consumers (the attachment hand-off prompt wording,
37
+ * attachment-download-urls.ts) can never disagree with the storage actually
38
+ * in use. "presigned": time-limited, single-object, remotely fetchable.
39
+ * "local-serve": the stigmer-server's unauthenticated loopback serve URL,
40
+ * reachable only from this machine.
41
+ */
42
+ readonly downloadUrlKind: "presigned" | "local-serve";
34
43
  upload(key: string, content: Buffer, contentType?: string): Promise<string>;
35
44
  getDownloadUrl(key: string): Promise<string>;
36
45
  /**
@@ -52,6 +61,7 @@ export type ArtifactStorageType = "local" | "proxy";
52
61
  // ── Local Backend ────────────────────────────────────────────────────
53
62
 
54
63
  export class LocalArtifactStorage implements ArtifactStorage {
64
+ readonly downloadUrlKind = "local-serve" as const;
55
65
  private readonly basePath: string;
56
66
  private readonly serveUrlBase: string;
57
67
 
@@ -153,6 +163,7 @@ const DEFAULT_RETRY = {
153
163
  } as const;
154
164
 
155
165
  export class ProxyArtifactStorage implements ArtifactStorage {
166
+ readonly downloadUrlKind = "presigned" as const;
156
167
  private readonly baseUrl: string;
157
168
  private readonly authTokenSource: ProxyAuthTokenSource;
158
169
  /** Governs the proxy presign calls and the exists probe. */
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Download-URL hand-off for execution attachments (issue #532) — the single
3
+ * owner of the mint policy and the prompt wording that lets an agent pass an
4
+ * attachment to a tool whose backend cannot read the sandbox filesystem.
5
+ *
6
+ * Both harnesses (Cursor and deep-agent) materialize attachments to disk and
7
+ * list the workspace paths in their input-files prompt section; that story is
8
+ * unchanged and this module never touches it. What this module adds is the
9
+ * *remote hand-off* story: a per-attachment download URL, minted from the
10
+ * artifact storage the runner already downloads through, surfaced beside the
11
+ * path so the model can quote a short URL string in a tool-call argument
12
+ * instead of the impossible alternative (base64 through the model caps out in
13
+ * the tens of KB; real attachments run 2–8 MB).
14
+ *
15
+ * Mint rule: any attachment with a `storageKey` and a usable storage gets a
16
+ * URL, regardless of which branch materialized the bytes — this covers the
17
+ * local-mode fast path when an uploaded copy also exists. Attachments with no
18
+ * storage key (pure CLI-local files) and extracted ZIP entries (no
19
+ * attachment-level object) get no URL, and their listing is unchanged.
20
+ *
21
+ * Failure is non-fatal and silent in the prompt: the file IS materialized —
22
+ * only the remote hand-off affordance is absent — so a presign hiccup must
23
+ * not abort a turn the way a missing input does (the resolver/injector
24
+ * fail-hard doctrine covers inputs, not affordances). This mirrors how vision
25
+ * delivery degrades without killing materialization. The degrade is logged;
26
+ * the URL value itself is never logged (a presigned URL in the log pipeline
27
+ * would outlive its purpose).
28
+ *
29
+ * What the URL actually is depends on the storage backend, and the prompt
30
+ * must not overpromise: proxy storage mints genuinely presigned, time-limited,
31
+ * single-object URLs a remote service can fetch; local storage returns the
32
+ * stigmer-server's loopback serve URL, which only same-machine tools can
33
+ * reach. The backend self-describes via {@link ArtifactStorage.downloadUrlKind}
34
+ * and {@link downloadUrlDisclosureLine} words each kind honestly.
35
+ */
36
+
37
+ import type { ArtifactStorage } from "./artifact-storage.js";
38
+
39
+ /**
40
+ * What kind of URL a storage backend's `getDownloadUrl` mints. A property of
41
+ * the backend, not of the runner's execution mode: storage follows transport
42
+ * (see loadArtifactStorageConfig), so a local desktop runner on a cloud proxy
43
+ * mints real presigned URLs.
44
+ */
45
+ export type DownloadUrlKind = "presigned" | "local-serve";
46
+
47
+ /**
48
+ * Mint a download URL for one attachment, or `undefined` when there is
49
+ * nothing to mint (no key / no storage) or the mint fails (logged, non-fatal
50
+ * — see module doc). The caller spreads the result into its per-attachment
51
+ * record with the conditional-spread convention, so an unminted URL leaves no
52
+ * field behind.
53
+ */
54
+ export async function mintAttachmentDownloadUrl(
55
+ storage: ArtifactStorage | undefined,
56
+ storageKey: string,
57
+ filename: string,
58
+ ): Promise<string | undefined> {
59
+ if (!storageKey || !storage) return undefined;
60
+ try {
61
+ return await storage.getDownloadUrl(storageKey);
62
+ } catch (err) {
63
+ console.warn(
64
+ `[attachment-download-urls] could not mint a download URL for ` +
65
+ `'${filename}' (key: ${storageKey}) — the file is materialized and the ` +
66
+ `turn proceeds without one: ${err instanceof Error ? err.message : String(err)}`,
67
+ );
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The shared hand-off wording both harnesses embed into their input-files
74
+ * prompt section when at least one listed file carries a download URL (each
75
+ * wraps it in its own section framing). Kept here so the two prompts never
76
+ * drift apart in what they promise the agent.
77
+ *
78
+ * Each kind is worded to its real capability. "Time-limited" is deliberately
79
+ * unquantified: the presign TTL belongs to the serving side (the cloud
80
+ * proxy's constant today) and hardcoding it here would silently drift.
81
+ */
82
+ export function downloadUrlDisclosureLine(kind: DownloadUrlKind): string {
83
+ switch (kind) {
84
+ case "presigned":
85
+ return (
86
+ "Where a file lists a download URL, you can pass that URL to tools " +
87
+ "whose backends cannot read this workspace's filesystem (e.g. remote " +
88
+ "services) — the tool fetches the file's contents itself. These URLs " +
89
+ "are time-limited and each grants access to its single file only."
90
+ );
91
+ case "local-serve":
92
+ return (
93
+ "Where a file lists a download URL, it is served by the local Stigmer " +
94
+ "server and is reachable only from this machine — tools running on " +
95
+ "this machine can fetch it, but remote services cannot."
96
+ );
97
+ }
98
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The runner-synthesized channel messaging attachment (proactive-messaging
3
- * DD-006 D7/D8) — the datastore records attachment's structural twin.
3
+ * DD-006 D7/D8).
4
4
  *
5
5
  * When the control plane says an agent serves at least one
6
6
  * proactive-messaging channel (the `listMessagingChannels` discovery
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * The runner-synthesized conversation participation attachment
3
- * (channel-conversations DD-008 D-c, A14) — the third synthesized
4
- * attachment, on the datastore module's shape (a cheap local predicate,
5
- * not the channel module's discovery machinery).
3
+ * (channel-conversations DD-008 D-c, A14) — a cheap local predicate,
4
+ * not the channel module's discovery machinery.
6
5
  *
7
6
  * When the session IS a live channel conversation, the runner
8
7
  * synthesizes ONE MCP attachment serving `escalate_to_human`, so the
@@ -21,8 +21,12 @@
21
21
  * identity space as the sub-agent McpAccess filter and the approval-policy
22
22
  * maps. An enabled name the server does not expose is warned and dropped
23
23
  * (enforce the intersection): the restriction still holds and the run
24
- * proceeds with the valid subset; apply-time validation against
25
- * discovered_capabilities is the server-side follow-up that catches typos.
24
+ * proceeds with the valid subset. The server-side half (issue #402) rejects
25
+ * such names at apply time once the referenced server has discovered
26
+ * capabilities (stigmer-server validateEnabledToolsStep /
27
+ * validateDefaultEnabledToolsStep); this runtime leniency remains the
28
+ * safety net for manifests applied before a server's first connect and for
29
+ * toolsets that changed since the last discovery.
26
30
  */
27
31
 
28
32
  /**
@@ -60,8 +60,8 @@ export function resolveMcpTransportPosture(
60
60
  /**
61
61
  * Assert that a resolved MCP server's transport is allowed under the given
62
62
  * posture. Applies only to user-defined McpServer resources flowing through
63
- * the resolvers — the internal datastore attachment is synthesized
64
- * separately and is already HTTP in cloud mode.
63
+ * the resolvers — the internal synthesized attachments are built
64
+ * separately and are already HTTP in cloud mode.
65
65
  *
66
66
  * @throws McpTransportError when the posture forbids stdio and the server uses it
67
67
  */
@@ -2,12 +2,26 @@
2
2
  * The Plan-mode filesystem permission rules — the enforcement twin of
3
3
  * `plan-mode-prompt.ts` (which carries the instruction half of the contract).
4
4
  *
5
- * Plan mode is read-only BY CONSTRUCTION on the native harness: these rules
6
- * deny every filesystem write operation at the tool level so
7
- * write_file/edit_file cannot mutate the workspace regardless of what the
8
- * model was told. Rules are first-match-wins with a permissive default, so a
9
- * single deny-all-writes rule is sufficient. (The Cursor harness has no
10
- * tool-level lever and enforces plan mode via its prompt prefix instead.)
5
+ * Plan mode is contained BY CONSTRUCTION on the native harness: these rules
6
+ * deny every filesystem write everywhere AND scope reads to the session
7
+ * workspace (issue #528 — owner ruling: the workspace is plan mode's read
8
+ * boundary on cloud and desktop runners alike). Without the read boundary,
9
+ * model-provided absolute paths reached anywhere the process account could
10
+ * read the pod filesystem (including /proc/self/environ) on cloud runners,
11
+ * the user's whole home directory on desktop — while plan mode still carries
12
+ * exfiltration-capable tools (web_fetch, MCP). Rules are first-match-wins
13
+ * with a permissive default (deepagents' decidePathAccess), so order is
14
+ * load-bearing: the workspace read-allow must precede the read-deny.
15
+ *
16
+ * The workspace-root pattern is matched as a STRING against the raw tool-call
17
+ * path (micromatch, dot:true), before the backend touches disk. That is
18
+ * exactly why the legitimate out-of-workspace reads keep working: skills,
19
+ * attachments, and the approved plan live in the platform dir but are
20
+ * addressed through the `{workspace}/.stigmer` symlink (see
21
+ * shared/workspace/stigmer-link.ts), so their path strings are in-root even
22
+ * though the bytes are not. The same holds for multi-workspace local entries
23
+ * (`{workspace}/{name}` symlinks). A realpath-based boundary would break
24
+ * both; do not "harden" this into one.
11
25
  *
12
26
  * Applied in execute-deep-agent/setup.ts to the parent graph AND threaded
13
27
  * into every compiled sub-agent graph: deepagents' parent-permission
@@ -18,9 +32,9 @@
18
32
  *
19
33
  * Rules travel with a companion: every graph that carries them also installs
20
34
  * the path-normalization middleware (middleware/path-normalization.ts,
21
- * issue #429), because deepagents' rule validation refuses workspace-relative
22
- * paths outright without the shim, prompt-compliant relative READS die in
23
- * validation instead of just working. Both are derived from the same
35
+ * issues #429/#528), because deepagents' rule validation refuses
36
+ * workspace-relative paths outright and its `ls`/`glob`/`grep` schema default
37
+ * of "/" would deny the bare first listing. Both are derived from the same
24
38
  * expression at each composition site so they cannot drift apart.
25
39
  *
26
40
  * Invariant: never combine these rules with a shell-capable (sandbox)
@@ -30,8 +44,41 @@
30
44
  * for shell capability on both the parent and sub-agent backends.
31
45
  */
32
46
 
47
+ import { resolve } from "node:path";
33
48
  import type { FilesystemPermission } from "deepagents";
34
49
 
35
- export const PLAN_MODE_PERMISSIONS: FilesystemPermission[] = [
36
- { operations: ["write"], paths: ["/**"], mode: "deny" },
37
- ];
50
+ /**
51
+ * Backslash-escape every character micromatch/picomatch treats as glob
52
+ * syntax, so the result matches the input literally. micromatch exports no
53
+ * escape API of its own, and this is correctness, not caution: an unescaped
54
+ * `(` in a desktop project path would make the workspace read-allow rule
55
+ * silently never match — bricking every plan-mode read for that workspace.
56
+ * Semantics are pinned end-to-end through deepagents' own matcher by the
57
+ * special-character workspace suite in plan-mode-path-normalization.test.ts.
58
+ */
59
+ export function escapeGlobLiteral(literal: string): string {
60
+ return literal.replace(/[\\*?()[\]{}!+@]/g, "\\$&");
61
+ }
62
+
63
+ /**
64
+ * Build the plan-mode rule set for a graph whose filesystem backend is
65
+ * rooted at `workspaceRootDir`. The three rules read as the policy:
66
+ * reads allowed in the workspace, reads denied everywhere else, writes
67
+ * denied everywhere.
68
+ *
69
+ * `{root}/**` matches the root itself as well as its subtree (verified
70
+ * against the installed micromatch), so one allow pattern suffices. The
71
+ * root is `path.resolve`d first because enforcement canonicalizes incoming
72
+ * paths (collapsed slashes, no trailing separator) before matching — a
73
+ * trailing slash in the pattern would silently match nothing.
74
+ */
75
+ export function buildPlanModePermissions(
76
+ workspaceRootDir: string,
77
+ ): FilesystemPermission[] {
78
+ const canonicalRoot = resolve(workspaceRootDir);
79
+ return [
80
+ { operations: ["read"], paths: [`${escapeGlobLiteral(canonicalRoot)}/**`] },
81
+ { operations: ["read"], paths: ["/**"], mode: "deny" },
82
+ { operations: ["write"], paths: ["/**"], mode: "deny" },
83
+ ];
84
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Shared mechanics of runner-synthesized MCP attachments — the pieces
3
- * the datastore records attachment (T05) and the channel messaging
4
- * attachment (proactive-messaging DD-006 D8) have in common, extracted
5
- * when the second attachment arrived.
3
+ * the channel messaging attachment (proactive-messaging DD-006 D8) and
4
+ * the conversation participation attachment (DD-008 D-c) have in
5
+ * common, extracted when the second attachment arrived.
6
6
  *
7
7
  * A synthesized attachment is a first-party MCP server entry the runner
8
8
  * builds itself (no McpServer resource, no Environment, no credential in
@@ -44,7 +44,7 @@ export interface SynthesizedAttachmentOptions {
44
44
  * Inject a synthesized attachment into a resolved server list — AFTER
45
45
  * resolve + backfill (see the module header). A user server shadowing
46
46
  * the reserved slug is replaced, loudly; `label` names the attachment
47
- * in that warning (e.g. "datastore records").
47
+ * in that warning (e.g. "channel messaging").
48
48
  */
49
49
  export function injectSynthesizedAttachment(
50
50
  resolvedServers: ResolvedMcpServer[],