@stigmer/runner 3.12.0 → 3.12.2

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 (97) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
  3. package/dist/activities/execute-cursor/capture-flow.js +5 -4
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
  6. package/dist/activities/execute-cursor/cas-observations.js +10 -4
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +39 -13
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/skill-resolver.d.ts +8 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +77 -26
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
  14. package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
  15. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  17. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/setup.js +15 -3
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
  24. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  25. package/dist/activities/generate-session-subject.d.ts +80 -0
  26. package/dist/activities/generate-session-subject.js +283 -0
  27. package/dist/activities/generate-session-subject.js.map +1 -0
  28. package/dist/client/stigmer-client.d.ts +8 -0
  29. package/dist/client/stigmer-client.js +10 -0
  30. package/dist/client/stigmer-client.js.map +1 -1
  31. package/dist/middleware/approval-gate.d.ts +25 -10
  32. package/dist/middleware/approval-gate.js +33 -14
  33. package/dist/middleware/approval-gate.js.map +1 -1
  34. package/dist/middleware/otel-spans.d.ts +2 -1
  35. package/dist/middleware/otel-spans.js +2 -1
  36. package/dist/middleware/otel-spans.js.map +1 -1
  37. package/dist/middleware/path-normalization.d.ts +15 -2
  38. package/dist/middleware/path-normalization.js +39 -5
  39. package/dist/middleware/path-normalization.js.map +1 -1
  40. package/dist/runner-manager.js +3 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.js +3 -1
  43. package/dist/runner.js.map +1 -1
  44. package/dist/shared/attachment-vision.js +9 -0
  45. package/dist/shared/attachment-vision.js.map +1 -1
  46. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  47. package/dist/shared/mcp-enabled-tools.js +6 -2
  48. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  49. package/dist/shared/mcp-manager.js +8 -0
  50. package/dist/shared/mcp-manager.js.map +1 -1
  51. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  52. package/dist/shared/plan-mode-permissions.js +56 -12
  53. package/dist/shared/plan-mode-permissions.js.map +1 -1
  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__/zip-fixtures.ts +206 -0
  62. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  63. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  64. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +166 -49
  65. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  66. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  67. package/src/activities/execute-cursor/hook-script.ts +39 -13
  68. package/src/activities/execute-cursor/skill-resolver.ts +98 -31
  69. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
  70. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  71. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +7 -2
  72. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  73. package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
  74. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  75. package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
  76. package/src/activities/execute-deep-agent/setup.ts +15 -3
  77. package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
  78. package/src/activities/generate-session-subject.ts +370 -0
  79. package/src/client/stigmer-client.ts +11 -0
  80. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  81. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  82. package/src/middleware/approval-gate.ts +58 -24
  83. package/src/middleware/otel-spans.ts +2 -1
  84. package/src/middleware/path-normalization.ts +42 -5
  85. package/src/runner-manager.ts +3 -0
  86. package/src/runner.ts +3 -0
  87. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  88. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  89. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  90. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  91. package/src/shared/attachment-vision.ts +9 -0
  92. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  93. package/src/shared/mcp-enabled-tools.ts +6 -2
  94. package/src/shared/mcp-manager.ts +8 -0
  95. package/src/shared/plan-mode-permissions.ts +59 -12
  96. package/src/shared/zip-extract.ts +35 -117
  97. package/src/shared/zip-structure.ts +181 -0
