@gajae-code/coding-agent 0.18.7 → 0.18.9

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 (344) hide show
  1. package/CHANGELOG.md +377 -1
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/job-manager.d.ts +1 -1
  4. package/dist/types/async/support.d.ts +0 -14
  5. package/dist/types/capability/mcp.d.ts +2 -0
  6. package/dist/types/capability/skill.d.ts +2 -0
  7. package/dist/types/capability/types.d.ts +2 -0
  8. package/dist/types/cli/public-command-errors.d.ts +3 -1
  9. package/dist/types/cli/setup-cli.d.ts +1 -1
  10. package/dist/types/commands/deep-interview.d.ts +3 -0
  11. package/dist/types/commands/mcp-serve.d.ts +4 -0
  12. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  13. package/dist/types/config/file-lock.d.ts +1 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  15. package/dist/types/config/model-profile-activation.d.ts +11 -3
  16. package/dist/types/config/model-registry.d.ts +4 -9
  17. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  18. package/dist/types/cursor-delete-path.d.ts +1 -0
  19. package/dist/types/cursor-mcp-name.d.ts +8 -0
  20. package/dist/types/customization/inventory.d.ts +2 -0
  21. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  24. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  25. package/dist/types/discovery/helpers.d.ts +6 -1
  26. package/dist/types/discovery/mcp-json.d.ts +1 -0
  27. package/dist/types/eval/py/kernel.d.ts +18 -0
  28. package/dist/types/export/html/css-color.d.ts +2 -0
  29. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  30. package/dist/types/export/html/template.generated.d.ts +1 -1
  31. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  32. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  33. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  34. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  35. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  36. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  37. package/dist/types/extensibility/skill-management.d.ts +4 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  40. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  41. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  42. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  44. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  45. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  46. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  47. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  48. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  49. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  50. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  51. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  52. package/dist/types/lsp/config.d.ts +8 -5
  53. package/dist/types/lsp/workspace-path.d.ts +1 -0
  54. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  55. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  56. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  57. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  58. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  59. package/dist/types/modes/interactive-mode.d.ts +1 -0
  60. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  61. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  62. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  63. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  64. package/dist/types/progress/progress-contract.d.ts +154 -0
  65. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  66. package/dist/types/progress/project-progress.d.ts +141 -0
  67. package/dist/types/progress/render-progress.d.ts +28 -0
  68. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  69. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  70. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -0
  71. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  72. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  73. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  74. package/dist/types/runtime-mcp/types.d.ts +6 -0
  75. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  76. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  77. package/dist/types/sdk/broker/broker.d.ts +24 -2
  78. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  79. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  80. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  81. package/dist/types/sdk/bus/index.d.ts +12 -1
  82. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  83. package/dist/types/sdk/client/client.d.ts +2 -0
  84. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  85. package/dist/types/sdk/index.d.ts +1 -0
  86. package/dist/types/sdk/router/session-router.d.ts +2 -0
  87. package/dist/types/sdk/session-list.d.ts +1 -1
  88. package/dist/types/sdk/session.d.ts +8 -0
  89. package/dist/types/session/agent-session.d.ts +38 -9
  90. package/dist/types/session/artifacts.d.ts +2 -2
  91. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  92. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  93. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  94. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  95. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  96. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  97. package/dist/types/session/session-manager.d.ts +31 -5
  98. package/dist/types/session/session-retirement.d.ts +31 -7
  99. package/dist/types/session/session-storage.d.ts +69 -2
  100. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  101. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  102. package/dist/types/setup/credential-import.d.ts +6 -4
  103. package/dist/types/skill-state/active-state.d.ts +38 -0
  104. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  105. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  106. package/dist/types/task/executor.d.ts +3 -0
  107. package/dist/types/task/isolation-dir.d.ts +26 -0
  108. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  109. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  110. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  111. package/dist/types/tools/github-side-effect.d.ts +22 -0
  112. package/dist/types/tools/index.d.ts +3 -0
  113. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  114. package/dist/types/tools/python.d.ts +9 -1
  115. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  116. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  117. package/dist/types/tools/todo-write.d.ts +6 -0
  118. package/dist/types/utils/tools-checksum.d.ts +7 -0
  119. package/package.json +7 -7
  120. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  121. package/scripts/verify-managed-empty-stop.ts +28 -0
  122. package/src/async/endpoint-id.ts +26 -0
  123. package/src/async/job-manager.ts +247 -132
  124. package/src/async/support.ts +0 -25
  125. package/src/capability/index.ts +15 -0
  126. package/src/capability/mcp.ts +2 -0
  127. package/src/capability/skill.ts +2 -0
  128. package/src/capability/types.ts +2 -0
  129. package/src/cli/auth-broker-cli.ts +1 -1
  130. package/src/cli/customize-doctor.ts +15 -2
  131. package/src/cli/doctor/repairs.ts +4 -1
  132. package/src/cli/doctor-supervisor.ts +3 -0
  133. package/src/cli/public-command-errors.ts +21 -1
  134. package/src/cli/public-command-registry.ts +28 -6
  135. package/src/cli/setup-cli.ts +4 -4
  136. package/src/cli/skills-cli.ts +5 -0
  137. package/src/commands/deep-interview.ts +47 -8
  138. package/src/commands/mcp-serve.ts +7 -0
  139. package/src/commands/sdk.ts +455 -397
  140. package/src/config/autorouting-tier-map.ts +117 -7
  141. package/src/config/file-lock.ts +316 -79
  142. package/src/config/mcp-schema.json +4 -0
  143. package/src/config/model-bindings-applier.ts +20 -0
  144. package/src/config/model-profile-activation.ts +240 -46
  145. package/src/config/model-registry.ts +47 -153
  146. package/src/config/skill-settings-defaults.ts +7 -0
  147. package/src/coordinator-mcp/policy.ts +3 -2
  148. package/src/coordinator-mcp/server.ts +2 -0
  149. package/src/cursor-delete-path.ts +10 -0
  150. package/src/cursor-mcp-name.ts +15 -0
  151. package/src/cursor.ts +13 -2
  152. package/src/customization/inventory.ts +4 -13
  153. package/src/dap/config.ts +2 -2
  154. package/src/debug/report-bundle-parent.ts +22 -0
  155. package/src/debug/report-bundle.ts +3 -0
  156. package/src/debug/system-info.ts +13 -1
  157. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  158. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  159. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  160. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  161. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  162. package/src/defaults/gjc-defaults.test.ts +18 -1
  163. package/src/defaults/gjc-defaults.ts +3 -0
  164. package/src/defaults/gjc-skills.generated.ts +58 -36
  165. package/src/discovery/agents.ts +6 -1
  166. package/src/discovery/builtin.ts +13 -1
  167. package/src/discovery/helpers.ts +171 -29
  168. package/src/discovery/mcp-json.ts +26 -12
  169. package/src/discovery/ssh.ts +1 -1
  170. package/src/discovery/substitute-plugin-root.ts +3 -1
  171. package/src/edit/file-read-cache.ts +1 -1
  172. package/src/eval/py/executor.ts +796 -154
  173. package/src/eval/py/kernel.ts +79 -13
  174. package/src/eval/py/tool-bridge.ts +4 -0
  175. package/src/export/html/css-color.ts +9 -0
  176. package/src/export/html/html-export-destination.ts +16 -0
  177. package/src/export/html/index.ts +7 -10
  178. package/src/export/html/template.generated.ts +1 -1
  179. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  180. package/src/extensibility/extensions/runner.ts +15 -1
  181. package/src/extensibility/extensions/types.ts +7 -0
  182. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  183. package/src/extensibility/gjc-plugins/runtime-adapters.ts +85 -8
  184. package/src/extensibility/plugins/manager.ts +3 -2
  185. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  186. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  187. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  188. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  189. package/src/extensibility/skill-management.ts +9 -2
  190. package/src/extensibility/skills.ts +6 -1
  191. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  192. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  193. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  194. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  195. package/src/gjc-runtime/gc-runtime.ts +206 -9
  196. package/src/gjc-runtime/launch-worktree.ts +70 -9
  197. package/src/gjc-runtime/psmux-detect.ts +28 -4
  198. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  199. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  200. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  201. package/src/gjc-runtime/state-argv.ts +2 -0
  202. package/src/gjc-runtime/state-migrations.ts +91 -29
  203. package/src/gjc-runtime/state-runtime.ts +3716 -135
  204. package/src/gjc-runtime/state-writer.ts +105 -16
  205. package/src/gjc-runtime/tmux-common.ts +8 -5
  206. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  207. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  208. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  209. package/src/harness-control-plane/operate.ts +12 -4
  210. package/src/harness-control-plane/owner.ts +5 -1
  211. package/src/hashline/parser.ts +48 -9
  212. package/src/hashline/recovery.ts +13 -4
  213. package/src/internal-urls/docs-index.generated.ts +20 -19
  214. package/src/internal-urls/skill-path.ts +19 -0
  215. package/src/internal-urls/skill-protocol.ts +2 -0
  216. package/src/lsp/config.ts +12 -63
  217. package/src/lsp/edits.ts +6 -0
  218. package/src/lsp/workspace-path.ts +51 -0
  219. package/src/main.ts +195 -31
  220. package/src/memories/index.ts +88 -7
  221. package/src/modes/acp/acp-agent.ts +88 -4
  222. package/src/modes/components/custom-editor.ts +14 -0
  223. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  224. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  225. package/src/modes/components/extensions/state-manager.ts +15 -5
  226. package/src/modes/components/hook-editor.ts +1 -1
  227. package/src/modes/components/hook-selector.ts +1 -1
  228. package/src/modes/components/model-selector.ts +1 -4
  229. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  230. package/src/modes/components/status-line/segments.ts +4 -0
  231. package/src/modes/controllers/event-controller.ts +11 -4
  232. package/src/modes/controllers/input-controller.ts +76 -31
  233. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -16
  234. package/src/modes/controllers/selector-controller.ts +24 -15
  235. package/src/modes/controllers/todo-command-controller.ts +1 -1
  236. package/src/modes/interactive-mode.ts +5 -1
  237. package/src/modes/runtime-init.ts +4 -0
  238. package/src/modes/utils/abort-message.ts +3 -1
  239. package/src/modes/utils/interrupt-ownership.ts +12 -0
  240. package/src/modes/utils/keybinding-matchers.ts +4 -0
  241. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  242. package/src/progress/collect-project-progress.ts +153 -0
  243. package/src/progress/progress-contract.ts +172 -0
  244. package/src/progress/progress-snapshot.ts +140 -0
  245. package/src/progress/project-progress.ts +446 -0
  246. package/src/progress/render-progress.ts +209 -0
  247. package/src/prompts/system/master-mode.md +1 -0
  248. package/src/prompts/tools/skill-discovery.md +1 -1
  249. package/src/runtime-mcp/client.ts +4 -2
  250. package/src/runtime-mcp/config-writer.ts +10 -6
  251. package/src/runtime-mcp/config.ts +1 -0
  252. package/src/runtime-mcp/json-rpc.ts +2 -1
  253. package/src/runtime-mcp/manager.ts +29 -11
  254. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  255. package/src/runtime-mcp/oauth-flow.ts +14 -2
  256. package/src/runtime-mcp/oauth-public-url.ts +14 -0
  257. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  258. package/src/runtime-mcp/pool.ts +6 -0
  259. package/src/runtime-mcp/project-host-values.ts +46 -0
  260. package/src/runtime-mcp/smithery-registry.ts +22 -3
  261. package/src/runtime-mcp/transports/http.ts +4 -3
  262. package/src/runtime-mcp/types.ts +6 -0
  263. package/src/sdk/acp/adapter.ts +95 -8
  264. package/src/sdk/broker/broker-exit.ts +52 -23
  265. package/src/sdk/broker/broker.ts +81 -6
  266. package/src/sdk/broker/doctor-restart.ts +4 -0
  267. package/src/sdk/broker/ensure.ts +31 -2
  268. package/src/sdk/broker/lifecycle.ts +711 -66
  269. package/src/sdk/broker/session-index.ts +191 -81
  270. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  271. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  272. package/src/sdk/bus/index.ts +238 -43
  273. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  274. package/src/sdk/cli/session-cli.ts +17 -3
  275. package/src/sdk/client/client.ts +28 -8
  276. package/src/sdk/host/query/handlers.ts +9 -1
  277. package/src/sdk/host/session-runtime.test.ts +2705 -303
  278. package/src/sdk/host/session-runtime.ts +490 -143
  279. package/src/sdk/host/surface-policy.ts +1 -0
  280. package/src/sdk/index.ts +8 -0
  281. package/src/sdk/lifecycle-session.ts +3 -1
  282. package/src/sdk/prompt-deadline-manager.ts +15 -0
  283. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  284. package/src/sdk/protocol/operation-registry.ts +9 -2
  285. package/src/sdk/router/session-router.ts +82 -18
  286. package/src/sdk/session-list.ts +1 -0
  287. package/src/sdk/session.ts +69 -45
  288. package/src/session/agent-session.ts +679 -238
  289. package/src/session/artifacts.ts +176 -71
  290. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  291. package/src/session/internal/managed-session-scope.ts +3779 -462
  292. package/src/session/internal/managed-session-storage.ts +501 -78
  293. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  294. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  295. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  296. package/src/session/session-manager.ts +951 -133
  297. package/src/session/session-retirement.ts +573 -14
  298. package/src/session/session-storage.ts +447 -15
  299. package/src/session/task-artifact-owner-codec.ts +71 -8
  300. package/src/session/task-artifact-owner-retirement.ts +21 -19
  301. package/src/setup/credential-auto-import.ts +12 -3
  302. package/src/setup/credential-import.ts +176 -5
  303. package/src/setup/paseo/announce.ts +6 -1
  304. package/src/skill-state/active-state.ts +145 -2
  305. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  306. package/src/slash-commands/builtin-registry.ts +39 -9
  307. package/src/slash-commands/helpers/mcp.ts +8 -4
  308. package/src/slash-commands/helpers/progress-report.ts +23 -0
  309. package/src/stt/setup.ts +3 -5
  310. package/src/task/commands.ts +3 -1
  311. package/src/task/executor.ts +4 -0
  312. package/src/task/index.ts +6 -0
  313. package/src/task/isolation-dir.ts +143 -0
  314. package/src/task/worktree.ts +16 -7
  315. package/src/tools/ask.ts +205 -6
  316. package/src/tools/ast-edit-write-path.ts +68 -0
  317. package/src/tools/ast-edit.ts +10 -1
  318. package/src/tools/bash-allowed-prefixes.ts +15 -0
  319. package/src/tools/bash-skill-urls.ts +56 -3
  320. package/src/tools/bash.ts +48 -79
  321. package/src/tools/browser/cdp-url.ts +18 -0
  322. package/src/tools/browser.ts +8 -1
  323. package/src/tools/descriptors.ts +5 -1
  324. package/src/tools/eval.ts +56 -36
  325. package/src/tools/gh.ts +10 -0
  326. package/src/tools/github-side-effect.ts +112 -0
  327. package/src/tools/image-gen.ts +1 -1
  328. package/src/tools/index.ts +3 -0
  329. package/src/tools/monitor-mutation.ts +27 -0
  330. package/src/tools/monitor.ts +7 -0
  331. package/src/tools/python.ts +188 -66
  332. package/src/tools/recipe/index.ts +9 -0
  333. package/src/tools/recipe/recipe-guard.ts +19 -0
  334. package/src/tools/restricted-cwd.ts +10 -0
  335. package/src/tools/skill-discovery.ts +5 -0
  336. package/src/tools/skill.ts +28 -1
  337. package/src/tools/todo-write.ts +10 -1
  338. package/src/tools/tool-catalog.generated.ts +1 -1
  339. package/src/tools/write.ts +1 -0
  340. package/src/utils/external-editor.ts +3 -2
  341. package/src/utils/tools-checksum.ts +34 -0
  342. package/src/utils/tools-manager.ts +24 -6
  343. package/src/web/insane/url-guard.ts +2 -1
  344. package/src/web/search/providers/codex.ts +3 -2
