@gajae-code/coding-agent 0.18.7 → 0.18.8

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 (279) hide show
  1. package/CHANGELOG.md +279 -1
  2. package/dist/types/async/job-manager.d.ts +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/capability/skill.d.ts +2 -0
  5. package/dist/types/capability/types.d.ts +2 -0
  6. package/dist/types/cli/public-command-errors.d.ts +3 -1
  7. package/dist/types/cli/setup-cli.d.ts +1 -1
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  10. package/dist/types/config/file-lock.d.ts +1 -0
  11. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  12. package/dist/types/config/model-profile-activation.d.ts +11 -3
  13. package/dist/types/config/model-registry.d.ts +4 -9
  14. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  15. package/dist/types/cursor-delete-path.d.ts +1 -0
  16. package/dist/types/cursor-mcp-name.d.ts +8 -0
  17. package/dist/types/customization/inventory.d.ts +2 -0
  18. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  21. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  22. package/dist/types/discovery/helpers.d.ts +6 -1
  23. package/dist/types/export/html/css-color.d.ts +2 -0
  24. package/dist/types/export/html/template.generated.d.ts +1 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  26. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  27. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  28. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  29. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  30. package/dist/types/extensibility/skill-management.d.ts +3 -1
  31. package/dist/types/extensibility/skills.d.ts +2 -0
  32. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  33. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  34. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  36. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  37. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  38. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  39. package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
  40. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  41. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  42. package/dist/types/lsp/workspace-path.d.ts +1 -0
  43. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  44. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  45. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  46. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  47. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  48. package/dist/types/modes/interactive-mode.d.ts +1 -0
  49. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  50. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  51. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  52. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  53. package/dist/types/progress/progress-contract.d.ts +154 -0
  54. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  55. package/dist/types/progress/project-progress.d.ts +141 -0
  56. package/dist/types/progress/render-progress.d.ts +28 -0
  57. package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
  58. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  59. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  60. package/dist/types/runtime-mcp/types.d.ts +6 -0
  61. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  62. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  63. package/dist/types/sdk/broker/broker.d.ts +18 -1
  64. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  65. package/dist/types/sdk/bus/index.d.ts +12 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  67. package/dist/types/sdk/client/client.d.ts +2 -0
  68. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  69. package/dist/types/sdk/index.d.ts +1 -0
  70. package/dist/types/sdk/router/session-router.d.ts +2 -0
  71. package/dist/types/sdk/session-list.d.ts +1 -1
  72. package/dist/types/sdk/session.d.ts +8 -0
  73. package/dist/types/session/agent-session.d.ts +38 -9
  74. package/dist/types/session/artifacts.d.ts +2 -2
  75. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  76. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  77. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  78. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  79. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  80. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  81. package/dist/types/session/session-manager.d.ts +31 -5
  82. package/dist/types/session/session-retirement.d.ts +31 -7
  83. package/dist/types/session/session-storage.d.ts +62 -2
  84. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  85. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  86. package/dist/types/setup/credential-import.d.ts +6 -4
  87. package/dist/types/skill-state/active-state.d.ts +38 -0
  88. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  89. package/dist/types/task/executor.d.ts +3 -0
  90. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  91. package/dist/types/tools/index.d.ts +3 -0
  92. package/dist/types/tools/python.d.ts +9 -1
  93. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  94. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  95. package/dist/types/tools/todo-write.d.ts +6 -0
  96. package/dist/types/utils/tools-checksum.d.ts +7 -0
  97. package/package.json +7 -7
  98. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  99. package/scripts/verify-managed-empty-stop.ts +28 -0
  100. package/src/async/job-manager.ts +228 -122
  101. package/src/capability/index.ts +17 -0
  102. package/src/capability/mcp.ts +2 -0
  103. package/src/capability/skill.ts +2 -0
  104. package/src/capability/types.ts +2 -0
  105. package/src/cli/auth-broker-cli.ts +1 -1
  106. package/src/cli/customize-doctor.ts +15 -2
  107. package/src/cli/public-command-errors.ts +21 -1
  108. package/src/cli/setup-cli.ts +4 -4
  109. package/src/cli/skills-cli.ts +5 -0
  110. package/src/commands/deep-interview.ts +47 -8
  111. package/src/commands/sdk.ts +28 -8
  112. package/src/config/autorouting-tier-map.ts +117 -7
  113. package/src/config/file-lock.ts +307 -68
  114. package/src/config/mcp-schema.json +4 -0
  115. package/src/config/model-bindings-applier.ts +20 -0
  116. package/src/config/model-profile-activation.ts +240 -46
  117. package/src/config/model-registry.ts +47 -153
  118. package/src/config/skill-settings-defaults.ts +7 -0
  119. package/src/coordinator-mcp/policy.ts +3 -2
  120. package/src/cursor-delete-path.ts +10 -0
  121. package/src/cursor-mcp-name.ts +15 -0
  122. package/src/cursor.ts +13 -2
  123. package/src/customization/inventory.ts +3 -0
  124. package/src/debug/report-bundle-parent.ts +22 -0
  125. package/src/debug/report-bundle.ts +3 -0
  126. package/src/debug/system-info.ts +13 -1
  127. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  128. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  129. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  130. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  131. package/src/defaults/gjc-defaults.test.ts +18 -1
  132. package/src/defaults/gjc-defaults.ts +3 -0
  133. package/src/defaults/gjc-skills.generated.ts +58 -36
  134. package/src/discovery/agents.ts +6 -1
  135. package/src/discovery/builtin.ts +10 -0
  136. package/src/discovery/helpers.ts +159 -20
  137. package/src/discovery/mcp-json.ts +6 -0
  138. package/src/discovery/ssh.ts +1 -1
  139. package/src/discovery/substitute-plugin-root.ts +3 -1
  140. package/src/edit/file-read-cache.ts +1 -1
  141. package/src/eval/py/executor.ts +380 -120
  142. package/src/export/html/css-color.ts +9 -0
  143. package/src/export/html/index.ts +5 -4
  144. package/src/export/html/template.generated.ts +1 -1
  145. package/src/extensibility/extensions/runner.ts +4 -0
  146. package/src/extensibility/extensions/types.ts +7 -0
  147. package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
  148. package/src/extensibility/plugins/manager.ts +3 -2
  149. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  150. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  151. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  152. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  153. package/src/extensibility/skill-management.ts +6 -2
  154. package/src/extensibility/skills.ts +6 -1
  155. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  156. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  157. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  158. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  159. package/src/gjc-runtime/gc-runtime.ts +206 -9
  160. package/src/gjc-runtime/launch-worktree.ts +14 -4
  161. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  162. package/src/gjc-runtime/state-argv.ts +2 -0
  163. package/src/gjc-runtime/state-migrations.ts +91 -29
  164. package/src/gjc-runtime/state-runtime.ts +3716 -135
  165. package/src/gjc-runtime/state-writer.ts +83 -8
  166. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  167. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  168. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  169. package/src/harness-control-plane/operate.ts +12 -4
  170. package/src/harness-control-plane/owner.ts +5 -1
  171. package/src/hashline/parser.ts +48 -9
  172. package/src/hashline/recovery.ts +13 -4
  173. package/src/internal-urls/docs-index.generated.ts +18 -17
  174. package/src/internal-urls/skill-path.ts +19 -0
  175. package/src/internal-urls/skill-protocol.ts +2 -0
  176. package/src/lsp/edits.ts +6 -0
  177. package/src/lsp/workspace-path.ts +51 -0
  178. package/src/main.ts +195 -31
  179. package/src/modes/acp/acp-agent.ts +88 -4
  180. package/src/modes/components/custom-editor.ts +14 -0
  181. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  182. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  183. package/src/modes/components/extensions/state-manager.ts +15 -5
  184. package/src/modes/components/model-selector.ts +1 -4
  185. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  186. package/src/modes/components/status-line/segments.ts +4 -0
  187. package/src/modes/controllers/event-controller.ts +11 -4
  188. package/src/modes/controllers/input-controller.ts +74 -29
  189. package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
  190. package/src/modes/controllers/selector-controller.ts +28 -15
  191. package/src/modes/interactive-mode.ts +4 -0
  192. package/src/modes/runtime-init.ts +4 -0
  193. package/src/modes/utils/abort-message.ts +3 -1
  194. package/src/modes/utils/interrupt-ownership.ts +12 -0
  195. package/src/modes/utils/keybinding-matchers.ts +4 -0
  196. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  197. package/src/progress/collect-project-progress.ts +153 -0
  198. package/src/progress/progress-contract.ts +172 -0
  199. package/src/progress/progress-snapshot.ts +140 -0
  200. package/src/progress/project-progress.ts +446 -0
  201. package/src/progress/render-progress.ts +209 -0
  202. package/src/prompts/system/master-mode.md +1 -0
  203. package/src/prompts/tools/skill-discovery.md +1 -1
  204. package/src/runtime-mcp/config-writer.ts +10 -6
  205. package/src/runtime-mcp/config.ts +1 -0
  206. package/src/runtime-mcp/oauth-flow.ts +5 -0
  207. package/src/runtime-mcp/oauth-public-url.ts +8 -0
  208. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  209. package/src/runtime-mcp/smithery-registry.ts +22 -3
  210. package/src/runtime-mcp/transports/http.ts +2 -2
  211. package/src/runtime-mcp/types.ts +6 -0
  212. package/src/sdk/acp/adapter.ts +93 -8
  213. package/src/sdk/broker/broker-exit.ts +52 -23
  214. package/src/sdk/broker/broker.ts +75 -6
  215. package/src/sdk/broker/ensure.ts +2 -0
  216. package/src/sdk/broker/lifecycle.ts +711 -66
  217. package/src/sdk/broker/session-index.ts +191 -81
  218. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  219. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  220. package/src/sdk/bus/index.ts +238 -43
  221. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  222. package/src/sdk/cli/session-cli.ts +17 -3
  223. package/src/sdk/client/client.ts +28 -8
  224. package/src/sdk/host/query/handlers.ts +9 -1
  225. package/src/sdk/host/session-runtime.test.ts +2647 -294
  226. package/src/sdk/host/session-runtime.ts +426 -109
  227. package/src/sdk/host/surface-policy.ts +1 -0
  228. package/src/sdk/index.ts +8 -0
  229. package/src/sdk/lifecycle-session.ts +3 -1
  230. package/src/sdk/prompt-deadline-manager.ts +15 -0
  231. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  232. package/src/sdk/protocol/operation-registry.ts +9 -2
  233. package/src/sdk/router/session-router.ts +75 -12
  234. package/src/sdk/session-list.ts +1 -0
  235. package/src/sdk/session.ts +67 -38
  236. package/src/session/agent-session.ts +623 -224
  237. package/src/session/artifacts.ts +174 -70
  238. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  239. package/src/session/internal/managed-session-scope.ts +3457 -462
  240. package/src/session/internal/managed-session-storage.ts +376 -61
  241. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  242. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  243. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  244. package/src/session/session-manager.ts +939 -133
  245. package/src/session/session-retirement.ts +573 -14
  246. package/src/session/session-storage.ts +417 -14
  247. package/src/session/task-artifact-owner-codec.ts +71 -8
  248. package/src/session/task-artifact-owner-retirement.ts +21 -19
  249. package/src/setup/credential-auto-import.ts +12 -3
  250. package/src/setup/credential-import.ts +176 -5
  251. package/src/setup/paseo/announce.ts +6 -1
  252. package/src/skill-state/active-state.ts +132 -2
  253. package/src/slash-commands/builtin-registry.ts +39 -9
  254. package/src/slash-commands/helpers/progress-report.ts +23 -0
  255. package/src/stt/setup.ts +3 -5
  256. package/src/task/commands.ts +3 -1
  257. package/src/task/executor.ts +4 -0
  258. package/src/task/index.ts +6 -0
  259. package/src/tools/ask.ts +205 -6
  260. package/src/tools/ast-edit.ts +4 -0
  261. package/src/tools/bash-allowed-prefixes.ts +15 -0
  262. package/src/tools/bash-skill-urls.ts +56 -3
  263. package/src/tools/bash.ts +48 -79
  264. package/src/tools/descriptors.ts +5 -1
  265. package/src/tools/eval.ts +56 -36
  266. package/src/tools/image-gen.ts +1 -1
  267. package/src/tools/index.ts +3 -0
  268. package/src/tools/python.ts +187 -66
  269. package/src/tools/recipe/index.ts +9 -0
  270. package/src/tools/recipe/recipe-guard.ts +19 -0
  271. package/src/tools/restricted-cwd.ts +10 -0
  272. package/src/tools/skill-discovery.ts +5 -0
  273. package/src/tools/skill.ts +28 -1
  274. package/src/tools/todo-write.ts +10 -1
  275. package/src/tools/tool-catalog.generated.ts +1 -1
  276. package/src/tools/write.ts +1 -0
  277. package/src/utils/tools-checksum.ts +34 -0
  278. package/src/utils/tools-manager.ts +24 -6
  279. 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;
