@stigmer/runner 3.12.4 → 3.12.5

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 (146) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/cursor-mcp-config.d.ts +45 -0
  3. package/dist/activities/execute-cursor/cursor-mcp-config.js +76 -0
  4. package/dist/activities/execute-cursor/cursor-mcp-config.js.map +1 -0
  5. package/dist/activities/execute-cursor/index.d.ts +10 -1
  6. package/dist/activities/execute-cursor/index.js +47 -29
  7. package/dist/activities/execute-cursor/index.js.map +1 -1
  8. package/dist/activities/execute-cursor/prompt-builder.d.ts +13 -2
  9. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  10. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  11. package/dist/activities/execute-cursor/session-lifecycle.d.ts +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.d.ts +2 -21
  13. package/dist/activities/execute-cursor/skill-resolver.js +5 -108
  14. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  15. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +9 -0
  16. package/dist/activities/execute-deep-agent/approval-file-change.js +2 -1
  17. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +18 -1
  19. package/dist/activities/execute-deep-agent/cas-capture-backend.js +20 -3
  20. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/index.js +57 -5
  22. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +9 -0
  24. package/dist/activities/execute-deep-agent/prompt-builder.js +10 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/setup.js +17 -16
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/subagent-transformer.js +17 -11
  29. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +3 -3
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/middleware/path-normalization.d.ts +49 -49
  34. package/dist/middleware/path-normalization.js +73 -84
  35. package/dist/middleware/path-normalization.js.map +1 -1
  36. package/dist/middleware/types.d.ts +3 -3
  37. package/dist/runner-manager.d.ts +1 -1
  38. package/dist/runner-manager.js +22 -15
  39. package/dist/runner-manager.js.map +1 -1
  40. package/dist/runner.js +11 -0
  41. package/dist/runner.js.map +1 -1
  42. package/dist/shared/args-preview.d.ts +8 -0
  43. package/dist/shared/args-preview.js +14 -3
  44. package/dist/shared/args-preview.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +11 -1
  46. package/dist/shared/artifact-storage.js +10 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/declared-preferences.d.ts +47 -0
  49. package/dist/shared/declared-preferences.js +64 -0
  50. package/dist/shared/declared-preferences.js.map +1 -0
  51. package/dist/shared/mcp-enabled-tools.d.ts +2 -3
  52. package/dist/shared/mcp-enabled-tools.js +2 -3
  53. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  54. package/dist/shared/mcp-resolver.d.ts +20 -15
  55. package/dist/shared/mcp-resolver.js +11 -12
  56. package/dist/shared/mcp-resolver.js.map +1 -1
  57. package/dist/shared/placeholder-resolver.d.ts +9 -2
  58. package/dist/shared/placeholder-resolver.js +9 -2
  59. package/dist/shared/placeholder-resolver.js.map +1 -1
  60. package/dist/shared/plan-mode-permissions.d.ts +26 -46
  61. package/dist/shared/plan-mode-permissions.js +27 -55
  62. package/dist/shared/plan-mode-permissions.js.map +1 -1
  63. package/dist/shared/skill-mount.d.ts +89 -0
  64. package/dist/shared/skill-mount.js +142 -0
  65. package/dist/shared/skill-mount.js.map +1 -0
  66. package/dist/shared/skill-writer.d.ts +28 -26
  67. package/dist/shared/skill-writer.js +79 -102
  68. package/dist/shared/skill-writer.js.map +1 -1
  69. package/dist/shared/worker-shutdown.d.ts +67 -0
  70. package/dist/shared/worker-shutdown.js +79 -0
  71. package/dist/shared/worker-shutdown.js.map +1 -0
  72. package/dist/shared/workspace/types.d.ts +3 -2
  73. package/dist/workflow-engine/loader.js +38 -1
  74. package/dist/workflow-engine/loader.js.map +1 -1
  75. package/dist/workflow-engine/tasks/human-input.js +23 -2
  76. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  77. package/package.json +15 -11
  78. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +50 -0
  79. package/src/activities/execute-cursor/__tests__/cursor-mcp-config.test.ts +111 -0
  80. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -94
  81. package/src/activities/execute-cursor/cursor-mcp-config.ts +107 -0
  82. package/src/activities/execute-cursor/index.ts +63 -35
  83. package/src/activities/execute-cursor/prompt-builder.ts +29 -2
  84. package/src/activities/execute-cursor/session-lifecycle.ts +1 -1
  85. package/src/activities/execute-cursor/skill-resolver.ts +10 -137
  86. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +15 -0
  87. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +75 -0
  88. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  89. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  90. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  91. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  92. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +42 -29
  93. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +52 -0
  94. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  95. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +11 -8
  96. package/src/activities/execute-deep-agent/approval-file-change.ts +11 -1
  97. package/src/activities/execute-deep-agent/cas-capture-backend.ts +20 -3
  98. package/src/activities/execute-deep-agent/index.ts +57 -5
  99. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -0
  100. package/src/activities/execute-deep-agent/setup.ts +21 -19
  101. package/src/activities/execute-deep-agent/subagent-transformer.ts +19 -12
  102. package/src/activities/execute-deep-agent/subagent-wiring.ts +3 -3
  103. package/src/index.ts +1 -1
  104. package/src/middleware/__tests__/path-normalization.test.ts +32 -32
  105. package/src/middleware/path-normalization.ts +78 -90
  106. package/src/middleware/types.ts +3 -3
  107. package/src/runner-manager.ts +26 -16
  108. package/src/runner.ts +14 -0
  109. package/src/shared/__tests__/artifact-storage.test.ts +28 -0
  110. package/src/shared/__tests__/bedrock-seam.test.ts +5 -5
  111. package/src/shared/__tests__/declared-preferences.test.ts +109 -0
  112. package/src/shared/__tests__/foundry-seam.test.ts +5 -5
  113. package/src/shared/__tests__/plan-mode-permissions.test.ts +15 -44
  114. package/src/shared/__tests__/skill-mount.test.ts +238 -0
  115. package/src/shared/__tests__/skill-writer.test.ts +151 -128
  116. package/src/shared/__tests__/vertex-adapter.test.ts +30 -3
  117. package/src/shared/__tests__/vertex-seam.test.ts +5 -5
  118. package/src/shared/__tests__/worker-shutdown.test.ts +101 -0
  119. package/src/shared/args-preview.ts +17 -3
  120. package/src/shared/artifact-storage.ts +20 -1
  121. package/src/shared/declared-preferences.ts +84 -0
  122. package/src/shared/mcp-enabled-tools.ts +2 -3
  123. package/src/shared/mcp-resolver.ts +20 -20
  124. package/src/shared/placeholder-resolver.ts +9 -2
  125. package/src/shared/plan-mode-permissions.ts +27 -58
  126. package/src/shared/skill-mount.ts +179 -0
  127. package/src/shared/skill-writer.ts +96 -130
  128. package/src/shared/worker-shutdown.ts +99 -0
  129. package/src/shared/workspace/types.ts +3 -2
  130. package/src/workflow-engine/__tests__/loader.test.ts +51 -0
  131. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +117 -0
  132. package/src/workflow-engine/loader.ts +46 -1
  133. package/src/workflow-engine/tasks/human-input.ts +33 -5
  134. package/dist/activities/execute-cursor/connect-backfill.d.ts +0 -19
  135. package/dist/activities/execute-cursor/connect-backfill.js +0 -27
  136. package/dist/activities/execute-cursor/connect-backfill.js.map +0 -1
  137. package/dist/activities/execute-cursor/mcp-resolver.d.ts +0 -120
  138. package/dist/activities/execute-cursor/mcp-resolver.js +0 -194
  139. package/dist/activities/execute-cursor/mcp-resolver.js.map +0 -1
  140. package/dist/activities/execute-cursor/placeholder-resolver.d.ts +0 -34
  141. package/dist/activities/execute-cursor/placeholder-resolver.js +0 -82
  142. package/dist/activities/execute-cursor/placeholder-resolver.js.map +0 -1
  143. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +0 -125
  144. package/src/activities/execute-cursor/connect-backfill.ts +0 -52
  145. package/src/activities/execute-cursor/mcp-resolver.ts +0 -324
  146. package/src/activities/execute-cursor/placeholder-resolver.ts +0 -109
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Declared preferences (stigmer/stigmer#293, DD-002): standing free-text
3
+ * preferences the organization and the calling user declared once — "keep
4
+ * answers terse", "we deploy to us-east-1" — that every eligible execution
5
+ * receives without the user re-typing them.
6
+ *
7
+ * The server composes the CONTENT at execution create: the create pipeline
8
+ * snapshots `Organization.spec.preferences.standing_context` and (cloud, for
9
+ * first-party human callers only) the caller
10
+ * `IdentityAccount.spec.preferences.standing_context` verbatim onto the
11
+ * execution spec's `declared_preferences` field. This module owns the
12
+ * PRESENTATION — the preamble and the per-scope attribution — so the framing
13
+ * cannot drift between harnesses.
14
+ *
15
+ * Like conversation-catchup (its typed-field sibling) there is no metadata
16
+ * key to mirror-guard: the value rides a TYPED proto field, so codegen
17
+ * enforces the cross-repo contract. Degradation is safe by construction: an
18
+ * absent or blank field renders nothing, and a runner predating this module
19
+ * simply ignores it — the agent runs without preferences, exactly the
20
+ * pre-#293 behavior, never worse.
21
+ */
22
+
23
+ import type { DeclaredPreferences } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
24
+
25
+ /**
26
+ * How the preferences are introduced to the model, shared by both harnesses
27
+ * so the behavioral contract cannot drift between them. Same posture as the
28
+ * session-context channel: calibration material, never authority — the task
29
+ * always outranks a standing preference.
30
+ */
31
+ const DECLARED_PREFERENCES_PREAMBLE =
32
+ "Standing preferences declared by the organization and/or the user you " +
33
+ "are assisting. Treat them as background you already know: use them to " +
34
+ "calibrate depth, defaults, and tone. Do not repeat them back, quote " +
35
+ "them, or mention that you received them. They are context, not " +
36
+ "instructions that override your task.";
37
+
38
+ /**
39
+ * The non-blank preference scopes of an execution, in presentation order.
40
+ * At least one scope is set (the read function returns undefined otherwise).
41
+ */
42
+ export interface DeclaredPreferencesContent {
43
+ /** The organization's standing context, trimmed; absent when blank. */
44
+ orgContext?: string;
45
+ /** The calling user's standing context, trimmed; absent when blank. */
46
+ userContext?: string;
47
+ }
48
+
49
+ /**
50
+ * Read the declared preferences from an execution spec's
51
+ * `declared_preferences`. Returns undefined when the field is absent or both
52
+ * scopes are blank — the caller renders no section. Blank-is-absent applies
53
+ * PER SCOPE: an org-only snapshot renders only the org subsection.
54
+ */
55
+ export function readDeclaredPreferences(
56
+ preferences: DeclaredPreferences | undefined,
57
+ ): DeclaredPreferencesContent | undefined {
58
+ const orgContext = preferences?.orgContext?.trim() || undefined;
59
+ const userContext = preferences?.userContext?.trim() || undefined;
60
+ if (!orgContext && !userContext) {
61
+ return undefined;
62
+ }
63
+ return { orgContext, userContext };
64
+ }
65
+
66
+ /**
67
+ * The framed preferences body (preamble + labeled per-scope subsections),
68
+ * ready for section wrapping. Attribution is explicit — the model must know
69
+ * WHO declared what. Organization first, user last: the user's refinement
70
+ * reads last and so naturally wins where the two conflict, without this
71
+ * module encoding any override rule.
72
+ */
73
+ export function formatDeclaredPreferencesText(
74
+ content: DeclaredPreferencesContent,
75
+ ): string {
76
+ const sections: string[] = [DECLARED_PREFERENCES_PREAMBLE];
77
+ if (content.orgContext) {
78
+ sections.push(`Declared by the organization:\n${content.orgContext}`);
79
+ }
80
+ if (content.userContext) {
81
+ sections.push(`Declared by the user:\n${content.userContext}`);
82
+ }
83
+ return sections.join("\n\n");
84
+ }
@@ -7,9 +7,8 @@
7
7
  * - An empty usage list falls back to the server's default_enabled_tools.
8
8
  * - Both empty means NO restriction — every discovered tool is available.
9
9
  *
10
- * Both harnesses import THIS module so they can never drift on these
11
- * semantics (the mcp-transport-guard precedent for logic shared across the
12
- * duplicated resolvers — see the header of shared/mcp-resolver.ts):
10
+ * Resolution is shared (shared/mcp-resolver.ts threads the effective list
11
+ * onto every ResolvedMcpServer), but ENFORCEMENT is per-harness:
13
12
  * - The deep-agent harness filters the discovered toolset before it reaches
14
13
  * the model (shared/mcp-manager.ts connectMcpServers).
15
14
  * - The Cursor harness cannot hide tools (the Cursor SDK's McpServerConfig
@@ -2,13 +2,17 @@
2
2
  * Resolves Stigmer McpServerUsage references into an intermediate
3
3
  * ResolvedMcpServer format.
4
4
  *
5
- * Consumers: the deep-agent harness (execute-deep-agent/setup.ts), the
6
- * connect backfill (shared/connect-backfill.ts), and the discovery
7
- * activity (activities/discover-mcp-server.ts, via mcpServerToResolved).
8
- * NOTE: the Cursor harness does NOT use this module — it has its own
9
- * near-duplicate resolver at activities/execute-cursor/mcp-resolver.ts.
10
- * A behavioral change here (like the transport guard) must be mirrored
11
- * there until the two are consolidated.
5
+ * THE single resolver for both harnesses (oss#387 retired the Cursor
6
+ * harness's near-duplicate): the deep-agent harness
7
+ * (execute-deep-agent/setup.ts), the Cursor harness
8
+ * (execute-cursor/index.ts), the connect backfill
9
+ * (shared/connect-backfill.ts), and the discovery activity
10
+ * (activities/discover-mcp-server.ts, via mcpServerToResolved) all consume
11
+ * it. Each harness maps the result into its SDK format at the last hop:
12
+ * toMcpClientConfig (shared/mcp-manager.ts) for LangChain,
13
+ * toCursorMcpConfig (execute-cursor/cursor-mcp-config.ts) for the Cursor
14
+ * SDK — so a behavioral change here (like the transport guard) lands in
15
+ * both execution paths by construction.
12
16
  */
13
17
 
14
18
  import type { McpServerUsage, ToolApprovalOverride } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
@@ -51,8 +55,11 @@ export interface ResolvedMcpServer {
51
55
  * enabled_tools, falling back to the server's default_enabled_tools when
52
56
  * the usage's list is empty (see shared/mcp-enabled-tools.ts). Absent when
53
57
  * unrestricted — which keeps synthesized attachment servers (built without
54
- * a usage) and the discovery path unfiltered by construction. Enforced at
55
- * connect time by connectMcpServers (shared/mcp-manager.ts).
58
+ * a usage) and the discovery path unfiltered by construction. Enforcement
59
+ * is per-harness: the deep-agent path filters at connect time
60
+ * (connectMcpServers, shared/mcp-manager.ts); the Cursor SDK config cannot
61
+ * hide tools, so that harness denies non-enabled calls in the HITL hook
62
+ * (see hook-script.ts).
56
63
  */
57
64
  enabledTools?: string[];
58
65
  /**
@@ -61,11 +68,10 @@ export interface ResolvedMcpServer {
61
68
  * override to its own server — a flat cross-server list is how an
62
69
  * override once leaked onto (or silently un-gated) a same-named tool on
63
70
  * another server. Deliberately REQUIRED, not optional: empty means "no
64
- * overrides", and every construction site — including the near-duplicate
65
- * Cursor resolver and the synthesized attachments, which have no usage
66
- * and therefore no layer 3 must say so explicitly, so a forgotten
67
- * mirror cannot compile. Consumed by mergeApprovalPolicies
68
- * (shared/approval-policy.ts).
71
+ * overrides", and every construction site — including the synthesized
72
+ * attachments, which have no usage and therefore no layer 3 must say so
73
+ * explicitly, so a forgotten site cannot compile. Consumed by
74
+ * mergeApprovalPolicies (shared/approval-policy.ts).
69
75
  */
70
76
  toolApprovalOverrides: ToolApprovalOverride[];
71
77
  }
@@ -202,12 +208,6 @@ export function mcpServerToResolved(
202
208
  }
203
209
  }
204
210
 
205
- export function extractMcpServerSlugs(usages: McpServerUsage[]): string[] {
206
- return usages
207
- .map((u) => u.mcpServerRef?.slug)
208
- .filter((s): s is string => !!s);
209
- }
210
-
211
211
  /**
212
212
  * Merge MCP server usages from agent (base) and session (overlay).
213
213
  *
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Strict placeholder resolver for ${VAR_NAME} syntax in MCP server configs.
3
3
  *
4
+ * Port of the Python agent-runner's PlaceholderResolver (strict mode only).
4
5
  * Always raises on unresolved placeholders — sending literal ${VAR} as an
5
- * HTTP header value produces cryptic auth failures from the remote server.
6
+ * HTTP header value (e.g. Authorization: Bearer ${API_KEY}) produces
7
+ * cryptic auth failures from the remote server.
6
8
  */
7
9
 
8
10
  const PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
@@ -41,6 +43,9 @@ export function resolvePlaceholders(
41
43
 
42
44
  /**
43
45
  * Resolve placeholders in all values of a headers map.
46
+ *
47
+ * @throws PlaceholderResolutionError if any header value contains an
48
+ * unresolvable placeholder.
44
49
  */
45
50
  export function resolveHeaders(
46
51
  headers: Record<string, string>,
@@ -55,7 +60,9 @@ export function resolveHeaders(
55
60
 
56
61
  /**
57
62
  * Filter env vars to only keys declared in the MCP server's spec.env.
58
- * Prevents secret over-sharing by restricting the environment to
63
+ *
64
+ * Mirrors the agent-runner's _filter_env_to_declared_keys: prevents
65
+ * secret over-sharing by restricting the subprocess/HTTP environment to
59
66
  * explicitly declared variables.
60
67
  */
61
68
  export function filterEnvToDeclaredKeys(
@@ -2,26 +2,25 @@
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 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.
5
+ * Plan mode's policy is: reads anywhere in the workspace, writes nowhere.
6
+ * Only the write half needs a RULE. The read boundary that issue #528 built
7
+ * out of rules (workspace read-allow + read-deny, glob-escaped real root) is
8
+ * now STRUCTURAL: every native-harness backend is virtual-rooted
9
+ * (`virtualMode: true` see cas-capture-backend.ts, issue #754), so every
10
+ * path a tool can express resolves inside the workspace root and traversal
11
+ * is rejected at resolution. A rule cannot widen that, and no workspace-root
12
+ * glob is needed to narrow it — which also retires the whole
13
+ * special-characters-in-the-root hazard (`escapeGlobLiteral` and its
14
+ * end-to-end matcher suite) that the real-path rules carried.
15
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.
16
+ * Paths in these rules are VIRTUAL: "/" is the workspace root, matched by
17
+ * micromatch against the raw tool-call path (dot:true) after the
18
+ * path-normalization middleware has canonicalized it (relative "/"-rooted;
19
+ * see middleware/path-normalization.ts). The legitimate platform-dir reads
20
+ * (skills, attachments, the approved plan) keep working exactly as before:
21
+ * they are addressed through the `{workspace}/.stigmer` symlink
22
+ * (shared/workspace/stigmer-link.ts), an in-root path string in either
23
+ * dialect.
25
24
  *
26
25
  * Applied in execute-deep-agent/setup.ts to the parent graph AND threaded
27
26
  * into every compiled sub-agent graph: deepagents' parent-permission
@@ -30,13 +29,6 @@
30
29
  * (issue #255). Kept as its own side-effect-free module so tests can pin the
31
30
  * production rules without dragging in setup.ts's import graph.
32
31
  *
33
- * Rules travel with a companion: every graph that carries them also installs
34
- * the path-normalization middleware (middleware/path-normalization.ts,
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
38
- * expression at each composition site so they cannot drift apart.
39
- *
40
32
  * Invariant: never combine these rules with a shell-capable (sandbox)
41
33
  * backend — deepagents rejects that pairing at graph construction (see the
42
34
  * cas-capture-backend.ts header). Plan mode guarantees it by construction:
@@ -44,41 +36,18 @@
44
36
  * for shell capability on both the parent and sub-agent backends.
45
37
  */
46
38
 
47
- import { resolve } from "node:path";
48
39
  import type { FilesystemPermission } from "deepagents";
49
40
 
50
41
  /**
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.
42
+ * Build the plan-mode rule set. One rule IS the policy: writes denied
43
+ * everywhere ("/**" is every virtual path; deepagents evaluates
44
+ * first-match-wins with a permissive default, so reads stay allowed
45
+ * workspace-confined structurally, not by rule).
68
46
  *
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.
47
+ * Deliberately parameterless: the pre-#754 signature took the workspace root
48
+ * to build a real-path read fence; resurrecting a root-derived rule would
49
+ * silently mismatch the virtual dialect the enforcement now sees.
74
50
  */
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
- ];
51
+ export function buildPlanModePermissions(): FilesystemPermission[] {
52
+ return [{ operations: ["write"], paths: ["/**"], mode: "deny" }];
84
53
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Skill mount mechanics — the ONE cache shape shared by both harnesses.
3
+ *
4
+ * A skill "mount" is the on-disk materialization of a skill inside the
5
+ * session's platform directory: `{platformDir}/skills/{name}/` holding
6
+ * SKILL.md plus the artifact's supporting files. Both harnesses write to
7
+ * that same physical location — the Cursor harness directly, the native
8
+ * (deep-agent) harness through the `.stigmer/` virtual namespace that
9
+ * LocalWorkspaceBackend routes there — so they MUST share one cache shape
10
+ * (issues #337/#672). This module owns that shape:
11
+ *
12
+ * - The mount is cached by the skill's content-addressed
13
+ * `status.version_hash`: metadata is still fetched every execution
14
+ * (that keeps latest-version freshness — push a skill update and the
15
+ * very next message picks it up), but the artifact download and file
16
+ * rewrite are skipped when the mounted content's hash already matches.
17
+ * - Remounts rebuild the directory from scratch, so files deleted between
18
+ * skill versions never linger in the mount.
19
+ * - Crash-safe by ordering: the marker is stamped LAST, after every file
20
+ * of the mount landed — a crash mid-write leaves no marker, so the next
21
+ * execution remounts instead of trusting a partial tree.
22
+ *
23
+ * Extracted from execute-cursor/skill-resolver.ts (PR #682) when the
24
+ * deep-agent path adopted the same cache (issue #337). Orchestration —
25
+ * which skills to mount, prompt metadata, degradation logging — stays
26
+ * with each harness; only the per-skill-directory mechanics live here.
27
+ */
28
+
29
+ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
30
+ import { join, dirname, extname, resolve } from "node:path";
31
+ import { ConnectError, Code } from "@connectrpc/connect";
32
+ import type { StigmerClient } from "../client/stigmer-client.js";
33
+ import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
34
+ import { extractZipFileEntries } from "./zip-extract.js";
35
+
36
+ /** Subdirectory of the platform dir where skill mounts live. */
37
+ export const SKILLS_SUBDIR = "skills";
38
+
39
+ /**
40
+ * Marker recording what a skill's mount directory currently holds. Written
41
+ * LAST, after every file of the mount landed — a crash mid-write leaves no
42
+ * marker, so the next execution remounts instead of trusting a partial tree.
43
+ */
44
+ export const MOUNT_MARKER_FILE = ".stigmer-mount.json";
45
+
46
+ export interface MountMarker {
47
+ /** Content-addressed version hash (`Skill.status.version_hash`) of the mounted content. */
48
+ versionHash: string;
49
+ /**
50
+ * Whether the artifact's files are part of the mount. `false` when the
51
+ * skill has no artifact OR when the download failed and the mount fell
52
+ * back to SKILL.md only — the latter makes the next execution retry the
53
+ * download rather than cache the degraded mount.
54
+ */
55
+ artifactMounted: boolean;
56
+ }
57
+
58
+ /**
59
+ * File extensions written with the executable bit set. The ZIP layer is
60
+ * deliberately mode-blind (path + bytes only, see zip-extract.ts), so
61
+ * extension is the only signal available for `./script.sh`-style
62
+ * invocation to work out of the mount.
63
+ */
64
+ const SCRIPT_EXTENSIONS = new Set([".sh", ".py", ".js", ".ts", ".rb", ".pl"]);
65
+
66
+ /**
67
+ * Whether the mount at `skillDir` already holds this version's content.
68
+ *
69
+ * Fresh means: the marker's hash matches AND the mount isn't a degraded
70
+ * SKILL.md-only fallback when the skill does carry an artifact. Any read or
71
+ * parse failure counts as stale — the remount is the safe default.
72
+ */
73
+ export async function mountIsFresh(
74
+ skillDir: string,
75
+ versionHash: string,
76
+ wantsArtifact: boolean,
77
+ ): Promise<boolean> {
78
+ try {
79
+ const raw = await readFile(join(skillDir, MOUNT_MARKER_FILE), "utf-8");
80
+ const marker = JSON.parse(raw) as Partial<MountMarker>;
81
+ return marker.versionHash === versionHash && (marker.artifactMounted === true || !wantsArtifact);
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Download a skill artifact's ZIP bytes, transfer lane first (#675).
89
+ *
90
+ * The URL lane (getArtifactDownloadUrl → HTTP GET) carries any valid skill
91
+ * size; the unary getArtifact response is capped by the server's 10MB gRPC
92
+ * message limit. Servers that predate the lane (and cloud until its sibling
93
+ * lands) answer the mint with UNIMPLEMENTED — those fall back to the unary
94
+ * path, which behaves exactly as before for ≤10MB artifacts.
95
+ *
96
+ * Runs only on a mount-cache miss (the hash-keyed marker above) — a hit
97
+ * skips the transfer entirely, whichever lane would have carried it.
98
+ */
99
+ export async function downloadArtifact(
100
+ client: StigmerClient,
101
+ artifactStorageKey: string,
102
+ ): Promise<Uint8Array | undefined> {
103
+ let minted;
104
+ try {
105
+ minted = await client.getSkillArtifactDownloadUrl(artifactStorageKey);
106
+ } catch (err) {
107
+ if (err instanceof ConnectError && err.code === Code.Unimplemented) {
108
+ const resp = await client.getSkillArtifact(artifactStorageKey);
109
+ return resp.artifact && resp.artifact.length > 0 ? resp.artifact : undefined;
110
+ }
111
+ throw err;
112
+ }
113
+
114
+ const resp = await fetch(minted.url);
115
+ if (!resp.ok) {
116
+ throw new Error(`artifact fetch failed: HTTP ${resp.status} from ${minted.url}`);
117
+ }
118
+ const bytes = new Uint8Array(await resp.arrayBuffer());
119
+ if (minted.sizeBytes > 0n && BigInt(bytes.length) !== minted.sizeBytes) {
120
+ throw new Error(
121
+ `artifact fetch truncated: got ${bytes.length} bytes, expected ${minted.sizeBytes}`,
122
+ );
123
+ }
124
+ return bytes.length > 0 ? bytes : undefined;
125
+ }
126
+
127
+ /**
128
+ * (Re)write a skill's mount directory from scratch.
129
+ *
130
+ * The directory is removed first so files deleted between versions don't
131
+ * linger in the mount, then SKILL.md and the artifact files are written, and
132
+ * the marker is stamped LAST (see MOUNT_MARKER_FILE for the crash-safety
133
+ * contract). SKILL.md always comes from `spec.skillMd` — the server's
134
+ * authoritative copy, which the push pipeline guarantees is non-empty
135
+ * (push.go hard-fails a ZIP without an extractable SKILL.md) — never from
136
+ * the zip; both the zip's SKILL.md and any stray marker-named entry are
137
+ * excluded from extraction so the mount's ownership of those two files is
138
+ * unconditional.
139
+ *
140
+ * Every extracted entry must resolve inside `skillDir`. The server already
141
+ * rejects traversal at push (google/safearchive), but this runner-side
142
+ * check is kept as defense-in-depth: it is the guard the deep-agent path
143
+ * used to get from LocalWorkspaceBackend's platform-path routing, preserved
144
+ * here when skill writes moved to direct fs.
145
+ */
146
+ export async function writeSkillMount(
147
+ skill: Skill,
148
+ skillDir: string,
149
+ artifactBytes: Uint8Array | undefined,
150
+ ): Promise<void> {
151
+ await rm(skillDir, { recursive: true, force: true });
152
+ await mkdir(skillDir, { recursive: true });
153
+
154
+ await writeFile(join(skillDir, "SKILL.md"), skill.spec!.skillMd, "utf-8");
155
+
156
+ const artifactMounted = artifactBytes !== undefined && artifactBytes.length > 0;
157
+ if (artifactMounted) {
158
+ const normalizedSkillDir = resolve(skillDir);
159
+ const entries = await extractZipFileEntries(artifactBytes, { exclude: ["SKILL.md", MOUNT_MARKER_FILE] });
160
+ for (const entry of entries) {
161
+ const filePath = resolve(normalizedSkillDir, entry.path);
162
+ if (filePath !== normalizedSkillDir && !filePath.startsWith(normalizedSkillDir + "/")) {
163
+ throw new Error(
164
+ `skill artifact entry escapes its mount directory: '${entry.path}'`,
165
+ );
166
+ }
167
+ await mkdir(dirname(filePath), { recursive: true });
168
+ await writeFile(filePath, entry.content, {
169
+ mode: SCRIPT_EXTENSIONS.has(extname(entry.path)) ? 0o755 : 0o644,
170
+ });
171
+ }
172
+ }
173
+
174
+ const versionHash = skill.status?.versionHash ?? "";
175
+ if (versionHash !== "") {
176
+ const marker: MountMarker = { versionHash, artifactMounted };
177
+ await writeFile(join(skillDir, MOUNT_MARKER_FILE), JSON.stringify(marker), "utf-8");
178
+ }
179
+ }