@@ -8,7 +8,9 @@ import type {
8
8
  NativeExactFileIdentity,
9
9
  NativeExactUnlinkResult,
10
10
  NativeOwnerOnlySecurityResult,
11
+ RecoveryFsFile,
11
12
  RecoveryFsIdentity,
13
+ RecoveryFsResult,
12
14
  RecoveryFsRoot,
13
15
  } from "@gajae-code/natives";
14
16
  import { isEnoent, logger } from "@gajae-code/utils";
@@ -49,6 +51,7 @@ export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
49
51
  export const MANAGED_ARTIFACT_MAX_FILES = 50_000;
50
52
  export const MANAGED_ARTIFACT_MAX_FILE_BYTES = 128 * 1024 * 1024;
51
53
  export const MANAGED_ARTIFACT_MAX_TOTAL_BYTES = 512 * 1024 * 1024;
54
+ export const MANAGED_SESSION_READ_RANGE_MAX_BYTES = 64 * 1024 * 1024;
52
55
  const REPLACEMENT_CLEANUP_RECEIPT_MAX_BYTES = 64 * 1024;
53
56
  const REPLACEMENT_CLEANUP_RECEIPT_SCAN_LIMIT = MANAGED_ARTIFACT_MAX_FILES;
54
57
  export const MANAGED_ARTIFACT_COPY_BATCH_SIZE = 256;