@@ -88,10 +88,11 @@ export interface ApprovalGateConfig {
88
88
  * `write`/`delete` whose target path is capturable (git-tracked — see
89
89
  * {@link isCapturablePath}) FLOWS instead of interrupting: the edit is reviewed
90
90
  * post-hoc as a captured `FileChangeSet`, not gated before it runs. A gitignored
91
- * path is NOT capturable, so it stays gated (the git substrate cannot capture or
92
- * revert it) — the exact twin of the Cursor hook's `__stigmer_is_gitignored`
93
- * allow-branch. `shell` and MCP tools are never bypassed by this flag. Off (the
94
- * default) keeps the classic true-pause gate for every mutation.
91
+ * path is NOT capturable by the git substrate, so it stays gated unless
92
+ * {@link captureIgnored} routes it into CAS capture — the exact twin of the
93
+ * Cursor hook's `__stigmer_is_gitignored` allow-branch. `shell` and MCP tools
94
+ * are never bypassed by this flag. Off (the default) keeps the classic
95
+ * true-pause gate for every mutation.
95
96
  */
96
97
  readonly fileCaptureMode?: boolean;
97
98
  /**
@@ -107,12 +108,12 @@ export interface ApprovalGateConfig {
107
108
  * instead of the interrupt gate, applying the DD-E secret gate. When off, a
108
109
  * gitignored path stays on the interrupt gate exactly as before.
109
110
  *
110
- * TRUE ONLY FOR THE PARENT GATE. Sub-agents build their own plain filesystem
111
- * backends, which the CAS observer does not wrap, so flowing their gitignored
112
- * edits would apply unobserved, unreviewable bytes. `buildSubAgentMiddleware`
113
- * therefore forces this to false for sub-agent gates — it must NOT be inherited
114
- * as true. (Sub-agent git-tracked edits are still captured by the turn-boundary
115
- * git diff, which is backend-agnostic.)
111
+ * True only for gates whose backend a CAS observer wraps: the parent gate
112
+ * always (setup.ts), and since DD-19 sub-agent gates too, because
113
+ * `compileSubagents` gives every sub-agent a CAS-observing backend wired to
114
+ * the SAME shared observer and `buildSubAgentMiddleware` then inherits this
115
+ * config verbatim. A gate over an UNOBSERVED backend must keep this false:
116
+ * flowing its gitignored edits would apply unobserved, unreviewable bytes.
116
117
  */
117
118
  readonly captureIgnored?: boolean;
118
119
  /**
@@ -122,6 +123,20 @@ export interface ApprovalGateConfig {
122
123
  * secret; the CONTENT never leaves the workspace). Absent ⇒ nothing recorded.
123
124
  */
124
125
  readonly recordBlockedSecret?: (rawPath: string) => void;
126
+ /**
127
+ * Capture a file's pre-delete bytes into the CAS observer so a CAS-owned
128
+ * (gitignored / non-git) DELETE can flow under {@link captureIgnored} and be
129
+ * reviewed post-hoc exactly like a write (issue #303) — the delete twin of the
130
+ * backend's `recordBefore` write observation. A delete is the one mutation the
131
+ * backend cannot observe (deepagents has no backend delete method to wrap), so
132
+ * the gate captures at authorization time instead: the file's bytes are still
133
+ * on disk here, and the turn boundary then reads after=null and authors a
134
+ * `FILE_CHANGE_KIND_DELETE` with the same discard-restores contract.
135
+ *
136
+ * Absent ⇒ deletes keep today's interrupt-gate behavior (fail-closed: a delete
137
+ * we cannot capture cannot be reviewed, so it must be approved up front).
138
+ */
139
+ readonly captureDeleteBefore?: (rawPath: string) => Promise<void>;
125
140
  /**
126
141
  * Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
127
142
  * the creating surface — a messaging channel, a guest share — has no
@@ -213,22 +228,39 @@ export function createApprovalGateMiddleware(
213
228
  emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
214
229
  return await handler(request);
215
230
  }
216
- // Gitignored path. Only the PARENT gate (captureIgnored) routes it into
217
- // CAS capture; sub-agent gates fall through to the interrupt gate below,
218
- // exactly as before (their backends are not CAS-observed).
231
+ // Gitignored / non-git path: a gate whose backend the shared CAS
232
+ // observer wraps (captureIgnored the parent gate, and sub-agent
233
+ // gates since DD-19) routes it into CAS capture; any other gate falls
234
+ // through to the interrupt gate below.
219
235
  if (config.captureIgnored) {
220
- if (isSecretLikePath(path)) {
221
- // DD-E fail-closed: a secret-like gitignored edit is NEVER applied
222
- // and NEVER captured. Record it so the turn boundary authors a
223
- // DIFF_UNREVIEWABLE entry (blocking approval); nothing is written.
224
- config.recordBlockedSecret?.(path);
225
- return secretBlockToolMessage(toolName, path, toolCall.id);
226
- }
227
236
  if (category === "write") {
237
+ if (isSecretLikePath(path)) {
238
+ // DD-E fail-closed: a secret-like gitignored WRITE is NEVER
239
+ // applied and NEVER captured — its content must not surface
240
+ // anywhere, approval prompt included. Record it so the turn
241
+ // boundary authors a DIFF_UNREVIEWABLE entry (blocking
242
+ // approval); nothing is written.
243
+ config.recordBlockedSecret?.(path);
244
+ return secretBlockToolMessage(toolName, path, toolCall.id);
245
+ }
228
246
  // Non-secret gitignored write/edit: flows (apply-then-review). The
229
247
  // CAS observer already holds its before-bytes and the turn boundary
230
- // captures it into CAS. (A gitignored delete has no backend capture
231
- // path, so it falls through to the interrupt gate.)
248
+ // captures it into CAS.
249
+ emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
250
+ return await handler(request);
251
+ }
252
+ if (category === "delete" && config.captureDeleteBefore && !isSecretLikePath(path)) {
253
+ // Non-secret CAS-owned delete: capture the before-bytes NOW (the
254
+ // one moment they still exist on disk — no backend delete method
255
+ // to observe them), then flow. The turn boundary reads after=null
256
+ // and authors a reviewable, restorable DELETE entry (issue #303).
257
+ //
258
+ // A SECRET-LIKE delete deliberately falls through to the interrupt
259
+ // gate instead: unlike a write, its args expose no secret content,
260
+ // so a human may safely approve it — but its before-bytes must
261
+ // never enter CAS, so it cannot flow. (This also aligns the twins:
262
+ // the Cursor hook routes secret deletes to its deny-gate.)
263
+ await config.captureDeleteBefore(path);
232
264
  emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
233
265
  return await handler(request);
234
266
  }
@@ -244,8 +276,10 @@ export function createApprovalGateMiddleware(
244
276
  // continues) exactly like the capture-mode secret block — a secret write is
245
277
  // never applied or persisted in ANY mode. Placed AFTER the capture block so
246
278
  // capture-mode paths stay byte-identical (a capturable write already flowed;
247
- // a captureIgnored gitignored secret is already blocked). Deletes carry no
248
- // content and stay on the deny-gate. recordBlockedSecret is a no-op unless a
279
+ // a captureIgnored gitignored secret write is already blocked). A delete
280
+ // carries no content, so a secret-like delete needs no hard-block: it stays
281
+ // on the interrupt gate below, where a human may still approve it (its
282
+ // bytes are simply never captured). recordBlockedSecret is a no-op unless a
249
283
  // turn boundary reads it (the git-no-storage case, where it authors a
250
284
  // content-less DIFF_UNREVIEWABLE).
251
285
  if (!serverSlug && category === "write") {
@@ -2,7 +2,8 @@
2
2
  * OpenTelemetry spans and metrics middleware for deep-agent execution.
3
3
  *
4
4
  * Creates stigmer.llm.call and stigmer.mcp.tool_call spans with
5
- * attributes matching the Go workflow-runner schema (pkg/otel/spans.go).
5
+ * attributes matching the span schema inherited from the retired Go
6
+ * workflow-runner (kept stable so dashboards survived the migration).
6
7
  * Also records metrics (histograms + counters) for aggregate dashboards.
7
8
  *
8
9
  * Graceful no-op when no TracerProvider/MeterProvider is configured.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Workspace-relative path normalization for permission-rule-bearing graphs
3
- * (issue #429).
3
+ * (issues #429, #528).
4
4
  *
5
5
  * deepagents' permission enforcement canonicalizes tool-call paths BEFORE any
6
6
  * rule or backend runs, and its validation refuses non-absolute paths — on
@@ -20,13 +20,26 @@
20
20
  * resolves relative paths under the workspace root, so act mode keeps a
21
21
  * byte-zero delta.
22
22
  *
23
+ * It also supplies the workspace root when `ls`/`glob`/`grep` are called
24
+ * with NO path argument (issue #528). Those tools' schema default is "/" —
25
+ * the OS ROOT under the legacy backend — and the default is applied inside
26
+ * the tool, after this seam, so under the workspace read boundary a bare
27
+ * first `ls` would otherwise die with `permission denied for read on /`
28
+ * (the same first-turn degradation class #429 fixed). An EXPLICIT "/" is
29
+ * deliberately NOT rewritten: the model asked for the OS root, and the
30
+ * honest answer under plan mode's read boundary is the rules' denial —
31
+ * silently substituting workspace contents would be an answer to a
32
+ * different question.
33
+ *
23
34
  * Invariant — nothing becomes newly reachable: only paths whose resolution
24
35
  * stays INSIDE the workspace root are rewritten. Escaping relatives (`../x`)
25
36
  * and `~`-carrying paths are left raw so today's validation refusal keeps
26
37
  * speaking (a naive join would resolve `..` away and smuggle an out-of-root
27
38
  * read past validation), and absolute paths pass through byte-untouched.
28
39
  * The middleware converts false errors into correct behavior — never a
29
- * refusal into an allowance the rules didn't decide.
40
+ * refusal into an allowance the rules didn't decide. (The absent-path
41
+ * injection honors the same line: it narrows the tool's own OS-root default
42
+ * to the workspace, granting nothing the rules would refuse.)
30
43
  *
31
44
  * Tool matching is by bare built-in name, the house doctrine (an MCP server
32
45
  * is not expected to shadow a built-in name — see shared/tool-kind.ts); the
@@ -41,9 +54,8 @@ import type { StigmerMiddleware } from "./types.js";
41
54
 
42
55
  /**
43
56
  * The path-bearing argument of each deepagents built-in filesystem tool.
44
- * `ls`/`glob`/`grep` take a base directory `path` (defaulting to "/" when
45
- * omitted absolute, so untouched here); the file tools take `file_path`.
46
- * Confirmed against the installed deepagents' tool definitions.
57
+ * `ls`/`glob`/`grep` take a base directory `path`; the file tools take
58
+ * `file_path`. Confirmed against the installed deepagents' tool definitions.
47
59
  */
48
60
  const PATH_ARG_BY_TOOL: ReadonlyMap<string, string> = new Map([
49
61
  ["ls", "path"],
@@ -54,6 +66,15 @@ const PATH_ARG_BY_TOOL: ReadonlyMap<string, string> = new Map([
54
66
  ["edit_file", "file_path"],
55
67
  ]);
56
68
 
69
+ /**
70
+ * The tools whose path argument is optional with an OS-root ("/") schema
71
+ * default. When the model omits it, this middleware supplies the workspace
72
+ * root instead (issue #528). The file tools are deliberately excluded: an
73
+ * absent `file_path` is a genuine model error, and the tool's own input
74
+ * validation gives the better message.
75
+ */
76
+ const DIR_DEFAULTING_TOOLS: ReadonlySet<string> = new Set(["ls", "glob", "grep"]);
77
+
57
78
  export interface PathNormalizationConfig {
58
79
  /** The workspace root the graph's filesystem backend resolves against. */
59
80
  readonly rootDir: string;
@@ -108,6 +129,22 @@ export function createPathNormalizationMiddleware(
108
129
  if (!argKey) return handler(request);
109
130
 
110
131
  const raw = request.toolCall.args[argKey];
132
+
133
+ // Absent base directory on ls/glob/grep: the middleware sees the
134
+ // model's raw args, BEFORE the tool's zod parse applies the "/"
135
+ // (OS root) schema default — so the omission must be filled here,
136
+ // where the workspace root is known. An explicit "/" is not this
137
+ // case and flows through to an honest rule denial (header doctrine).
138
+ if (raw == null && DIR_DEFAULTING_TOOLS.has(request.toolCall.name)) {
139
+ return handler({
140
+ ...request,
141
+ toolCall: {
142
+ ...request.toolCall,
143
+ args: { ...request.toolCall.args, [argKey]: rootDir },
144
+ },
145
+ });
146
+ }
147
+
111
148
  if (typeof raw !== "string") return handler(request);
112
149
 
113
150
  const normalized = normalizeWorkspacePathArg(raw, rootDir);
@@ -703,6 +703,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
703
703
  { createCursorActivities },
704
704
  { createDeepAgentActivities },
705
705
  { createEnsureThreadActivities },
706
+ { createGenerateSessionSubjectActivities },
706
707
  { createClassifyToolApprovalsActivities },
707
708
  { createDiscoverMcpServerActivities },
708
709
  { createEvaluateExpressionsActivities },
@@ -721,6 +722,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
721
722
  import("./activities/execute-cursor/index.js"),
722
723
  import("./activities/execute-deep-agent/index.js"),
723
724
  import("./activities/ensure-thread.js"),
725
+ import("./activities/generate-session-subject.js"),
724
726
  import("./activities/classify-tool-approvals.js"),
725
727
  import("./activities/discover-mcp-server.js"),
726
728
  import("./activities/evaluate-expressions.js"),
@@ -741,6 +743,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
741
743
  ...createCursorActivities(config),
742
744
  ...createDeepAgentActivities(config),
743
745
  ...createEnsureThreadActivities(),
746
+ ...createGenerateSessionSubjectActivities(config),
744
747
  ...createClassifyToolApprovalsActivities(config),
745
748
  ...createDiscoverMcpServerActivities(config),
746
749
  ...createEvaluateExpressionsActivities(),
package/src/runner.ts CHANGED
@@ -407,6 +407,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
407
407
  { createCursorActivities },
408
408
  { createDeepAgentActivities },
409
409
  { createEnsureThreadActivities },
410
+ { createGenerateSessionSubjectActivities },
410
411
  { createClassifyToolApprovalsActivities },
411
412
  { createDiscoverMcpServerActivities },
412
413
  { createEvaluateExpressionsActivities },
@@ -425,6 +426,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
425
426
  import("./activities/execute-cursor/index.js"),
426
427
  import("./activities/execute-deep-agent/index.js"),
427
428
  import("./activities/ensure-thread.js"),
429
+ import("./activities/generate-session-subject.js"),
428
430
  import("./activities/classify-tool-approvals.js"),
429
431
  import("./activities/discover-mcp-server.js"),
430
432
  import("./activities/evaluate-expressions.js"),
@@ -445,6 +447,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
445
447
  ...createCursorActivities(config),
446
448
  ...createDeepAgentActivities(config),
447
449
  ...createEnsureThreadActivities(),
450
+ ...createGenerateSessionSubjectActivities(config),
448
451
  ...createClassifyToolApprovalsActivities(config),
449
452
  ...createDiscoverMcpServerActivities(config),
450
453
  ...createEvaluateExpressionsActivities(),
@@ -291,6 +291,10 @@ describe("VisionBudget — size and count budgets", () => {
291
291
  });
292
292
 
293
293
  it("ships the production constants agreed in T04 (raw bytes)", () => {
294
+ // Also the ADVERTISED == ENFORCED drift alarm (stigmer/stigmer#365):
295
+ // the registry document advertises these exact values in its
296
+ // `limits.vision` block, pinned by the cloud codec's own test. If this
297
+ // fails, the budget changed on one side only — update both together.
294
298
  expect(MAX_VISION_IMAGE_BYTES).toBe(3 * 1024 * 1024);
295
299
  expect(MAX_VISION_TOTAL_BYTES).toBe(4 * 1024 * 1024);
296
300
  expect(MAX_VISION_IMAGES).toBe(10);
@@ -66,7 +66,7 @@ describe("toMcpClientConfig", () => {
66
66
  });
67
67
  });
68
68
 
69
- it("maps HTTP servers to streamable_http transport", () => {
69
+ it("maps HTTP servers to streamable_http transport with reconnect enabled", () => {
70
70
  const servers = [
71
71
  makeServer({
72
72
  slug: "github",
@@ -80,10 +80,14 @@ describe("toMcpClientConfig", () => {
80
80
  transport: "http",
81
81
  url: "https://api.github.com/mcp",
82
82
  headers: { Authorization: "Bearer tok" },
83
+ // oss#316: severed remote connections (e.g. the hosted bridge
84
+ // restarting mid-execution) re-initialize instead of stranding the
85
+ // agent on a dead session.
86
+ reconnect: { enabled: true, maxAttempts: 3, delayMs: 1_000 },
83
87
  });
84
88
  });
85
89
 
86
- it("maps SSE servers to streamable_http transport", () => {
90
+ it("maps SSE servers to streamable_http transport with reconnect enabled", () => {
87
91
  const servers = [
88
92
  makeServer({
89
93
  slug: "sse-server",
@@ -96,6 +100,7 @@ describe("toMcpClientConfig", () => {
96
100
  transport: "http",
97
101
  url: "https://sse.example.com/mcp",
98
102
  headers: undefined,
103
+ reconnect: { enabled: true, maxAttempts: 3, delayMs: 1_000 },
99
104
  });
100
105
  });
101
106
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unit pins for the plan-mode rule builder (issue #528).
3
+ *
4
+ * The rule ORDER and SHAPE are the security boundary: deepagents evaluates
5
+ * first-match-wins with a permissive default, so the workspace read-allow
6
+ * must precede the read-deny, and the deny patterns must be the match-all
7
+ * "/**". These tests pin the built value; the boundary's end-to-end
8
+ * behavior (including matcher semantics for escaped roots and symlinked
9
+ * platform paths) is pinned against the real deepagents runtime in
10
+ * execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import {
15
+ buildPlanModePermissions,
16
+ escapeGlobLiteral,
17
+ } from "../plan-mode-permissions.js";
18
+
19
+ describe("buildPlanModePermissions", () => {
20
+ it("builds the policy in enforcement order: workspace read-allow, then read-deny, then write-deny", () => {
21
+ expect(buildPlanModePermissions("/ws/session-1/repo")).toEqual([
22
+ { operations: ["read"], paths: ["/ws/session-1/repo/**"] },
23
+ { operations: ["read"], paths: ["/**"], mode: "deny" },
24
+ { operations: ["write"], paths: ["/**"], mode: "deny" },
25
+ ]);
26
+ });
27
+
28
+ it("canonicalizes the root before building the pattern", () => {
29
+ // Enforcement canonicalizes incoming paths (no trailing separator,
30
+ // collapsed slashes) before matching — a pattern built from a raw
31
+ // trailing-slash root would silently match nothing.
32
+ const [allow] = buildPlanModePermissions("/ws//session-1/repo/");
33
+ expect(allow.paths).toEqual(["/ws/session-1/repo/**"]);
34
+ });
35
+
36
+ it("glob-escapes the root so special characters match literally", () => {
37
+ const [allow] = buildPlanModePermissions("/Users/x/My (work) [v2]");
38
+ expect(allow.paths).toEqual(["/Users/x/My \\(work\\) \\[v2\\]/**"]);
39
+ });
40
+ });
41
+
42
+ describe("escapeGlobLiteral", () => {
43
+ it("escapes every micromatch metacharacter", () => {
44
+ expect(escapeGlobLiteral("a*b?c(d)e[f]g{h}i!j+k@l")).toBe(
45
+ "a\\*b\\?c\\(d\\)e\\[f\\]g\\{h\\}i\\!j\\+k\\@l",
46
+ );
47
+ });
48
+
49
+ it("escapes backslashes themselves", () => {
50
+ expect(escapeGlobLiteral("a\\b")).toBe("a\\\\b");
51
+ });
52
+
53
+ it("leaves plain path characters alone", () => {
54
+ expect(escapeGlobLiteral("/ws/session-1/repo.dir")).toBe(
55
+ "/ws/session-1/repo.dir",
56
+ );
57
+ });
58
+ });
@@ -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
  });
@@ -32,6 +32,15 @@
32
32
 
33
33
  // ---------------------------------------------------------------------------
34
34
  // Budget constants (owner decision, 2026-08-09; project T04)
35
+ //
36
+ // ADVERTISED == ENFORCED (stigmer/stigmer#365): the model registry document
37
+ // advertises these values in its `limits.vision` block (rendered by the
38
+ // cloud's ModelRegistryDocumentCodec, mirrored verbatim by the OSS server)
39
+ // so clients can warn BEFORE an upload this budget would degrade. Both sides
40
+ // pin the literal values in tests — attachment-vision.test.ts here, the
41
+ // codec test there — so changing either side alone fails its own suite.
42
+ // Update both together; deploy the runner first if the budget ever shrinks
43
+ // (never advertise more than the runner accepts).
35
44
  // ---------------------------------------------------------------------------
36
45
 
37
46
  /**