@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
- package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
- package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
- package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +6 -2
- package/dist/activities/execute-cursor/index.js +16 -32
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +15 -11
- package/dist/activities/execute-cursor/prompt-builder.js +20 -10
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +34 -8
- package/dist/activities/execute-deep-agent/attachment-injector.js +111 -106
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +0 -15
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
- package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
- package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
- package/dist/activities/execute-deep-agent/prompt-builder.js +25 -8
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
- package/dist/activities/execute-deep-agent/setup.js +13 -45
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/config.d.ts +6 -5
- package/dist/config.js.map +1 -1
- package/dist/middleware/path-normalization.d.ts +15 -2
- package/dist/middleware/path-normalization.js +39 -5
- package/dist/middleware/path-normalization.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +11 -0
- package/dist/shared/artifact-storage.js +2 -0
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/attachment-download-urls.d.ts +62 -0
- package/dist/shared/attachment-download-urls.js +79 -0
- package/dist/shared/attachment-download-urls.js.map +1 -0
- package/dist/shared/channel-attachment.d.ts +1 -1
- package/dist/shared/channel-attachment.js +1 -1
- package/dist/shared/conversation-attachment.d.ts +2 -3
- package/dist/shared/conversation-attachment.js +2 -3
- package/dist/shared/conversation-attachment.js.map +1 -1
- package/dist/shared/mcp-enabled-tools.d.ts +6 -2
- package/dist/shared/mcp-enabled-tools.js +6 -2
- package/dist/shared/mcp-enabled-tools.js.map +1 -1
- package/dist/shared/mcp-transport-guard.d.ts +2 -2
- package/dist/shared/mcp-transport-guard.js +2 -2
- package/dist/shared/plan-mode-permissions.d.ts +46 -10
- package/dist/shared/plan-mode-permissions.js +56 -12
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/synthesized-attachment.d.ts +4 -4
- package/dist/shared/synthesized-attachment.js +4 -4
- package/dist/shared/zip-extract.d.ts +24 -6
- package/dist/shared/zip-extract.js +31 -90
- package/dist/shared/zip-extract.js.map +1 -1
- package/dist/shared/zip-structure.d.ts +61 -0
- package/dist/shared/zip-structure.js +128 -0
- package/dist/shared/zip-structure.js.map +1 -0
- package/package.json +2 -2
- package/src/__test-utils__/fake-artifact-storage.ts +10 -1
- package/src/__test-utils__/zip-fixtures.ts +206 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +62 -29
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
- package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
- package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
- package/src/activities/execute-cursor/index.ts +22 -37
- package/src/activities/execute-cursor/prompt-builder.ts +38 -22
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +270 -126
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +78 -2
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
- package/src/activities/execute-deep-agent/attachment-injector.ts +166 -143
- package/src/activities/execute-deep-agent/index.ts +0 -19
- package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
- package/src/activities/execute-deep-agent/prompt-builder.ts +38 -16
- package/src/activities/execute-deep-agent/setup.ts +13 -68
- package/src/config.ts +6 -5
- package/src/middleware/__tests__/path-normalization.test.ts +29 -3
- package/src/middleware/path-normalization.ts +42 -5
- package/src/shared/__tests__/artifact-storage.test.ts +9 -0
- package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
- package/src/shared/__tests__/channel-attachment.test.ts +2 -2
- package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
- package/src/shared/__tests__/zip-extract.test.ts +106 -89
- package/src/shared/artifact-storage.ts +11 -0
- package/src/shared/attachment-download-urls.ts +98 -0
- package/src/shared/channel-attachment.ts +1 -1
- package/src/shared/conversation-attachment.ts +2 -3
- package/src/shared/mcp-enabled-tools.ts +6 -2
- package/src/shared/mcp-transport-guard.ts +2 -2
- package/src/shared/plan-mode-permissions.ts +59 -12
- package/src/shared/synthesized-attachment.ts +4 -4
- package/src/shared/zip-extract.ts +35 -117
- package/src/shared/zip-structure.ts +181 -0
- package/dist/shared/datastore-attachment.d.ts +0 -99
- package/dist/shared/datastore-attachment.js +0 -184
- package/dist/shared/datastore-attachment.js.map +0 -1
- package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
- package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
- 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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)
|
|
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) —
|
|
4
|
-
*
|
|
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
|
|
25
|
-
*
|
|
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
|
|
64
|
-
* separately and
|
|
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
|
|
6
|
-
* deny every filesystem write
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
22
|
-
* paths outright
|
|
23
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
4
|
-
* attachment (
|
|
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. "
|
|
47
|
+
* in that warning (e.g. "channel messaging").
|
|
48
48
|
*/
|
|
49
49
|
export function injectSynthesizedAttachment(
|
|
50
50
|
resolvedServers: ResolvedMcpServer[],
|