@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
@@ -36,12 +36,6 @@ import { connectMcpServers, type McpConnectionResult } from "../../shared/mcp-ma
36
36
  import { mergeMcpServerUsages, resolveMcpServers } from "../../shared/mcp-resolver.js";
37
37
  import { resolveMcpTransportPosture } from "../../shared/mcp-transport-guard.js";
38
38
  import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
39
- import {
40
- DATASTORE_ATTACHMENT_SLUG,
41
- formatDatastoresSection,
42
- missingRecordTools,
43
- synthesizeDatastoreAttachment,
44
- } from "../../shared/datastore-attachment.js";
45
39
  import {
46
40
  discoverChannelMessaging,
47
41
  formatChannelTemplatesSection,
@@ -58,7 +52,7 @@ import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
58
52
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
59
53
  import { createCasCaptureBackend } from "./cas-capture-backend.js";
60
54
  import { buildShellEnv } from "./shell-env.js";
61
- import { PLAN_MODE_PERMISSIONS } from "../../shared/plan-mode-permissions.js";
55
+ import { buildPlanModePermissions } from "../../shared/plan-mode-permissions.js";
62
56
  import { CasCaptureObserver } from "./cas-capture-observer.js";
63
57
  import { isGitWorkTree, isPathCapturable } from "../../shared/filereview/git-substrate.js";
64
58
  import { deriveCaptureMode } from "../../shared/filereview/capture.js";
@@ -129,16 +123,6 @@ export interface SetupResult {
129
123
  readonly session: Session;
130
124
  readonly workspaceBackend: WorkspaceBackend;
131
125
  readonly mcpConnection: McpConnectionResult | null;
132
- /**
133
- * Record tools declared via datastore_usages but absent from the connected
134
- * stigmer-records roster this turn (issue #325). Empty = healthy (also when
135
- * the agent uses no datastores). Non-empty selects the degraded prompt
136
- * section in Step 8, and the activity surfaces it to operators as a
137
- * MESSAGE_SYSTEM row — the vision-facts pattern: setup computes the honest
138
- * struct, the caller discloses. Recomputed every turn, so a healed outage
139
- * stops disclosing on the next turn.
140
- */
141
- readonly datastoreToolsMissing: readonly string[];
142
126
  readonly mergedEnvVars: Record<string, string>;
143
127
  readonly secretKeys: ReadonlySet<string>;
144
128
  readonly modelName: string;
@@ -369,7 +353,6 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
369
353
  agent.spec!.mcpServerUsages || [],
370
354
  session.spec!.mcpServerUsages || [],
371
355
  );
372
- const datastoreUsages = agent.spec!.datastoreUsages || [];
373
356
 
374
357
  // The synthesized attachments' credential story (DD-006 D4): the
375
358
  // exchanged token authenticates the discovery reads per-call (the
@@ -410,7 +393,6 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
410
393
  let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
411
394
  if (shouldConnectMcp({
412
395
  mcpServerUsageCount: mcpServerUsages.length,
413
- datastoreUsageCount: datastoreUsages.length,
414
396
  channelMessagingCount: channelMessaging.length,
415
397
  conversationChannelId,
416
398
  })) {
@@ -444,25 +426,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
444
426
  envResult.secretKeys,
445
427
  );
446
428
 
447
- // The datastore records attachment (T05) — injected AFTER resolve +
448
- // backfill so the destructiveHint tightener can never gate it; empty
449
- // approval maps keep it approval-free by construction (see
450
- // shared/datastore-attachment.ts).
451
- if (datastoreUsages.length > 0) {
452
- const attachment = synthesizeDatastoreAttachment(datastoreUsages, {
453
- bridgeEndpoint: config.mcpBridgeEndpoint,
454
- credential: attachmentCredential,
455
- backendEndpoint: config.stigmerBackendEndpoint,
456
- });
457
- if (attachment) {
458
- backfilledServers = injectSynthesizedAttachment(
459
- backfilledServers, attachment, "datastore records",
460
- );
461
- }
462
- }
463
-
464
- // The channel messaging attachment (DD-006 D7/D8) — the records
465
- // attachment's twin, injected under the same after-backfill rule.
429
+ // The channel messaging attachment (DD-006 D7/D8) — injected AFTER
430
+ // resolve + backfill so the destructiveHint tightener can never gate
431
+ // it; empty approval maps keep it approval-free by construction.
466
432
  if (channelMessaging.length > 0) {
467
433
  const attachment = synthesizeChannelAttachment(channelMessaging, {
468
434
  bridgeEndpoint: config.mcpBridgeEndpoint,
@@ -477,8 +443,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
477
443
  }
478
444
 
479
445
  // The conversation participation attachment (DD-008 D-c) — the
480
- // third sibling, same after-backfill rule. HTTP-only: synthesize
481
- // answers undefined with no bridge endpoint by design (see
446
+ // channel attachment's sibling, same after-backfill rule. HTTP-only:
447
+ // synthesize answers undefined with no bridge endpoint by design (see
482
448
  // shared/conversation-attachment.ts).
483
449
  const conversationAttachment = synthesizeConversationAttachment(conversationChannelId, {
484
450
  bridgeEndpoint: config.mcpBridgeEndpoint,
@@ -585,37 +551,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
585
551
  }
586
552
  timing.mark("inject_attachments");
587
553
 
588
- // Reconcile the declared datastores against the record tools actually
589
- // connected (issue #325): the records roster always serves all five
590
- // tools, so any absence means the store is degraded, and the prompt
591
- // must say so instead of promising tools the agent does not have. The
592
- // connected roster is post-connect truth — when usages exist, MCP
593
- // connect always ran above (a thrown connect already failed the run).
594
- const datastoreToolsMissing = datastoreUsages.length > 0 && mcpConnection
595
- ? missingRecordTools(
596
- (mcpConnection.serverToolMap[DATASTORE_ATTACHMENT_SLUG] ?? [])
597
- .map((t) => t.name),
598
- )
599
- : [];
600
- if (datastoreToolsMissing.length > 0) {
601
- console.warn(
602
- `[setup] Datastore record tools degraded: declared ` +
603
- `${datastoreUsages.length} datastore usage(s) but the ` +
604
- `'${DATASTORE_ATTACHMENT_SLUG}' roster is missing ` +
605
- `[${datastoreToolsMissing.join(", ")}] — the prompt discloses the ` +
606
- `outage instead of the available-datastores section.`,
607
- );
608
- }
609
-
610
554
  // Step 8: Build enhanced system prompt
611
555
  const systemPrompt = buildEnhancedSystemPrompt({
612
556
  instructions,
613
557
  provisionResults,
614
558
  containerRoot: workspaceBackend.rootDir,
615
559
  skillsPromptSection,
616
- datastoresPromptSection: datastoreUsages.length > 0
617
- ? formatDatastoresSection(datastoreUsages, datastoreToolsMissing)
618
- : undefined,
619
560
  // "" (nothing sendable) threads as undefined: the tool alone still
620
561
  // serves text sends inside a 24-hour window (DD-006 D6).
621
562
  channelTemplatesPromptSection: channelMessaging.length > 0
@@ -625,6 +566,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
625
566
  workspaceRoot: workspaceBackend.rootDir,
626
567
  injectedFiles,
627
568
  vision: visionPromptInfo,
569
+ downloadUrlKind: artifactStorage?.downloadUrlKind,
628
570
  interactionMode: execution.spec!.executionConfig?.interactionMode,
629
571
  buildFromPlan: execution.spec!.executionConfig?.buildFromPlan,
630
572
  contextBridge: readContextBridge(session.spec!.metadata),
@@ -656,8 +598,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
656
598
  // every sub-agent graph, AND the path-normalization middleware (issue #429)
657
599
  // all derive from this single value, so a rule-bearing graph can never miss
658
600
  // the shim that keeps prompt-compliant relative paths from dying in rule
659
- // validation (`path must be absolute`).
660
- const planModePermissions = isPlanMode ? PLAN_MODE_PERMISSIONS : undefined;
601
+ // validation (`path must be absolute`). Built from the same rootDir the
602
+ // backends and the shim resolve against, so the workspace read boundary
603
+ // (issue #528) and the paths it must admit agree by construction.
604
+ const planModePermissions = isPlanMode
605
+ ? buildPlanModePermissions(workspaceBackend.rootDir)
606
+ : undefined;
661
607
 
662
608
  const toolServerMap = new Map<string, string>();
663
609
  if (mcpConnection) {
@@ -936,7 +882,6 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
936
882
  session,
937
883
  workspaceBackend,
938
884
  mcpConnection,
939
- datastoreToolsMissing,
940
885
  mergedEnvVars: envResult.mergedEnvVars,
941
886
  secretKeys: envResult.secretKeys,
942
887
  modelName,
package/src/config.ts CHANGED
@@ -61,11 +61,12 @@ export interface Config {
61
61
  readonly stigmerBackendEndpoint: string;
62
62
  readonly stigmerToken: string | null;
63
63
  /**
64
- * The MCP bridge endpoint for the runner-synthesized datastore
65
- * records attachment (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
66
- * https://mcp.stigmer.ai). Null selects the OSS/local shape: a
67
- * spawned `stigmer mcp-server` stdio child against the local backend.
68
- * See shared/datastore-attachment.ts.
64
+ * The MCP bridge endpoint for the runner-synthesized attachments —
65
+ * channel messaging and conversation participation
66
+ * (STIGMER_MCP_BRIDGE_ENDPOINT, e.g. https://mcp.stigmer.ai). Null
67
+ * selects the OSS/local shape: a spawned `stigmer mcp-server` stdio
68
+ * child against the local backend. See shared/channel-attachment.ts
69
+ * and shared/conversation-attachment.ts.
69
70
  */
70
71
  readonly mcpBridgeEndpoint: string | null;
71
72
  /**
@@ -104,9 +104,35 @@ describe("createPathNormalizationMiddleware", () => {
104
104
  expect(args.file_path).toBe("/etc/hosts");
105
105
  });
106
106
 
107
- it("leaves an omitted base path (backend default) alone", async () => {
108
- const args = await argsSeenByHandler("ls", {});
109
- expect(args.path).toBeUndefined();
107
+ it("supplies the workspace root when the base path is omitted on ls/glob/grep", async () => {
108
+ // Deliberate reversal of the original #429 pin ("leaves an omitted base
109
+ // path alone"): the tools' schema default is "/" — the OS ROOT under the
110
+ // legacy backend — applied inside the tool AFTER this seam, so under the
111
+ // #528 workspace read boundary an untouched omission would deny the bare
112
+ // first listing. The middleware fills the omission with the root it
113
+ // already knows.
114
+ for (const tool of ["ls", "glob", "grep"]) {
115
+ const args = await argsSeenByHandler(tool, { pattern: "TOKEN" });
116
+ expect(args.path).toBe(ROOT);
117
+ expect(args.pattern).toBe("TOKEN");
118
+ }
119
+ });
120
+
121
+ it("does not fill an omitted file_path on the file tools", async () => {
122
+ // An absent file_path is a genuine model error — the tool's own input
123
+ // validation gives the better message than any path this seam could invent.
124
+ const args = await argsSeenByHandler("read_file", {});
125
+ expect(args.file_path).toBeUndefined();
126
+ });
127
+
128
+ it("leaves an explicit '/' untouched on the dir tools — the rules answer it honestly", async () => {
129
+ // The model asked for the OS root; under plan mode's read boundary the
130
+ // honest answer is the rules' denial, not silently substituted workspace
131
+ // contents (the #429 never-convert-a-refusal-into-an-allowance doctrine).
132
+ for (const tool of ["ls", "glob", "grep"]) {
133
+ const args = await argsSeenByHandler(tool, { path: "/" });
134
+ expect(args.path).toBe("/");
135
+ }
110
136
  });
111
137
 
112
138
  it("never touches tools outside the built-in filesystem set", async () => {
@@ -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);
@@ -49,6 +49,10 @@ describe("LocalArtifactStorage", () => {
49
49
  expect(url).toBe("http://localhost:7235/artifacts/file.txt");
50
50
  });
51
51
 
52
+ it("self-describes its URLs as local-serve (loopback reach, no expiry — issue #532)", () => {
53
+ expect(storage.downloadUrlKind).toBe("local-serve");
54
+ });
55
+
52
56
  it("returns true for existing keys", async () => {
53
57
  const key = "artifacts/exec-1/file.bin";
54
58
  await storage.upload(key, Buffer.from("data"));
@@ -167,6 +171,11 @@ describe("ProxyArtifactStorage", () => {
167
171
  globalThis.fetch = originalFetch;
168
172
  });
169
173
 
174
+ it("self-describes its URLs as presigned (time-limited, remotely fetchable — issue #532)", () => {
175
+ const s = new ProxyArtifactStorage("https://proxy.example.com", "tok");
176
+ expect(s.downloadUrlKind).toBe("presigned");
177
+ });
178
+
170
179
  it("uploads via presigned URL flow", async () => {
171
180
  const calls: { url: string; method: string; body?: string }[] = [];
172
181
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for the attachment download-URL hand-off policy (issue #532):
3
+ * the branch-independent mint rule, the non-fatal degrade, and the
4
+ * per-kind disclosure wording both harnesses embed.
5
+ */
6
+
7
+ import { describe, it, expect, vi, afterEach } from "vitest";
8
+ import {
9
+ mintAttachmentDownloadUrl,
10
+ downloadUrlDisclosureLine,
11
+ } from "../attachment-download-urls.js";
12
+ import { makeInMemoryArtifactStorage } from "../../__test-utils__/fake-artifact-storage.js";
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ describe("mintAttachmentDownloadUrl", () => {
19
+ it("mints a URL for a storage key", async () => {
20
+ const { storage } = makeInMemoryArtifactStorage();
21
+
22
+ const url = await mintAttachmentDownloadUrl(storage, "attachments/01A/lease.pdf", "lease.pdf");
23
+
24
+ expect(url).toBe("mem://attachments/01A/lease.pdf");
25
+ });
26
+
27
+ it("returns undefined when the attachment has no storage key", async () => {
28
+ const { storage } = makeInMemoryArtifactStorage();
29
+
30
+ const url = await mintAttachmentDownloadUrl(storage, "", "local.csv");
31
+
32
+ expect(url).toBeUndefined();
33
+ expect(storage.getDownloadUrl).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it("returns undefined when no storage is available", async () => {
37
+ const url = await mintAttachmentDownloadUrl(undefined, "attachments/01A/lease.pdf", "lease.pdf");
38
+
39
+ expect(url).toBeUndefined();
40
+ });
41
+
42
+ it("degrades to undefined on a mint failure and logs the degrade (never throws)", async () => {
43
+ const { storage } = makeInMemoryArtifactStorage();
44
+ storage.getDownloadUrl.mockRejectedValueOnce(new Error("presign endpoint unreachable"));
45
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
46
+
47
+ const url = await mintAttachmentDownloadUrl(storage, "attachments/01A/lease.pdf", "lease.pdf");
48
+
49
+ expect(url).toBeUndefined();
50
+ expect(warn).toHaveBeenCalledOnce();
51
+ expect(warn.mock.calls[0][0]).toContain("lease.pdf");
52
+ });
53
+ });
54
+
55
+ describe("downloadUrlDisclosureLine", () => {
56
+ it("promises time-limited single-object access for presigned URLs", () => {
57
+ const line = downloadUrlDisclosureLine("presigned");
58
+
59
+ expect(line).toContain("time-limited");
60
+ expect(line).toContain("single file");
61
+ expect(line).not.toContain("this machine");
62
+ });
63
+
64
+ it("is honest about local-serve reach — same machine only, no expiry claim", () => {
65
+ const line = downloadUrlDisclosureLine("local-serve");
66
+
67
+ expect(line).toContain("reachable only from this machine");
68
+ expect(line).not.toContain("time-limited");
69
+ });
70
+ });
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The channel messaging attachment (proactive-messaging DD-006 D7/D8):
3
3
  * discovery with the never-throw failure posture, both connection
4
- * shapes, the structural approval-freedom the datastore attachment
5
- * pinned before it, and the prompt section's filter/order/cap rules
4
+ * shapes, the structural approval-freedom of synthesized attachments,
5
+ * and the prompt section's filter/order/cap rules
6
6
  * (DD-006 D6). The route is the cross-repo string, guarded here and in
7
7
  * the mcp-server integration test (the TOOL_CALL_LIMIT precedent); the
8
8
  * slug and roster are runner-internal and guarded here alone.
@@ -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,17 +1,14 @@
1
1
  /**
2
- * Composition of ALL THREE synthesized attachments through
3
- * injectSynthesizedAttachment — the first test to chain them the way
4
- * both harness call sites do (datastore, then channels, then
5
- * conversation, each after resolve + backfill). Per-slug independence
6
- * is the property that makes a third attachment safe to add: replacing
7
- * one reserved slug must never disturb its siblings.
2
+ * Composition of the synthesized attachments through
3
+ * injectSynthesizedAttachment — chained the way both harness call sites
4
+ * do (channels, then conversation, each after resolve + backfill).
5
+ * Per-slug independence is the property that makes another attachment
6
+ * safe to add: replacing one reserved slug must never disturb its
7
+ * siblings.
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from "vitest";
11
11
  import type { MessagingChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
12
- import { create } from "@bufbuild/protobuf";
13
- import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
14
- import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
15
12
 
16
13
  import {
17
14
  CHANNEL_ATTACHMENT_SLUG,
@@ -21,10 +18,6 @@ import {
21
18
  CONVERSATION_ATTACHMENT_SLUG,
22
19
  synthesizeConversationAttachment,
23
20
  } from "../conversation-attachment.js";
24
- import {
25
- DATASTORE_ATTACHMENT_SLUG,
26
- synthesizeDatastoreAttachment,
27
- } from "../datastore-attachment.js";
28
21
  import { injectSynthesizedAttachment } from "../synthesized-attachment.js";
29
22
  import type { ResolvedMcpServer } from "../mcp-resolver.js";
30
23
 
@@ -44,47 +37,34 @@ const userServer: ResolvedMcpServer = {
44
37
  discoveredCapabilitiesEmpty: false,
45
38
  };
46
39
 
47
- function allThree(): ResolvedMcpServer[] {
48
- const datastore = synthesizeDatastoreAttachment(
49
- [create(DatastoreUsageSchema, {
50
- datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
51
- })],
52
- options,
53
- )!;
40
+ function bothAttachments(): ResolvedMcpServer[] {
54
41
  const channels = synthesizeChannelAttachment(
55
42
  [{ channel: { channel: "isc-whatsapp", provider: "whatsapp" } as MessagingChannel, templates: [] }],
56
43
  options,
57
44
  )!;
58
45
  const conversation = synthesizeConversationAttachment("agch_1", options)!;
59
46
 
60
- // The harness order at both call sites: datastore, channels, conversation.
61
- let servers = injectSynthesizedAttachment([userServer], datastore, "datastore records");
62
- servers = injectSynthesizedAttachment(servers, channels, "channel messaging");
47
+ // The harness order at both call sites: channels, then conversation.
48
+ let servers = injectSynthesizedAttachment([userServer], channels, "channel messaging");
63
49
  return injectSynthesizedAttachment(servers, conversation, "conversation participation");
64
50
  }
65
51
 
66
- describe("three synthesized attachments in one chain", () => {
67
- it("composes all three after the user's servers, in injection order", () => {
68
- expect(allThree().map((s) => s.slug)).toEqual([
52
+ describe("synthesized attachments in one chain", () => {
53
+ it("composes after the user's servers, in injection order", () => {
54
+ expect(bothAttachments().map((s) => s.slug)).toEqual([
69
55
  "github",
70
- DATASTORE_ATTACHMENT_SLUG,
71
56
  CHANNEL_ATTACHMENT_SLUG,
72
57
  CONVERSATION_ATTACHMENT_SLUG,
73
58
  ]);
74
59
  });
75
60
 
76
61
  it("each rides its own bridge route with the shared credential", () => {
77
- const bySlug = new Map(allThree().map((s) => [s.slug, s]));
78
- expect(bySlug.get(DATASTORE_ATTACHMENT_SLUG)?.url).toBe("https://mcp.stigmer.ai/records");
62
+ const bySlug = new Map(bothAttachments().map((s) => [s.slug, s]));
79
63
  expect(bySlug.get(CHANNEL_ATTACHMENT_SLUG)?.url).toBe("https://mcp.stigmer.ai/channels");
80
64
  expect(bySlug.get(CONVERSATION_ATTACHMENT_SLUG)?.url).toBe(
81
65
  "https://mcp.stigmer.ai/conversation",
82
66
  );
83
- for (const slug of [
84
- DATASTORE_ATTACHMENT_SLUG,
85
- CHANNEL_ATTACHMENT_SLUG,
86
- CONVERSATION_ATTACHMENT_SLUG,
87
- ]) {
67
+ for (const slug of [CHANNEL_ATTACHMENT_SLUG, CONVERSATION_ATTACHMENT_SLUG]) {
88
68
  expect(bySlug.get(slug)?.headers).toEqual({ Authorization: "Bearer sandbox-token" });
89
69
  }
90
70
  });
@@ -96,20 +76,18 @@ describe("three synthesized attachments in one chain", () => {
96
76
  slug: CONVERSATION_ATTACHMENT_SLUG,
97
77
  url: "https://evil.example.com",
98
78
  };
99
- const conversation = synthesizeConversationAttachment("agch_1", options)!;
100
- const datastore = synthesizeDatastoreAttachment(
101
- [create(DatastoreUsageSchema, {
102
- datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
103
- })],
79
+ const channels = synthesizeChannelAttachment(
80
+ [{ channel: { channel: "isc-whatsapp", provider: "whatsapp" } as MessagingChannel, templates: [] }],
104
81
  options,
105
82
  )!;
83
+ const conversation = synthesizeConversationAttachment("agch_1", options)!;
106
84
 
107
- let servers = injectSynthesizedAttachment([impostor, userServer], datastore, "datastore records");
85
+ let servers = injectSynthesizedAttachment([impostor, userServer], channels, "channel messaging");
108
86
  servers = injectSynthesizedAttachment(servers, conversation, "conversation participation");
109
87
 
110
88
  expect(servers.map((s) => s.slug)).toEqual([
111
89
  "github",
112
- DATASTORE_ATTACHMENT_SLUG,
90
+ CHANNEL_ATTACHMENT_SLUG,
113
91
  CONVERSATION_ATTACHMENT_SLUG,
114
92
  ]);
115
93
  expect(servers.find((s) => s.slug === CONVERSATION_ATTACHMENT_SLUG)?.url).toBe(