@@ -390,6 +393,15 @@ const replacementPublisherOwnerId = randomUUID();
390
393
  const activeReplacementAttempts = new Set<string>();
391
394
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
392
395
 
396
+ /** Exact quarantine names used when native exact-unlink leaves a scrubbed lock inode. */
397
+ export function isManagedLockQuarantineName(name: string): boolean {
398
+ const prefix = ".gjc-lock-";
399
+ const suffix = ".stale";
400
+ return (
401
+ name.startsWith(prefix) && name.endsWith(suffix) && UUID_PATTERN.test(name.slice(prefix.length, -suffix.length))
402
+ );
403
+ }
404
+
393
405
  function replacementReceiptPublisher(bytes: Uint8Array): ReplacementReceiptPublisher | undefined {
394
406
  let record: Record<string, unknown> | undefined;
395
407
  try {
@@ -874,43 +886,24 @@ async function hashReaperFile(pathname: string, expected: fs.BigIntStats): Promi
874
886
  }
875
887
  }
876
888
 
877
- function exactReaperUnlinkSync(
878
- pathname: string,
879
- stat: fs.BigIntStats,
880
- sha256: string,
881
- allowHardLink: boolean,
882
- ): NativeExactUnlinkResult {
883
- const parent = fs.lstatSync(path.dirname(pathname), { bigint: true });
884
- if (!parent.isDirectory() || parent.isSymbolicLink()) return { ok: false, code: "parent_mismatch" };
885
- const identity: NativeExactFileIdentity = {
886
- dev: stat.dev,
887
- ino: stat.ino,
888
- nlink: stat.nlink,
889
- parentDev: parent.dev,
890
- parentIno: parent.ino,
891
- size: stat.size,
892
- mtimeNs: stat.mtimeNs,
893
- sha256,
894
- quarantineName: remnantReapQuarantineName(pathname),
895
- ...(allowHardLink ? { allowHardLink: true, requireHardLink: true } : {}),
896
- };
897
- return nativeSessionStorage().exactUnlinkDirect(pathname, identity);
889
+ interface ReaperParentIdentity {
890
+ readonly dev: bigint;
891
+ readonly ino: bigint;
898
892
  }
899
893
 
900
- async function exactReaperUnlink(
894
+ function exactReaperUnlinkSync(
901
895
  pathname: string,
902
896
  stat: fs.BigIntStats,
903
897
  sha256: string,
904
898
  allowHardLink: boolean,
905
- ): Promise<NativeExactUnlinkResult> {
906
- const parent = await fsp.lstat(path.dirname(pathname), { bigint: true });
907
- if (!parent.isDirectory() || parent.isSymbolicLink()) return { ok: false, code: "parent_mismatch" };
899
+ parentIdentity: ReaperParentIdentity,
900
+ ): NativeExactUnlinkResult {
908
901
  const identity: NativeExactFileIdentity = {
909
902
  dev: stat.dev,
910
903
  ino: stat.ino,
911
904
  nlink: stat.nlink,
912
- parentDev: parent.dev,
913
- parentIno: parent.ino,
905
+ parentDev: parentIdentity.dev,
906
+ parentIno: parentIdentity.ino,
914
907
  size: stat.size,
915
908
  mtimeNs: stat.mtimeNs,
916
909
  sha256,
@@ -957,10 +950,12 @@ export function reapScrubbedProtocolRemnantsSync(
957
950
  const cutoff = Date.now() - minAgeMs;
958
951
  let reaped = 0;
959
952
  let failures = 0;
953
+ // Immutable expected identity only; native reopens and revalidates the parent per unlink.
954
+ let parentIdentity: ReaperParentIdentity | undefined;
960
955
  for (const name of names) {
961
956
  const terminalRemnant = SCRUBBED_REMNANT_PREFIXES.some(prefix => name.startsWith(prefix));
962
- const replacementCandidate = REPLACEMENT_STAGING_NAME.test(name);
963
- const quarantineCandidate = REAPER_QUARANTINE_NAME.test(name);
957
+ const replacementCandidate = !terminalRemnant && REPLACEMENT_STAGING_NAME.test(name);
958
+ const quarantineCandidate = !terminalRemnant && REAPER_QUARANTINE_NAME.test(name);
964
959
  if (!terminalRemnant && !replacementCandidate && !quarantineCandidate) continue;
965
960
  const alias = terminalRemnant
966
961
  ? undefined
@@ -980,9 +975,29 @@ export function reapScrubbedProtocolRemnantsSync(
980
975
  if (named.mtimeMs > cutoff) continue;
981
976
  const sha256 = terminalRemnant ? EMPTY_FILE_SHA256 : hashReaperFileSync(pathname, named);
982
977
  if (!sha256) continue;
983
- const removed = exactReaperUnlinkSync(pathname, named, sha256, !terminalRemnant);
978
+ if (!parentIdentity) {
979
+ const parent = fs.lstatSync(path.dirname(pathname), { bigint: true });
980
+ if (!parent.isDirectory() || parent.isSymbolicLink()) {
981
+ failures += 1;
982
+ continue;
983
+ }
984
+ parentIdentity = { dev: parent.dev, ino: parent.ino };
985
+ }
986
+ const removed = exactReaperUnlinkSync(pathname, named, sha256, !terminalRemnant, parentIdentity);
984
987
  if (removed.ok) reaped += 1;
985
- else if (!isBenignReaperRace(removed)) failures += 1;
988
+ else {
989
+ let parentKindMismatch = false;
990
+ if (removed.code === "reparse_point") {
991
+ try {
992
+ const parent = fs.lstatSync(path.dirname(pathname), { bigint: true });
993
+ parentKindMismatch = !parent.isDirectory() || parent.isSymbolicLink();
994
+ } catch (error) {
995
+ // Preserve lookup-error accounting without changing or retrying the native result.
996
+ if (!isEnoent(error)) failures += 1;
997
+ }
998
+ }
999
+ if (parentKindMismatch || !isBenignReaperRace(removed)) failures += 1;
1000
+ }
986
1001
  } catch (error) {
987
1002
  if (!isEnoent(error)) failures += 1;
988
1003
  }
@@ -1016,10 +1031,11 @@ export async function reapScrubbedProtocolRemnants(
1016
1031
  let reaped = 0;
1017
1032
  let failures = 0;
1018
1033
  let scanned = 0;
1034
+ let parentIdentity: ReaperParentIdentity | undefined;
1019
1035
  for (const name of names) {
1020
1036
  const terminalRemnant = SCRUBBED_REMNANT_PREFIXES.some(prefix => name.startsWith(prefix));
1021
- const replacementCandidate = REPLACEMENT_STAGING_NAME.test(name);
1022
- const quarantineCandidate = REAPER_QUARANTINE_NAME.test(name);
1037
+ const replacementCandidate = !terminalRemnant && REPLACEMENT_STAGING_NAME.test(name);
1038
+ const quarantineCandidate = !terminalRemnant && REAPER_QUARANTINE_NAME.test(name);
1023
1039
  if (!terminalRemnant && !replacementCandidate && !quarantineCandidate) continue;
1024
1040
  if (++scanned % SCRUBBED_REMNANT_REAP_BATCH_SIZE === 0) await Bun.sleep(0);
1025
1041
  const alias = terminalRemnant
@@ -1038,9 +1054,29 @@ export async function reapScrubbedProtocolRemnants(
1038
1054
  if (named.mtimeMs > cutoff) continue;
1039
1055
  const sha256 = terminalRemnant ? EMPTY_FILE_SHA256 : await hashReaperFile(pathname, named);
1040
1056
  if (!sha256) continue;
1041
- const removed = await exactReaperUnlink(pathname, named, sha256, !terminalRemnant);
1057
+ if (!parentIdentity) {
1058
+ const parent = await fsp.lstat(path.dirname(pathname), { bigint: true });
1059
+ if (!parent.isDirectory() || parent.isSymbolicLink()) {
1060
+ failures += 1;
1061
+ continue;
1062
+ }
1063
+ parentIdentity = { dev: parent.dev, ino: parent.ino };
1064
+ }
1065
+ const removed = await exactReaperUnlinkSync(pathname, named, sha256, !terminalRemnant, parentIdentity);
1042
1066
  if (removed.ok) reaped += 1;
1043
- else if (!isBenignReaperRace(removed)) failures += 1;
1067
+ else {
1068
+ let parentKindMismatch = false;
1069
+ if (removed.code === "reparse_point") {
1070
+ try {
1071
+ const parent = await fsp.lstat(path.dirname(pathname), { bigint: true });
1072
+ parentKindMismatch = !parent.isDirectory() || parent.isSymbolicLink();
1073
+ } catch (error) {
1074
+ // Preserve lookup-error accounting without changing or retrying the native result.
1075
+ if (!isEnoent(error)) failures += 1;
1076
+ }
1077
+ }
1078
+ if (parentKindMismatch || !isBenignReaperRace(removed)) failures += 1;
1079
+ }
1044
1080
  } catch (error) {
1045
1081
  if (!isEnoent(error)) failures += 1;
1046
1082
  }
@@ -1163,7 +1199,7 @@ type RetainedManagedReplacer = {
1163
1199
  expectedSha256: string,
1164
1200
  ): { ok: boolean; code?: string };
1165
1201
  };
1166
- type LockRecord = {
1202
+ export type LockRecord = {
1167
1203
  attemptId: string;
1168
1204
  pid: number;
1169
1205
  bootId?: string;
@@ -1244,8 +1280,8 @@ export function retainManagedDirectoryAuthority(
1244
1280
  expected?: { dev: bigint; ino: bigint },
1245
1281
  ): RecoveryFsRoot | undefined {
1246
1282
  assertManagedDirectoryRoot(root);
1247
- managedRelativePath(root, directory);
1248
- const resolved = path.resolve(directory);
1283
+ const resolved = canonicalizeManagedPathWithinRoot(root.canonicalPath, directory);
1284
+ const relative = managedRelativePath(root, resolved).join("/");
1249
1285
  if (process.platform !== "linux") return undefined;
1250
1286
  const named = fs.lstatSync(resolved, { bigint: true });
1251
1287
  if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("Managed directory authority is unavailable");
@@ -1261,7 +1297,6 @@ export function retainManagedDirectoryAuthority(
1261
1297
  retainedRoot.identity.ino !== root.ino.toString()
1262
1298
  )
1263
1299
  throw new Error("Managed root authority changed");
1264
- const relative = path.relative(root.canonicalPath, resolved).split(path.sep).join("/");
1265
1300
  const rootRecovery = rootAuthority.recoveryReaperMetrics();
1266
1301
  const rootRecoveryUnhealthy = !rootRecovery.ok || Boolean(rootRecovery.code);
1267
1302
  if (rootRecoveryUnhealthy)
@@ -1330,14 +1365,79 @@ function ensureManagedRoot(root: ManagedDirectoryRoot): void {
1330
1365
  assertManagedDirectoryRoot(root);
1331
1366
  }
1332
1367
 
1333
- function managedRelativePath(root: ManagedDirectoryRoot, pathname: string): readonly string[] {
1334
- const relative = path.relative(root.canonicalPath, path.resolve(pathname));
1368
+ /** Resolve aliases above a managed root without following descendant symlinks. */
1369
+ function canonicalizeManagedPathWithinRoot(rootPath: string, pathname: string): string {
1370
+ const canonicalRootPath = path.resolve(rootPath);
1371
+ const namedRoot = fs.lstatSync(canonicalRootPath);
1372
+ if (!namedRoot.isDirectory() || namedRoot.isSymbolicLink())
1373
+ throw new Error(`Managed root authority changed: ${canonicalRootPath}`);
1374
+ const resolvedPath = path.resolve(pathname);
1375
+ const filesystemRoot = path.parse(resolvedPath).root;
1376
+ const components = path.relative(filesystemRoot, resolvedPath).split(path.sep).filter(Boolean);
1377
+ let currentPath = filesystemRoot;
1378
+ let reachedManagedRoot = currentPath === canonicalRootPath;
1379
+ if (reachedManagedRoot) currentPath = canonicalRootPath;
1380
+
1381
+ for (let index = 0; index < components.length; index++) {
1382
+ const component = components[index]!;
1383
+ const nextPath = path.join(currentPath, component);
1384
+ if (!reachedManagedRoot) {
1385
+ let canonicalPrefix: string;
1386
+ try {
1387
+ canonicalPrefix = fs.realpathSync.native(nextPath);
1388
+ } catch (error) {
1389
+ const code = (error as NodeJS.ErrnoException).code;
1390
+ if (code === "ENOENT" || code === "ENOTDIR")
1391
+ throw new Error(`Managed path escapes configured root: ${pathname}`, { cause: error });
1392
+ throw error;
1393
+ }
1394
+ if (canonicalPrefix === canonicalRootPath) {
1395
+ reachedManagedRoot = true;
1396
+ currentPath = canonicalRootPath;
1397
+ continue;
1398
+ }
1399
+ currentPath = nextPath;
1400
+ continue;
1401
+ }
1402
+
1403
+ let named: fs.Stats;
1404
+ try {
1405
+ named = fs.lstatSync(nextPath);
1406
+ } catch (error) {
1407
+ const code = (error as NodeJS.ErrnoException).code;
1408
+ if (code === "ENOENT") return path.join(currentPath, ...components.slice(index));
1409
+ throw error;
1410
+ }
1411
+ if (named.isSymbolicLink()) throw new Error(`Managed path contains symlink: ${nextPath}`);
1412
+ if (index < components.length - 1 && !named.isDirectory())
1413
+ throw new Error(`Managed path component is not a directory: ${nextPath}`);
1414
+ currentPath = nextPath;
1415
+ }
1416
+
1417
+ if (!reachedManagedRoot) throw new Error(`Managed path escapes configured root: ${pathname}`);
1418
+ return currentPath;
1419
+ }
1420
+
1421
+ function relativeWithinManagedRoot(rootPath: string, pathname: string): readonly string[] {
1422
+ const canonicalPath = canonicalizeManagedPathWithinRoot(rootPath, pathname);
1423
+ const relative = path.relative(rootPath, canonicalPath);
1424
+ if (process.platform === "win32") {
1425
+ const canonicalRoot = path.win32.normalize(rootPath);
1426
+ const canonicalCandidate = path.win32.normalize(canonicalPath);
1427
+ const rootPrefix = canonicalRoot.endsWith(path.win32.sep) ? canonicalRoot : `${canonicalRoot}${path.win32.sep}`;
1428
+ if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(rootPrefix))
1429
+ throw new Error(`Managed path escapes configured root: ${pathname}`);
1430
+ }
1335
1431
  if (relative === "") return [];
1336
1432
  if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
1337
1433
  throw new Error(`Managed path escapes configured root: ${pathname}`);
1338
1434
  return relative.split(path.sep);
1339
1435
  }
1340
1436
 
1437
+ function managedRelativePath(root: ManagedDirectoryRoot, pathname: string): readonly string[] {
1438
+ return relativeWithinManagedRoot(root.canonicalPath, pathname);
1439
+ }
1440
+
1341
1441
  const PROCESS_START_ID = randomUUID();
1342
1442
 
1343
1443
  class ManagedSecurityError extends Error {
@@ -1429,7 +1529,12 @@ function secureExistingManagedDirectory(pathname: string, kind: "directory" | "f
1429
1529
  );
1430
1530
  assertManagedPathIdentity(pathname, kind, named);
1431
1531
  if (verified.ok) return;
1432
- if (verified.code !== "acl_verify_failed") throw securityError(pathname, verified);
1532
+ // owner_mismatch: directory owner is not the current user. Repair attempts ownership transfer.
1533
+ // If the user lacks SeTakeOwnershipPrivilege (non-elevated), the repair will fail with
1534
+ // io_error, which is caught as a security error with clear context below.
1535
+ if (verified.code !== "acl_verify_failed" && verified.code !== "owner_mismatch") {
1536
+ throw securityError(pathname, verified);
1537
+ }
1433
1538
  const repaired = validateNativeSecurityResult(
1434
1539
  nativeSessionStorage().repairOwnerOnlyPathSecurityExpected(
1435
1540
  pathname,
@@ -1440,7 +1545,16 @@ function secureExistingManagedDirectory(pathname: string, kind: "directory" | "f
1440
1545
  "verify",
1441
1546
  kind,
1442
1547
  );
1443
- if (!repaired.ok) throw securityError(pathname, repaired);
1548
+ if (!repaired.ok) {
1549
+ // If initial verification was owner_mismatch and repair failed (typically io_error
1550
+ // when the user lacks SeTakeOwnershipPrivilege), provide clear context.
1551
+ if (verified.code === "owner_mismatch" && repaired.code === "io_error") {
1552
+ throw new Error(
1553
+ `${kind} owner mismatch: unable to take ownership (need administrator privileges or file ownership): ${pathname}`,
1554
+ );
1555
+ }
1556
+ throw securityError(pathname, repaired);
1557
+ }
1444
1558
  assertManagedPathIdentity(pathname, kind, named);
1445
1559
  }
1446
1560
 
@@ -1452,11 +1566,18 @@ function secureManagedDirectory(pathname: string, created: boolean, policy: Mana
1452
1566
  secure(pathname, "directory");
1453
1567
  }
1454
1568
 
1569
+ /** Exact, identity-bound read access retained for the lifetime of one managed stream. */
1570
+ export interface ManagedSessionReadLease {
1571
+ readRange(start: number, length: number): Uint8Array;
1572
+ close(): void;
1573
+ }
1574
+
1455
1575
  /** Internal retained-root capability for one managed descendant subtree. */
1456
1576
  export class ManagedSessionDescendantStore {
1457
1577
  readonly #root: ManagedDirectoryRoot;
1458
1578
  readonly #baseDir: string;
1459
1579
  readonly #policy: ManagedSessionSecurityPolicy;
1580
+ readonly #access: "read-only" | "read-write";
1460
1581
  readonly #authority: RecoveryFsRoot | undefined;
1461
1582
  #ownsAuthority = false;
1462
1583
  #closed = false;
@@ -1475,16 +1596,52 @@ export class ManagedSessionDescendantStore {
1475
1596
  policy?: ManagedSessionSecurityPolicy,
1476
1597
  profileAgentDir?: string,
1477
1598
  expectedSubtreeRoot?: ManagedDirectoryRoot,
1599
+ access: "read-only" | "read-write" = "read-write",
1478
1600
  ) {
1479
- managedRelativePath(root, baseDir);
1601
+ if (access !== "read-only" && access !== "read-write") throw new Error("managed_store_access_invalid");
1602
+ if (access === "read-only" && retained) throw new Error("managed_read_store_cannot_borrow_authority");
1603
+ if (access === "read-only" && !expectedSubtreeRoot)
1604
+ throw new Error("managed_read_store_requires_existing_identity");
1605
+ assertManagedDirectoryRoot(root);
1606
+ let canonicalBaseDir: string;
1607
+ try {
1608
+ canonicalBaseDir = canonicalizeManagedPathWithinRoot(root.canonicalPath, baseDir);
1609
+ } catch (error) {
1610
+ // If canonicalization fails with a symlink error on a retained path,
1611
+ // the binding has changed: the path no longer resolves to the original inode.
1612
+ if (retained && error instanceof Error && error.message.startsWith("Managed path contains symlink:")) {
1613
+ throw new Error("Managed descendant root binding changed");
1614
+ }
1615
+ throw error;
1616
+ }
1617
+ let canonicalAuthorityBaseDir: string;
1618
+ if (retained) {
1619
+ try {
1620
+ canonicalAuthorityBaseDir = canonicalizeManagedPathWithinRoot(
1621
+ root.canonicalPath,
1622
+ retained.authorityBaseDir,
1623
+ );
1624
+ } catch (error) {
1625
+ // If canonicalization fails with a symlink error on a retained path,
1626
+ // the binding has changed: the path no longer resolves to the original inode.
1627
+ if (error instanceof Error && error.message.startsWith("Managed path contains symlink:")) {
1628
+ throw new Error("Managed descendant root binding changed");
1629
+ }
1630
+ throw error;
1631
+ }
1632
+ } else {
1633
+ canonicalAuthorityBaseDir = canonicalBaseDir;
1634
+ }
1635
+ managedRelativePath(root, canonicalBaseDir);
1480
1636
 
1481
1637
  this.#root = root;
1482
- this.#baseDir = path.resolve(baseDir);
1638
+ this.#baseDir = canonicalBaseDir;
1483
1639
  this.#policy = policy ?? "default";
1640
+ this.#access = access;
1484
1641
  this.#profileAgentDir = profileAgentDir ?? root.canonicalPath;
1485
- this.#authorityBaseDir = retained?.authorityBaseDir ?? this.#baseDir;
1642
+ this.#authorityBaseDir = canonicalAuthorityBaseDir;
1486
1643
  if (retained) {
1487
- const relative = path.relative(retained.authorityBaseDir, this.#baseDir).split(path.sep).join("/");
1644
+ const relative = relativeWithinManagedRoot(canonicalAuthorityBaseDir, this.#baseDir).join("/");
1488
1645
  // For the root case (authorityBaseDir === baseDir, relative === ""), the
1489
1646
  // stable identity() result carries the exact root dev/inode without
1490
1647
  // snapshotting the entire live session tree. snapshotManagedTree("")
@@ -1558,7 +1715,7 @@ export class ManagedSessionDescendantStore {
1558
1715
  dev: canonicalFileId(subtreeStat.dev),
1559
1716
  ino: canonicalFileId(subtreeStat.ino),
1560
1717
  });
1561
- if (process.platform === "linux") {
1718
+ if (process.platform === "linux" && access === "read-write") {
1562
1719
  const before = fs.lstatSync(this.#baseDir, { bigint: true });
1563
1720
  const authority = nativeSessionStorage().openRecoveryFsRoot(this.#baseDir);
1564
1721
  const retained = authority.identity();
@@ -1606,19 +1763,27 @@ export class ManagedSessionDescendantStore {
1606
1763
  child.dev.toString(),
1607
1764
  child.ino.toString(),
1608
1765
  );
1609
- return new ManagedSessionDescendantStore(
1610
- this.#root,
1611
- resolved,
1612
- {
1613
- authority: retainedChild,
1614
- authorityBaseDir: resolved,
1615
- },
1616
- this.#policy,
1617
- this.#profileAgentDir,
1618
- );
1766
+ try {
1767
+ const derived = new ManagedSessionDescendantStore(
1768
+ this.#root,
1769
+ resolved,
1770
+ {
1771
+ authority: retainedChild,
1772
+ authorityBaseDir: resolved,
1773
+ },
1774
+ this.#policy,
1775
+ this.#profileAgentDir,
1776
+ );
1777
+ derived.#ownsAuthority = true;
1778
+ return derived;
1779
+ } catch (error) {
1780
+ retainedChild.close();
1781
+ throw error;
1782
+ }
1619
1783
  }
1620
1784
 
1621
1785
  retainAuthority(): RecoveryFsRoot | undefined {
1786
+ this.#assertWritable();
1622
1787
  if (!this.#authority) return undefined;
1623
1788
  return this.#authority.retainManagedDirectory(
1624
1789
  "",
@@ -1673,7 +1838,7 @@ export class ManagedSessionDescendantStore {
1673
1838
  captureDirectoryIdentity(relativePath: string): { dev: string; ino: string } {
1674
1839
  this.#assertBound();
1675
1840
  const resolved = this.#resolve(relativePath);
1676
- if (!this.#authority) this.#assertPathBackedDirectoryChain(resolved);
1841
+ if (this.#access === "read-only" || !this.#authority) this.#assertPathBackedDirectoryChain(resolved);
1677
1842
  const named = fs.lstatSync(resolved, { bigint: true });
1678
1843
  if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("managed_directory_identity_unavailable");
1679
1844
  const dev = canonicalFileId(named.dev);
@@ -1714,9 +1879,10 @@ export class ManagedSessionDescendantStore {
1714
1879
 
1715
1880
  #assertBound(): void {
1716
1881
  if (!this.#authority) {
1717
- const named = fs.statSync(this.#baseDir, { bigint: true });
1882
+ const named = fs.lstatSync(this.#baseDir, { bigint: true });
1718
1883
  if (
1719
1884
  !named.isDirectory() ||
1885
+ named.isSymbolicLink() ||
1720
1886
  canonicalFileId(named.dev) !== this.#subtreeRoot.dev ||
1721
1887
  canonicalFileId(named.ino) !== this.#subtreeRoot.ino
1722
1888
  )
@@ -2019,7 +2185,12 @@ export class ManagedSessionDescendantStore {
2019
2185
  }
2020
2186
  }
2021
2187
 
2188
+ #assertWritable(): void {
2189
+ if (this.#access === "read-only") throw new Error("managed_store_read_only");
2190
+ }
2191
+
2022
2192
  #beforeMutation(): void {
2193
+ this.#assertWritable();
2023
2194
  this.#assertBound();
2024
2195
  // Reaping is scheduled BEFORE reconciliation, not after. The receipt scan
2025
2196
  // throws `managed_replace_cleanup_receipt_limit_exceeded` once the bound
@@ -2591,12 +2762,18 @@ export class ManagedSessionDescendantStore {
2591
2762
  canonicalFileId(rootBefore.ino) !== this.#subtreeRoot.ino
2592
2763
  )
2593
2764
  throw new Error("Managed descendant root binding changed");
2594
- let fd: number | undefined;
2765
+ let fd: number;
2595
2766
  try {
2596
2767
  fd = fs.openSync(
2597
2768
  resolved,
2598
2769
  fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
2599
2770
  );
2771
+ } catch (error) {
2772
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
2773
+ throw error;
2774
+ }
2775
+ let descriptor: SessionStorageStat;
2776
+ try {
2600
2777
  const opened = fs.fstatSync(fd, { bigint: true });
2601
2778
  const named = fs.lstatSync(resolved, { bigint: true });
2602
2779
  if (
@@ -2618,7 +2795,7 @@ export class ManagedSessionDescendantStore {
2618
2795
  )
2619
2796
  throw new Error("Managed descendant root binding changed");
2620
2797
  this.#assertBound();
2621
- return managedAppendReceiptFromIdentity({
2798
+ descriptor = managedAppendReceiptFromIdentity({
2622
2799
  dev: canonicalFileId(opened.dev),
2623
2800
  ino: canonicalFileId(opened.ino),
2624
2801
  nlink: opened.nlink,
@@ -2627,11 +2804,13 @@ export class ManagedSessionDescendantStore {
2627
2804
  ctimeNs: opened.ctimeNs,
2628
2805
  }).descriptor;
2629
2806
  } catch (error) {
2630
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
2807
+ try {
2808
+ fs.closeSync(fd);
2809
+ } catch {}
2631
2810
  throw error;
2632
- } finally {
2633
- if (fd !== undefined) fs.closeSync(fd);
2634
2811
  }
2812
+ fs.closeSync(fd);
2813
+ return descriptor;
2635
2814
  }
2636
2815
  const stat = this.#authority.stat(this.#relative(resolved));
2637
2816
  if (!stat.ok) {
@@ -2657,7 +2836,8 @@ export class ManagedSessionDescendantStore {
2657
2836
  if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(length) || length < 0)
2658
2837
  throw new RangeError("Invalid managed range read");
2659
2838
  if (start > Number.MAX_SAFE_INTEGER - length) throw new RangeError("Managed range read start overflows");
2660
- if (length > 64 * 1024 * 1024) throw new RangeError("Managed range read exceeds the bounded maximum");
2839
+ if (length > MANAGED_SESSION_READ_RANGE_MAX_BYTES)
2840
+ throw new RangeError("Managed range read exceeds the bounded maximum");
2661
2841
  this.#assertPathBackedReadRelative(relativePath);
2662
2842
  this.#assertBound();
2663
2843
  const rootBefore = fs.lstatSync(this.#baseDir, { bigint: true });
@@ -2681,6 +2861,7 @@ export class ManagedSessionDescendantStore {
2681
2861
  resolved,
2682
2862
  fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
2683
2863
  );
2864
+ let snapshot: SessionStorageRangeSnapshot;
2684
2865
  try {
2685
2866
  const before = fs.fstatSync(fd, { bigint: true });
2686
2867
  if (!before.isFile() || before.nlink > 1n) throw new Error("source_changed");
@@ -2727,7 +2908,10 @@ export class ManagedSessionDescendantStore {
2727
2908
  named.isSymbolicLink() ||
2728
2909
  named.dev !== before.dev ||
2729
2910
  named.ino !== before.ino ||
2730
- named.nlink !== before.nlink
2911
+ named.nlink !== before.nlink ||
2912
+ named.size !== before.size ||
2913
+ named.mtimeNs !== before.mtimeNs ||
2914
+ named.ctimeNs !== before.ctimeNs
2731
2915
  )
2732
2916
  throw new Error("source_changed");
2733
2917
  const rootAfter = fs.lstatSync(this.#baseDir, { bigint: true });
@@ -2739,7 +2923,7 @@ export class ManagedSessionDescendantStore {
2739
2923
  )
2740
2924
  throw new Error("Managed descendant root binding changed");
2741
2925
  this.#assertBound();
2742
- return {
2926
+ snapshot = {
2743
2927
  bytes,
2744
2928
  stat: {
2745
2929
  dev: canonicalFileId(after.dev),
@@ -2753,10 +2937,133 @@ export class ManagedSessionDescendantStore {
2753
2937
  isFile: true,
2754
2938
  },
2755
2939
  };
2756
- } finally {
2757
- fs.closeSync(fd);
2940
+ } catch (error) {
2941
+ try {
2942
+ fs.closeSync(fd);
2943
+ } catch {}
2944
+ throw error;
2945
+ }
2946
+ fs.closeSync(fd);
2947
+ return snapshot;
2948
+ }
2949
+
2950
+ /** Open an identity-bound stream lease; Darwin uses exact bounded range reads per chunk. */
2951
+ openReadLease(
2952
+ relativePath: string,
2953
+ expectedDescriptor: Pick<SessionStorageStat, "dev" | "ino" | "nlink" | "size" | "mtimeNs" | "ctimeNs">,
2954
+ ): ManagedSessionReadLease {
2955
+ if (!Number.isSafeInteger(expectedDescriptor.size) || expectedDescriptor.size < 0)
2956
+ throw new RangeError("Invalid managed read identity size");
2957
+ this.#assertPathBackedReadRelative(relativePath);
2958
+ this.#assertBound();
2959
+ if (!this.#authority) {
2960
+ this.readRangeExpectedSync(relativePath, 0, 0, expectedDescriptor);
2961
+ let closed = false;
2962
+ return {
2963
+ readRange: (start, length) => {
2964
+ if (closed) throw new Error("closed");
2965
+ if (start + length > expectedDescriptor.size) throw new Error("range_not_present");
2966
+ return this.readRangeExpectedSync(relativePath, start, length, expectedDescriptor).bytes;
2967
+ },
2968
+ close: () => {
2969
+ if (closed) return;
2970
+ closed = true;
2971
+ this.readRangeExpectedSync(relativePath, 0, 0, expectedDescriptor);
2972
+ },
2973
+ };
2974
+ }
2975
+
2976
+ const relative = this.#relative(this.#resolve(relativePath));
2977
+ const authority = this.#authority;
2978
+ const matchesExpected = (identity: RecoveryFsIdentity | undefined): boolean => {
2979
+ if (!identity) return false;
2980
+ const actual = managedFileIdentityFromNative(identity);
2981
+ return (
2982
+ actual.dev === expectedDescriptor.dev &&
2983
+ actual.ino === expectedDescriptor.ino &&
2984
+ (expectedDescriptor.nlink === undefined || actual.nlink === expectedDescriptor.nlink) &&
2985
+ actual.size === expectedDescriptor.size &&
2986
+ actual.mtimeNs === expectedDescriptor.mtimeNs &&
2987
+ actual.ctimeNs === expectedDescriptor.ctimeNs
2988
+ );
2989
+ };
2990
+ const assertCurrentBinding = (): void => {
2991
+ this.#assertBound();
2992
+ const named = authority.stat(relative);
2993
+ if (!named.ok || !matchesExpected(named.identity)) throw new Error("source_changed");
2994
+ this.#assertBound();
2995
+ };
2996
+
2997
+ let file: RecoveryFsFile | undefined;
2998
+ try {
2999
+ file = authority.openFile(relative);
3000
+ const opened = file.identity();
3001
+ if (!opened.ok || !matchesExpected(opened.identity)) throw new Error("source_changed");
3002
+ assertCurrentBinding();
3003
+ } catch (error) {
3004
+ try {
3005
+ file?.close();
3006
+ } catch {
3007
+ // Preserve the identity/open failure; closing is still attempted.
3008
+ }
3009
+ throw error;
2758
3010
  }
3011
+
3012
+ let closed = false;
3013
+ return {
3014
+ readRange: (start, length) => {
3015
+ if (closed) throw new Error("closed");
3016
+ if (
3017
+ !Number.isSafeInteger(start) ||
3018
+ start < 0 ||
3019
+ !Number.isSafeInteger(length) ||
3020
+ length < 0 ||
3021
+ start > Number.MAX_SAFE_INTEGER - length
3022
+ )
3023
+ throw new RangeError("Invalid managed range read");
3024
+ if (length > 1024 * 1024) throw new RangeError("Managed read chunk exceeds the bounded maximum");
3025
+ if (start + length > expectedDescriptor.size) throw new Error("range_not_present");
3026
+ assertCurrentBinding();
3027
+ const before = file!.identity();
3028
+ if (!before.ok || !matchesExpected(before.identity)) throw new Error("source_changed");
3029
+ const chunk = file!.readChunk(start, length);
3030
+ if (!chunk.ok || !chunk.data || !chunk.identity) throw new Error(chunk.code ?? "managed_read_failed");
3031
+ if (!matchesExpected(chunk.identity)) throw new Error("source_changed");
3032
+ if (chunk.data.byteLength !== length) throw new Error("range_not_present");
3033
+ const after = file!.identity();
3034
+ if (!after.ok || !matchesExpected(after.identity)) throw new Error("source_changed");
3035
+ assertCurrentBinding();
3036
+ return chunk.data;
3037
+ },
3038
+ close: () => {
3039
+ if (closed) return;
3040
+ closed = true;
3041
+ let failure: unknown;
3042
+ try {
3043
+ assertCurrentBinding();
3044
+ } catch (error) {
3045
+ failure = error;
3046
+ }
3047
+ let result: RecoveryFsResult | undefined;
3048
+ try {
3049
+ result = file!.close();
3050
+ } catch (error) {
3051
+ failure ??= error;
3052
+ }
3053
+ if (failure === undefined && (!result?.ok || !matchesExpected(result.identity)))
3054
+ failure = new Error(result?.code ?? "source_changed");
3055
+ if (failure === undefined) {
3056
+ try {
3057
+ assertCurrentBinding();
3058
+ } catch (error) {
3059
+ failure = error;
3060
+ }
3061
+ }
3062
+ if (failure !== undefined) throw failure;
3063
+ },
3064
+ };
2759
3065
  }
3066
+
2760
3067
  /** Read an exact managed file without exposing its pathname as authority. */
2761
3068
  readExpected(relativePath: string): ManagedFileSnapshot | null {
2762
3069
  this.#assertBound();
@@ -2793,6 +3100,85 @@ export class ManagedSessionDescendantStore {
2793
3100
  };
2794
3101
  }
2795
3102
 
3103
+ /** Read one complete managed file through retained authority after descriptor-size admission. */
3104
+ readExpectedBounded(
3105
+ relativePath: string,
3106
+ maxBytes: number,
3107
+ admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
3108
+ ): ManagedFileSnapshot | null {
3109
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
3110
+ this.#assertBound();
3111
+ const resolved = this.#resolve(relativePath);
3112
+ if (!this.#authority) {
3113
+ try {
3114
+ this.#assertPathBackedDirectoryChain(resolved);
3115
+ const captured = captureManagedFileNoFollowBounded(resolved, maxBytes, admitSize);
3116
+ this.#assertBound();
3117
+ return captured;
3118
+ } catch (error) {
3119
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
3120
+ throw error;
3121
+ }
3122
+ }
3123
+ let descriptor: SessionStorageRangeSnapshot;
3124
+ try {
3125
+ descriptor = this.readRangeExpectedSync(this.#relative(resolved), 0, 0);
3126
+ } catch (error) {
3127
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
3128
+ throw error;
3129
+ }
3130
+ const { stat } = descriptor;
3131
+ if (stat.nlink !== 1n) throw new Error("source_changed");
3132
+ if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw new Error("source_changed");
3133
+ if (stat.size > maxBytes) throw new Error("artifact_capacity_exceeded");
3134
+ const lease = this.openReadLease(this.#relative(resolved), stat);
3135
+ let failed = false;
3136
+ let failure: unknown;
3137
+ let snapshot: ManagedFileSnapshot | undefined;
3138
+ try {
3139
+ admitSize?.(stat.size, {
3140
+ dev: stat.dev,
3141
+ ino: stat.ino,
3142
+ nlink: stat.nlink,
3143
+ size: stat.size,
3144
+ mtimeNs: stat.mtimeNs,
3145
+ ctimeNs: stat.ctimeNs,
3146
+ });
3147
+ const bytes = Buffer.alloc(stat.size);
3148
+ for (let offset = 0; offset < bytes.byteLength; ) {
3149
+ const length = Math.min(1024 * 1024, bytes.byteLength - offset);
3150
+ bytes.set(lease.readRange(offset, length), offset);
3151
+ offset += length;
3152
+ }
3153
+ snapshot = {
3154
+ bytes,
3155
+ identity: {
3156
+ dev: stat.dev,
3157
+ ino: stat.ino,
3158
+ nlink: stat.nlink,
3159
+ size: stat.size,
3160
+ mtimeNs: stat.mtimeNs,
3161
+ ctimeNs: stat.ctimeNs,
3162
+ sha256: createHash("sha256").update(bytes).digest("hex"),
3163
+ },
3164
+ };
3165
+ } catch (error) {
3166
+ failed = true;
3167
+ failure = error;
3168
+ }
3169
+ try {
3170
+ lease.close();
3171
+ } catch (error) {
3172
+ if (!failed) {
3173
+ failed = true;
3174
+ failure = error;
3175
+ }
3176
+ }
3177
+ if (failed) throw failure;
3178
+ if (!snapshot) throw new Error("Managed descendant snapshot is unavailable");
3179
+ return snapshot;
3180
+ }
3181
+
2796
3182
  /** Remove an exact captured file without reopening its pathname as authority. */
2797
3183
  removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
2798
3184
  this.#beforeMutation();
@@ -3028,6 +3414,7 @@ export class ManagedSessionDescendantStore {
3028
3414
  sourceStoreRelativePath: string;
3029
3415
  },
3030
3416
  ): ManagedFileSnapshot {
3417
+ this.#assertWritable();
3031
3418
  this.#assertBound();
3032
3419
  const sourceResolved = this.#resolve(sourceRelativePath);
3033
3420
  const destinationResolved = this.#resolve(destinationRelativePath);
@@ -3297,7 +3684,7 @@ function sameReplacementIdentity(
3297
3684
  );
3298
3685
  }
3299
3686
 
3300
- function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
3687
+ export function parseManagedLockRecord(bytes: Uint8Array): LockRecord | undefined {
3301
3688
  try {
3302
3689
  const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
3303
3690
  if (!value || typeof value !== "object") return undefined;
@@ -3320,7 +3707,7 @@ function parseLock(pathname: string): LockRecord | undefined {
3320
3707
  try {
3321
3708
  const stat = fs.lstatSync(pathname);
3322
3709
  if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
3323
- return parseLockBytes(fs.readFileSync(pathname));
3710
+ return parseManagedLockRecord(fs.readFileSync(pathname));
3324
3711
  } catch {
3325
3712
  return undefined;
3326
3713
  }
@@ -3329,7 +3716,7 @@ function parseLock(pathname: string): LockRecord | undefined {
3329
3716
  function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined {
3330
3717
  try {
3331
3718
  const snapshot = captureManagedFileNoFollow(pathname);
3332
- const record = parseLockBytes(snapshot.bytes);
3719
+ const record = parseManagedLockRecord(snapshot.bytes);
3333
3720
  return record ? { record, snapshot } : undefined;
3334
3721
  } catch {
3335
3722
  return undefined;
@@ -3437,17 +3824,41 @@ export function captureManagedFilePrefixNoFollow(pathname: string, maxBytes: num
3437
3824
  return captureManagedFileNoFollowLimit(pathname, maxBytes);
3438
3825
  }
3439
3826
 
3827
+ /** Captures a complete no-follow file only after descriptor-size admission and before allocating its bytes. */
3828
+ export function captureManagedFileNoFollowBounded(
3829
+ pathname: string,
3830
+ maxBytes: number,
3831
+ admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
3832
+ ): ManagedFileSnapshot {
3833
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
3834
+ return captureManagedFileNoFollowLimit(pathname, maxBytes, true, admitSize);
3835
+ }
3836
+
3440
3837
  /** Captures header/hash/copy input from one no-follow descriptor and rechecks the pathname before use. */
3441
3838
  export function captureManagedFileNoFollow(pathname: string): ManagedFileSnapshot {
3442
3839
  return captureManagedFileNoFollowLimit(pathname);
3443
3840
  }
3444
3841
 
3445
- function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): ManagedFileSnapshot {
3842
+ function captureManagedFileNoFollowLimit(
3843
+ pathname: string,
3844
+ maxBytes?: number,
3845
+ rejectOversized = false,
3846
+ admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
3847
+ ): ManagedFileSnapshot {
3446
3848
  const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0));
3849
+ let failed = false;
3850
+ let failure: unknown;
3851
+ let snapshot: ManagedFileSnapshot | undefined;
3447
3852
  try {
3448
3853
  const before = fs.fstatSync(fd, { bigint: true });
3449
- if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
3450
- const captureSize = maxBytes === undefined ? Number(before.size) : Math.min(Number(before.size), maxBytes);
3854
+ if (!before.isFile() || (rejectOversized ? before.nlink !== 1n : before.nlink > 1n))
3855
+ throw new Error("source_changed");
3856
+ const fileSize = Number(before.size);
3857
+ if (!Number.isSafeInteger(fileSize) || fileSize < 0) throw new Error("source_changed");
3858
+ if (rejectOversized && maxBytes !== undefined && fileSize > maxBytes)
3859
+ throw new Error("artifact_capacity_exceeded");
3860
+ admitSize?.(fileSize, identity(before));
3861
+ const captureSize = maxBytes === undefined ? fileSize : Math.min(fileSize, maxBytes);
3451
3862
  const bytes = Buffer.alloc(captureSize);
3452
3863
  let offset = 0;
3453
3864
  while (offset < bytes.byteLength) {
@@ -3460,10 +3871,22 @@ function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): M
3460
3871
  const named = fs.lstatSync(pathname, { bigint: true });
3461
3872
  if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
3462
3873
  throw new Error("source_changed");
3463
- return { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
3464
- } finally {
3874
+ snapshot = { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
3875
+ } catch (error) {
3876
+ failed = true;
3877
+ failure = error;
3878
+ }
3879
+ try {
3465
3880
  fs.closeSync(fd);
3881
+ } catch (error) {
3882
+ if (!failed) {
3883
+ failed = true;
3884
+ failure = error;
3885
+ }
3466
3886
  }
3887
+ if (failed) throw failure;
3888
+ if (!snapshot) throw new Error("Managed file capture is unavailable");
3889
+ return snapshot;
3467
3890
  }
3468
3891
 
3469
3892
  /** Streams a managed file once while retaining only a bounded header prefix and the full descriptor-bound digest. */