@@ -1429,7 +1465,12 @@ function secureExistingManagedDirectory(pathname: string, kind: "directory" | "f
1429
1465
  );
1430
1466
  assertManagedPathIdentity(pathname, kind, named);
1431
1467
  if (verified.ok) return;
1432
- if (verified.code !== "acl_verify_failed") throw securityError(pathname, verified);
1468
+ // owner_mismatch: directory owner is not the current user. Repair attempts ownership transfer.
1469
+ // If the user lacks SeTakeOwnershipPrivilege (non-elevated), the repair will fail with
1470
+ // io_error, which is caught as a security error with clear context below.
1471
+ if (verified.code !== "acl_verify_failed" && verified.code !== "owner_mismatch") {
1472
+ throw securityError(pathname, verified);
1473
+ }
1433
1474
  const repaired = validateNativeSecurityResult(
1434
1475
  nativeSessionStorage().repairOwnerOnlyPathSecurityExpected(
1435
1476
  pathname,
@@ -1440,7 +1481,16 @@ function secureExistingManagedDirectory(pathname: string, kind: "directory" | "f
1440
1481
  "verify",
1441
1482
  kind,
1442
1483
  );
1443
- if (!repaired.ok) throw securityError(pathname, repaired);
1484
+ if (!repaired.ok) {
1485
+ // If initial verification was owner_mismatch and repair failed (typically io_error
1486
+ // when the user lacks SeTakeOwnershipPrivilege), provide clear context.
1487
+ if (verified.code === "owner_mismatch" && repaired.code === "io_error") {
1488
+ throw new Error(
1489
+ `${kind} owner mismatch: unable to take ownership (need administrator privileges or file ownership): ${pathname}`,
1490
+ );
1491
+ }
1492
+ throw securityError(pathname, repaired);
1493
+ }
1444
1494
  assertManagedPathIdentity(pathname, kind, named);
1445
1495
  }
1446
1496
 
@@ -1452,11 +1502,18 @@ function secureManagedDirectory(pathname: string, created: boolean, policy: Mana
1452
1502
  secure(pathname, "directory");
1453
1503
  }
1454
1504
 
1505
+ /** Exact, identity-bound read access retained for the lifetime of one managed stream. */
1506
+ export interface ManagedSessionReadLease {
1507
+ readRange(start: number, length: number): Uint8Array;
1508
+ close(): void;
1509
+ }
1510
+
1455
1511
  /** Internal retained-root capability for one managed descendant subtree. */
1456
1512
  export class ManagedSessionDescendantStore {
1457
1513
  readonly #root: ManagedDirectoryRoot;
1458
1514
  readonly #baseDir: string;
1459
1515
  readonly #policy: ManagedSessionSecurityPolicy;
1516
+ readonly #access: "read-only" | "read-write";
1460
1517
  readonly #authority: RecoveryFsRoot | undefined;
1461
1518
  #ownsAuthority = false;
1462
1519
  #closed = false;
@@ -1475,12 +1532,18 @@ export class ManagedSessionDescendantStore {
1475
1532
  policy?: ManagedSessionSecurityPolicy,
1476
1533
  profileAgentDir?: string,
1477
1534
  expectedSubtreeRoot?: ManagedDirectoryRoot,
1535
+ access: "read-only" | "read-write" = "read-write",
1478
1536
  ) {
1537
+ if (access !== "read-only" && access !== "read-write") throw new Error("managed_store_access_invalid");
1538
+ if (access === "read-only" && retained) throw new Error("managed_read_store_cannot_borrow_authority");
1539
+ if (access === "read-only" && !expectedSubtreeRoot)
1540
+ throw new Error("managed_read_store_requires_existing_identity");
1479
1541
  managedRelativePath(root, baseDir);
1480
1542
 
1481
1543
  this.#root = root;
1482
1544
  this.#baseDir = path.resolve(baseDir);
1483
1545
  this.#policy = policy ?? "default";
1546
+ this.#access = access;
1484
1547
  this.#profileAgentDir = profileAgentDir ?? root.canonicalPath;
1485
1548
  this.#authorityBaseDir = retained?.authorityBaseDir ?? this.#baseDir;
1486
1549
  if (retained) {
@@ -1558,7 +1621,7 @@ export class ManagedSessionDescendantStore {
1558
1621
  dev: canonicalFileId(subtreeStat.dev),
1559
1622
  ino: canonicalFileId(subtreeStat.ino),
1560
1623
  });
1561
- if (process.platform === "linux") {
1624
+ if (process.platform === "linux" && access === "read-write") {
1562
1625
  const before = fs.lstatSync(this.#baseDir, { bigint: true });
1563
1626
  const authority = nativeSessionStorage().openRecoveryFsRoot(this.#baseDir);
1564
1627
  const retained = authority.identity();
@@ -1606,19 +1669,27 @@ export class ManagedSessionDescendantStore {
1606
1669
  child.dev.toString(),
1607
1670
  child.ino.toString(),
1608
1671
  );
1609
- return new ManagedSessionDescendantStore(
1610
- this.#root,
1611
- resolved,
1612
- {
1613
- authority: retainedChild,
1614
- authorityBaseDir: resolved,
1615
- },
1616
- this.#policy,
1617
- this.#profileAgentDir,
1618
- );
1672
+ try {
1673
+ const derived = new ManagedSessionDescendantStore(
1674
+ this.#root,
1675
+ resolved,
1676
+ {
1677
+ authority: retainedChild,
1678
+ authorityBaseDir: resolved,
1679
+ },
1680
+ this.#policy,
1681
+ this.#profileAgentDir,
1682
+ );
1683
+ derived.#ownsAuthority = true;
1684
+ return derived;
1685
+ } catch (error) {
1686
+ retainedChild.close();
1687
+ throw error;
1688
+ }
1619
1689
  }
1620
1690
 
1621
1691
  retainAuthority(): RecoveryFsRoot | undefined {
1692
+ this.#assertWritable();
1622
1693
  if (!this.#authority) return undefined;
1623
1694
  return this.#authority.retainManagedDirectory(
1624
1695
  "",
@@ -1673,7 +1744,7 @@ export class ManagedSessionDescendantStore {
1673
1744
  captureDirectoryIdentity(relativePath: string): { dev: string; ino: string } {
1674
1745
  this.#assertBound();
1675
1746
  const resolved = this.#resolve(relativePath);
1676
- if (!this.#authority) this.#assertPathBackedDirectoryChain(resolved);
1747
+ if (this.#access === "read-only" || !this.#authority) this.#assertPathBackedDirectoryChain(resolved);
1677
1748
  const named = fs.lstatSync(resolved, { bigint: true });
1678
1749
  if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("managed_directory_identity_unavailable");
1679
1750
  const dev = canonicalFileId(named.dev);
@@ -1714,9 +1785,10 @@ export class ManagedSessionDescendantStore {
1714
1785
 
1715
1786
  #assertBound(): void {
1716
1787
  if (!this.#authority) {
1717
- const named = fs.statSync(this.#baseDir, { bigint: true });
1788
+ const named = fs.lstatSync(this.#baseDir, { bigint: true });
1718
1789
  if (
1719
1790
  !named.isDirectory() ||
1791
+ named.isSymbolicLink() ||
1720
1792
  canonicalFileId(named.dev) !== this.#subtreeRoot.dev ||
1721
1793
  canonicalFileId(named.ino) !== this.#subtreeRoot.ino
1722
1794
  )
@@ -2019,7 +2091,12 @@ export class ManagedSessionDescendantStore {
2019
2091
  }
2020
2092
  }
2021
2093
 
2094
+ #assertWritable(): void {
2095
+ if (this.#access === "read-only") throw new Error("managed_store_read_only");
2096
+ }
2097
+
2022
2098
  #beforeMutation(): void {
2099
+ this.#assertWritable();
2023
2100
  this.#assertBound();
2024
2101
  // Reaping is scheduled BEFORE reconciliation, not after. The receipt scan
2025
2102
  // throws `managed_replace_cleanup_receipt_limit_exceeded` once the bound
@@ -2657,7 +2734,8 @@ export class ManagedSessionDescendantStore {
2657
2734
  if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(length) || length < 0)
2658
2735
  throw new RangeError("Invalid managed range read");
2659
2736
  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");
2737
+ if (length > MANAGED_SESSION_READ_RANGE_MAX_BYTES)
2738
+ throw new RangeError("Managed range read exceeds the bounded maximum");
2661
2739
  this.#assertPathBackedReadRelative(relativePath);
2662
2740
  this.#assertBound();
2663
2741
  const rootBefore = fs.lstatSync(this.#baseDir, { bigint: true });
@@ -2727,7 +2805,10 @@ export class ManagedSessionDescendantStore {
2727
2805
  named.isSymbolicLink() ||
2728
2806
  named.dev !== before.dev ||
2729
2807
  named.ino !== before.ino ||
2730
- named.nlink !== before.nlink
2808
+ named.nlink !== before.nlink ||
2809
+ named.size !== before.size ||
2810
+ named.mtimeNs !== before.mtimeNs ||
2811
+ named.ctimeNs !== before.ctimeNs
2731
2812
  )
2732
2813
  throw new Error("source_changed");
2733
2814
  const rootAfter = fs.lstatSync(this.#baseDir, { bigint: true });
@@ -2757,6 +2838,124 @@ export class ManagedSessionDescendantStore {
2757
2838
  fs.closeSync(fd);
2758
2839
  }
2759
2840
  }
2841
+
2842
+ /** Open an identity-bound stream lease; Darwin uses exact bounded range reads per chunk. */
2843
+ openReadLease(
2844
+ relativePath: string,
2845
+ expectedDescriptor: Pick<SessionStorageStat, "dev" | "ino" | "nlink" | "size" | "mtimeNs" | "ctimeNs">,
2846
+ ): ManagedSessionReadLease {
2847
+ if (!Number.isSafeInteger(expectedDescriptor.size) || expectedDescriptor.size < 0)
2848
+ throw new RangeError("Invalid managed read identity size");
2849
+ this.#assertPathBackedReadRelative(relativePath);
2850
+ this.#assertBound();
2851
+ if (!this.#authority) {
2852
+ this.readRangeExpectedSync(relativePath, 0, 0, expectedDescriptor);
2853
+ let closed = false;
2854
+ return {
2855
+ readRange: (start, length) => {
2856
+ if (closed) throw new Error("closed");
2857
+ if (start + length > expectedDescriptor.size) throw new Error("range_not_present");
2858
+ return this.readRangeExpectedSync(relativePath, start, length, expectedDescriptor).bytes;
2859
+ },
2860
+ close: () => {
2861
+ if (closed) return;
2862
+ closed = true;
2863
+ this.readRangeExpectedSync(relativePath, 0, 0, expectedDescriptor);
2864
+ },
2865
+ };
2866
+ }
2867
+
2868
+ const relative = this.#relative(this.#resolve(relativePath));
2869
+ const authority = this.#authority;
2870
+ const matchesExpected = (identity: RecoveryFsIdentity | undefined): boolean => {
2871
+ if (!identity) return false;
2872
+ const actual = managedFileIdentityFromNative(identity);
2873
+ return (
2874
+ actual.dev === expectedDescriptor.dev &&
2875
+ actual.ino === expectedDescriptor.ino &&
2876
+ (expectedDescriptor.nlink === undefined || actual.nlink === expectedDescriptor.nlink) &&
2877
+ actual.size === expectedDescriptor.size &&
2878
+ actual.mtimeNs === expectedDescriptor.mtimeNs &&
2879
+ actual.ctimeNs === expectedDescriptor.ctimeNs
2880
+ );
2881
+ };
2882
+ const assertCurrentBinding = (): void => {
2883
+ this.#assertBound();
2884
+ const named = authority.stat(relative);
2885
+ if (!named.ok || !matchesExpected(named.identity)) throw new Error("source_changed");
2886
+ this.#assertBound();
2887
+ };
2888
+
2889
+ let file: RecoveryFsFile | undefined;
2890
+ try {
2891
+ file = authority.openFile(relative);
2892
+ const opened = file.identity();
2893
+ if (!opened.ok || !matchesExpected(opened.identity)) throw new Error("source_changed");
2894
+ assertCurrentBinding();
2895
+ } catch (error) {
2896
+ try {
2897
+ file?.close();
2898
+ } catch {
2899
+ // Preserve the identity/open failure; closing is still attempted.
2900
+ }
2901
+ throw error;
2902
+ }
2903
+
2904
+ let closed = false;
2905
+ return {
2906
+ readRange: (start, length) => {
2907
+ if (closed) throw new Error("closed");
2908
+ if (
2909
+ !Number.isSafeInteger(start) ||
2910
+ start < 0 ||
2911
+ !Number.isSafeInteger(length) ||
2912
+ length < 0 ||
2913
+ start > Number.MAX_SAFE_INTEGER - length
2914
+ )
2915
+ throw new RangeError("Invalid managed range read");
2916
+ if (length > 1024 * 1024) throw new RangeError("Managed read chunk exceeds the bounded maximum");
2917
+ if (start + length > expectedDescriptor.size) throw new Error("range_not_present");
2918
+ assertCurrentBinding();
2919
+ const before = file!.identity();
2920
+ if (!before.ok || !matchesExpected(before.identity)) throw new Error("source_changed");
2921
+ const chunk = file!.readChunk(start, length);
2922
+ if (!chunk.ok || !chunk.data || !chunk.identity) throw new Error(chunk.code ?? "managed_read_failed");
2923
+ if (!matchesExpected(chunk.identity)) throw new Error("source_changed");
2924
+ if (chunk.data.byteLength !== length) throw new Error("range_not_present");
2925
+ const after = file!.identity();
2926
+ if (!after.ok || !matchesExpected(after.identity)) throw new Error("source_changed");
2927
+ assertCurrentBinding();
2928
+ return chunk.data;
2929
+ },
2930
+ close: () => {
2931
+ if (closed) return;
2932
+ closed = true;
2933
+ let failure: unknown;
2934
+ try {
2935
+ assertCurrentBinding();
2936
+ } catch (error) {
2937
+ failure = error;
2938
+ }
2939
+ let result: RecoveryFsResult | undefined;
2940
+ try {
2941
+ result = file!.close();
2942
+ } catch (error) {
2943
+ failure ??= error;
2944
+ }
2945
+ if (failure === undefined && (!result?.ok || !matchesExpected(result.identity)))
2946
+ failure = new Error(result?.code ?? "source_changed");
2947
+ if (failure === undefined) {
2948
+ try {
2949
+ assertCurrentBinding();
2950
+ } catch (error) {
2951
+ failure = error;
2952
+ }
2953
+ }
2954
+ if (failure !== undefined) throw failure;
2955
+ },
2956
+ };
2957
+ }
2958
+
2760
2959
  /** Read an exact managed file without exposing its pathname as authority. */
2761
2960
  readExpected(relativePath: string): ManagedFileSnapshot | null {
2762
2961
  this.#assertBound();
@@ -2793,6 +2992,85 @@ export class ManagedSessionDescendantStore {
2793
2992
  };
2794
2993
  }
2795
2994
 
2995
+ /** Read one complete managed file through retained authority after descriptor-size admission. */
2996
+ readExpectedBounded(
2997
+ relativePath: string,
2998
+ maxBytes: number,
2999
+ admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
3000
+ ): ManagedFileSnapshot | null {
3001
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
3002
+ this.#assertBound();
3003
+ const resolved = this.#resolve(relativePath);
3004
+ if (!this.#authority) {
3005
+ try {
3006
+ this.#assertPathBackedDirectoryChain(resolved);
3007
+ const captured = captureManagedFileNoFollowBounded(resolved, maxBytes, admitSize);
3008
+ this.#assertBound();
3009
+ return captured;
3010
+ } catch (error) {
3011
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
3012
+ throw error;
3013
+ }
3014
+ }
3015
+ let descriptor: SessionStorageRangeSnapshot;
3016
+ try {
3017
+ descriptor = this.readRangeExpectedSync(this.#relative(resolved), 0, 0);
3018
+ } catch (error) {
3019
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
3020
+ throw error;
3021
+ }
3022
+ const { stat } = descriptor;
3023
+ if (stat.nlink !== 1n) throw new Error("source_changed");
3024
+ if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw new Error("source_changed");
3025
+ if (stat.size > maxBytes) throw new Error("artifact_capacity_exceeded");
3026
+ const lease = this.openReadLease(this.#relative(resolved), stat);
3027
+ let failed = false;
3028
+ let failure: unknown;
3029
+ let snapshot: ManagedFileSnapshot | undefined;
3030
+ try {
3031
+ admitSize?.(stat.size, {
3032
+ dev: stat.dev,
3033
+ ino: stat.ino,
3034
+ nlink: stat.nlink,
3035
+ size: stat.size,
3036
+ mtimeNs: stat.mtimeNs,
3037
+ ctimeNs: stat.ctimeNs,
3038
+ });
3039
+ const bytes = Buffer.alloc(stat.size);
3040
+ for (let offset = 0; offset < bytes.byteLength; ) {
3041
+ const length = Math.min(1024 * 1024, bytes.byteLength - offset);
3042
+ bytes.set(lease.readRange(offset, length), offset);
3043
+ offset += length;
3044
+ }
3045
+ snapshot = {
3046
+ bytes,
3047
+ identity: {
3048
+ dev: stat.dev,
3049
+ ino: stat.ino,
3050
+ nlink: stat.nlink,
3051
+ size: stat.size,
3052
+ mtimeNs: stat.mtimeNs,
3053
+ ctimeNs: stat.ctimeNs,
3054
+ sha256: createHash("sha256").update(bytes).digest("hex"),
3055
+ },
3056
+ };
3057
+ } catch (error) {
3058
+ failed = true;
3059
+ failure = error;
3060
+ }
3061
+ try {
3062
+ lease.close();
3063
+ } catch (error) {
3064
+ if (!failed) {
3065
+ failed = true;
3066
+ failure = error;
3067
+ }
3068
+ }
3069
+ if (failed) throw failure;
3070
+ if (!snapshot) throw new Error("Managed descendant snapshot is unavailable");
3071
+ return snapshot;
3072
+ }
3073
+
2796
3074
  /** Remove an exact captured file without reopening its pathname as authority. */
2797
3075
  removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
2798
3076
  this.#beforeMutation();
@@ -3028,6 +3306,7 @@ export class ManagedSessionDescendantStore {
3028
3306
  sourceStoreRelativePath: string;
3029
3307
  },
3030
3308
  ): ManagedFileSnapshot {
3309
+ this.#assertWritable();
3031
3310
  this.#assertBound();
3032
3311
  const sourceResolved = this.#resolve(sourceRelativePath);
3033
3312
  const destinationResolved = this.#resolve(destinationRelativePath);
@@ -3297,7 +3576,7 @@ function sameReplacementIdentity(
3297
3576
  );
3298
3577
  }
3299
3578
 
3300
- function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
3579
+ export function parseManagedLockRecord(bytes: Uint8Array): LockRecord | undefined {
3301
3580
  try {
3302
3581
  const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
3303
3582
  if (!value || typeof value !== "object") return undefined;
@@ -3320,7 +3599,7 @@ function parseLock(pathname: string): LockRecord | undefined {
3320
3599
  try {
3321
3600
  const stat = fs.lstatSync(pathname);
3322
3601
  if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
3323
- return parseLockBytes(fs.readFileSync(pathname));
3602
+ return parseManagedLockRecord(fs.readFileSync(pathname));
3324
3603
  } catch {
3325
3604
  return undefined;
3326
3605
  }
@@ -3329,7 +3608,7 @@ function parseLock(pathname: string): LockRecord | undefined {
3329
3608
  function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined {
3330
3609
  try {
3331
3610
  const snapshot = captureManagedFileNoFollow(pathname);
3332
- const record = parseLockBytes(snapshot.bytes);
3611
+ const record = parseManagedLockRecord(snapshot.bytes);
3333
3612
  return record ? { record, snapshot } : undefined;
3334
3613
  } catch {
3335
3614
  return undefined;
@@ -3437,17 +3716,41 @@ export function captureManagedFilePrefixNoFollow(pathname: string, maxBytes: num
3437
3716
  return captureManagedFileNoFollowLimit(pathname, maxBytes);
3438
3717
  }
3439
3718
 
3719
+ /** Captures a complete no-follow file only after descriptor-size admission and before allocating its bytes. */
3720
+ export function captureManagedFileNoFollowBounded(
3721
+ pathname: string,
3722
+ maxBytes: number,
3723
+ admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
3724
+ ): ManagedFileSnapshot {
3725
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
3726
+ return captureManagedFileNoFollowLimit(pathname, maxBytes, true, admitSize);
3727
+ }
3728
+
3440
3729
  /** Captures header/hash/copy input from one no-follow descriptor and rechecks the pathname before use. */
3441
3730
  export function captureManagedFileNoFollow(pathname: string): ManagedFileSnapshot {
3442
3731
  return captureManagedFileNoFollowLimit(pathname);
3443
3732
  }
3444
3733
 
3445
- function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): ManagedFileSnapshot {
3734
+ function captureManagedFileNoFollowLimit(
3735
+ pathname: string,
3736
+ maxBytes?: number,
3737
+ rejectOversized = false,
3738
+ admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
3739
+ ): ManagedFileSnapshot {
3446
3740
  const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0));
3741
+ let failed = false;
3742
+ let failure: unknown;
3743
+ let snapshot: ManagedFileSnapshot | undefined;
3447
3744
  try {
3448
3745
  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);
3746
+ if (!before.isFile() || (rejectOversized ? before.nlink !== 1n : before.nlink > 1n))
3747
+ throw new Error("source_changed");
3748
+ const fileSize = Number(before.size);
3749
+ if (!Number.isSafeInteger(fileSize) || fileSize < 0) throw new Error("source_changed");
3750
+ if (rejectOversized && maxBytes !== undefined && fileSize > maxBytes)
3751
+ throw new Error("artifact_capacity_exceeded");
3752
+ admitSize?.(fileSize, identity(before));
3753
+ const captureSize = maxBytes === undefined ? fileSize : Math.min(fileSize, maxBytes);
3451
3754
  const bytes = Buffer.alloc(captureSize);
3452
3755
  let offset = 0;
3453
3756
  while (offset < bytes.byteLength) {
@@ -3460,10 +3763,22 @@ function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): M
3460
3763
  const named = fs.lstatSync(pathname, { bigint: true });
3461
3764
  if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
3462
3765
  throw new Error("source_changed");
3463
- return { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
3464
- } finally {
3766
+ snapshot = { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
3767
+ } catch (error) {
3768
+ failed = true;
3769
+ failure = error;
3770
+ }
3771
+ try {
3465
3772
  fs.closeSync(fd);
3773
+ } catch (error) {
3774
+ if (!failed) {
3775
+ failed = true;
3776
+ failure = error;
3777
+ }
3466
3778
  }
3779
+ if (failed) throw failure;
3780
+ if (!snapshot) throw new Error("Managed file capture is unavailable");
3781
+ return snapshot;
3467
3782
  }
3468
3783
 
3469
3784
  /** Streams a managed file once while retaining only a bounded header prefix and the full descriptor-bound digest. */