@gajae-code/coding-agent 0.18.11 → 0.18.12

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 (270) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. package/src/web/search/providers/searxng.ts +62 -10
@@ -9,6 +9,7 @@ import {
9
9
  ensureLaunchWorktree,
10
10
  evacuatePreWorktreeTarget,
11
11
  isReplaceableWorktreeTarget,
12
+ removeOwnedLaunchWorktree,
12
13
  restorePreWorktreeGjc,
13
14
  } from "./launch-worktree";
14
15
 
@@ -246,3 +247,150 @@ describe("acquireTargetLock", () => {
246
247
  expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
247
248
  });
248
249
  });
250
+
251
+ function ownedReceipt(): { created: true; reused: false; createdBranch: false } {
252
+ return { created: true, reused: false, createdBranch: false };
253
+ }
254
+
255
+ function detachedPlan(repoRoot: string, worktreePath: string): GjcLaunchWorktreePlan {
256
+ return {
257
+ enabled: true,
258
+ repoRoot,
259
+ worktreePath,
260
+ detached: true,
261
+ baseRef: "HEAD",
262
+ branchName: null,
263
+ };
264
+ }
265
+
266
+ /** Replace a real worktree directory with a symlink to a directory that copies its `.git` file. */
267
+ function swapWorktreeForForgedGitdir(worktreePath: string, forgeDir: string): void {
268
+ const gitFile = fs.readFileSync(path.join(worktreePath, ".git"), "utf8");
269
+ fs.mkdirSync(forgeDir, { recursive: true });
270
+ fs.writeFileSync(path.join(forgeDir, ".git"), gitFile.endsWith("\n") ? gitFile : `${gitFile}\n`);
271
+ fs.writeFileSync(path.join(forgeDir, "needle.txt"), "KEEP-NEEDLE\n");
272
+ fs.rmSync(worktreePath, { recursive: true, force: true });
273
+ fs.symlinkSync(forgeDir, worktreePath, "dir");
274
+ }
275
+
276
+ describe("removeOwnedLaunchWorktree", () => {
277
+ test("still removes a real directory worktree", () => {
278
+ const repo = initRepo("gjc-rm-real-repo-");
279
+ const bucket = makeTempDir("gjc-rm-real-bucket-");
280
+ const worktreePath = path.join(bucket, "wt");
281
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
282
+ fs.writeFileSync(path.join(worktreePath, "needle.txt"), "inside\n");
283
+
284
+ removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
285
+
286
+ expect(fs.existsSync(worktreePath)).toBe(false);
287
+ expect(countRegisteredWorktrees(repo)).toBe(1);
288
+ });
289
+
290
+ test("still removes a worktree reached through an in-repo directory symlink", () => {
291
+ const repo = initRepo("gjc-rm-inside-link-repo-");
292
+ const inside = path.join(repo, "inside");
293
+ fs.mkdirSync(inside);
294
+ fs.symlinkSync(inside, path.join(repo, ".worktrees"), "dir");
295
+ const worktreePath = path.join(repo, ".worktrees", "wt");
296
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
297
+
298
+ removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
299
+
300
+ expect(fs.existsSync(path.join(inside, "wt"))).toBe(false);
301
+ expect(fs.lstatSync(path.join(repo, ".worktrees")).isSymbolicLink()).toBe(true);
302
+ expect(countRegisteredWorktrees(repo)).toBe(1);
303
+ });
304
+
305
+ test("does not delete the referent when the worktree path is a symlink to a forged gitdir", () => {
306
+ const repo = initRepo("gjc-rm-forge-repo-");
307
+ const bucket = makeTempDir("gjc-rm-forge-bucket-");
308
+ const worktreePath = path.join(bucket, "wt");
309
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
310
+ const forge = makeTempDir("gjc-rm-forge-target-");
311
+ swapWorktreeForForgedGitdir(worktreePath, forge);
312
+
313
+ removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
314
+
315
+ expect(fs.readFileSync(path.join(forge, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
316
+ expect(fs.lstatSync(path.join(forge, ".git")).isFile()).toBe(true);
317
+ expect(fs.existsSync(worktreePath)).toBe(false);
318
+ });
319
+
320
+ test("still leaves an outside symlink when an in-repo bucket is retargeted", () => {
321
+ const repo = initRepo("gjc-rm-outlink-repo-");
322
+ const bucket = path.join(repo, ".worktrees");
323
+ fs.mkdirSync(bucket);
324
+ const worktreePath = path.join(bucket, "wt");
325
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
326
+ fs.rmSync(bucket, { recursive: true, force: true });
327
+ const outside = makeTempDir("gjc-rm-outlink-outside-");
328
+ const vault = makeTempDir("gjc-rm-outlink-vault-");
329
+ fs.writeFileSync(path.join(vault, "needle.txt"), "KEEP-NEEDLE\n");
330
+ const outsideLink = path.join(outside, "wt");
331
+ fs.symlinkSync(vault, outsideLink, "dir");
332
+ fs.symlinkSync(outside, bucket, "dir");
333
+
334
+ removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
335
+
336
+ expect(fs.lstatSync(outsideLink).isSymbolicLink()).toBe(true);
337
+ expect(fs.readlinkSync(outsideLink)).toBe(vault);
338
+ expect(fs.readFileSync(path.join(vault, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
339
+ expect(fs.lstatSync(bucket).isSymbolicLink()).toBe(true);
340
+ });
341
+
342
+ test("does not delete a directory outside the repo when an in-repo bucket symlink is retargeted", () => {
343
+ const repo = initRepo("gjc-rm-retarget-repo-");
344
+ const bucket = path.join(repo, ".worktrees");
345
+ fs.mkdirSync(bucket);
346
+ const worktreePath = path.join(bucket, "wt");
347
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
348
+ const gitFile = fs.readFileSync(path.join(worktreePath, ".git"), "utf8");
349
+ fs.rmSync(bucket, { recursive: true, force: true });
350
+ const outside = makeTempDir("gjc-rm-retarget-outside-");
351
+ const outsideWt = path.join(outside, "wt");
352
+ fs.mkdirSync(outsideWt);
353
+ fs.writeFileSync(path.join(outsideWt, ".git"), gitFile.endsWith("\n") ? gitFile : `${gitFile}\n`);
354
+ fs.writeFileSync(path.join(outsideWt, "needle.txt"), "KEEP-NEEDLE\n");
355
+ fs.symlinkSync(outside, bucket, "dir");
356
+
357
+ removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
358
+
359
+ expect(fs.readFileSync(path.join(outsideWt, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
360
+ expect(fs.lstatSync(bucket).isSymbolicLink()).toBe(true);
361
+ });
362
+ });
363
+
364
+ describe("commitPreWorktreeRestore symlink rollback", () => {
365
+ test("rolls back a symlinked worktree without deleting the forged referent", () => {
366
+ const repo = initRepo("gjc-rb-forge-repo-");
367
+ const bucket = makeTempDir("gjc-rb-forge-bucket-");
368
+ const worktreePath = path.join(bucket, "wt");
369
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
370
+ const forge = makeTempDir("gjc-rb-forge-target-");
371
+ swapWorktreeForForgedGitdir(worktreePath, forge);
372
+ const stashPath = path.join(bucket, ".gjc-pre-wt-wt");
373
+ fs.mkdirSync(stashPath, { recursive: true });
374
+ fs.writeFileSync(path.join(stashPath, "launcher.txt"), "config");
375
+
376
+ let message = "";
377
+ try {
378
+ commitPreWorktreeRestore(
379
+ {
380
+ restore: () => {
381
+ throw new Error("overlay exploded");
382
+ },
383
+ stashPath,
384
+ },
385
+ detachedPlan(repo, worktreePath),
386
+ );
387
+ throw new Error("expected commitPreWorktreeRestore to throw");
388
+ } catch (error) {
389
+ message = error instanceof Error ? error.message : String(error);
390
+ }
391
+
392
+ expect(message).toMatch(/worktree_gjc_restore_failed/);
393
+ expect(fs.readFileSync(path.join(forge, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
394
+ expect(fs.existsSync(path.join(stashPath, "launcher.txt"))).toBe(true);
395
+ });
396
+ });
@@ -244,9 +244,34 @@ function resolveWorktreeBucket(repoRoot: string): string {
244
244
  return resolveWorktreeBucketForPath(repoRoot, trustedWorktreeBucketEnv(process.env), os.homedir(), path);
245
245
  }
246
246
 
247
+ function throwIfBucketRealpathEscapesRepository(repoRoot: string, bucketPath: string): void {
248
+ let realBucket: string;
249
+ try {
250
+ realBucket = fs.realpathSync(bucketPath);
251
+ } catch (error) {
252
+ // A missing bucket is created later. A dangling link is classified by inspectBucketDir.
253
+ if (fileSystemErrorCode(error) === "ENOENT") return;
254
+ throw error;
255
+ }
256
+ const relativeReal = path.relative(fs.realpathSync(repoRoot), realBucket);
257
+ if (relativeReal.startsWith(`..${path.sep}`) || relativeReal === ".." || path.isAbsolute(relativeReal)) {
258
+ throw new Error(
259
+ [
260
+ "worktree_bucket_escapes_repository",
261
+ "The GJC launch worktree bucket resolves outside the repository.",
262
+ `Path: ${formatBucketPath(bucketPath)}`,
263
+ "Safe remediation: replace the path with a real directory inside the repository, or set GJC_WORKTREE_DIR to a directory outside the repository. GJC did not create a worktree.",
264
+ ].join("\n"),
265
+ );
266
+ }
267
+ }
268
+
247
269
  function ensureRepositoryBucketIgnored(repoRoot: string, bucketPath: string): void {
248
270
  const relativeBucket = path.relative(repoRoot, bucketPath);
271
+ // Lexical paths outside the repo are the operator override and stay allowed.
249
272
  if (relativeBucket.startsWith(`..${path.sep}`) || relativeBucket === ".." || path.isAbsolute(relativeBucket)) return;
273
+ // git check-ignore accepts a gitignored symlink without looking at its target.
274
+ throwIfBucketRealpathEscapesRepository(repoRoot, bucketPath);
250
275
  const ignoreProbe = path.join(relativeBucket, ".gjc-worktree-probe");
251
276
  for (const candidate of [relativeBucket, ignoreProbe]) {
252
277
  const result = Bun.spawnSync(["git", "check-ignore", "--quiet", "--", candidate], {
@@ -702,17 +727,87 @@ function tryRestore(restore: () => void): void {
702
727
  }
703
728
  }
704
729
 
705
- /** Best-effort removal of a worktree GJC just created, used when a later step must roll it back. */
706
- function rollbackCreatedWorktree(plan: GjcLaunchWorktreePlan): void {
730
+ function lexicalPathInsideRepository(repoRoot: string, targetPath: string): boolean {
731
+ const relative = path.relative(path.resolve(repoRoot), path.resolve(targetPath));
732
+ return !(relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative));
733
+ }
734
+
735
+ function realpathEscapesRepository(repoRoot: string, targetPath: string): boolean {
736
+ let realTarget: string;
737
+ try {
738
+ realTarget = fs.realpathSync(targetPath);
739
+ } catch (error) {
740
+ // A missing path is not an escape. Anything else is not safe to hand to remove --force.
741
+ if (fileSystemErrorCode(error) === "ENOENT") return false;
742
+ return true;
743
+ }
744
+ const relativeReal = path.relative(fs.realpathSync(repoRoot), realTarget);
745
+ return relativeReal.startsWith(`..${path.sep}`) || relativeReal === ".." || path.isAbsolute(relativeReal);
746
+ }
747
+
748
+ function pruneWorktreeRegistration(repoRoot: string): void {
707
749
  try {
708
- runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
750
+ runGit(repoRoot, ["worktree", "prune"]);
709
751
  } catch {
752
+ // Best-effort. Callers report their own failure.
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Remove a launch worktree this process just created.
758
+ *
759
+ * `git worktree remove --force` follows a symlink and deletes the referent when a
760
+ * copied `.git` gitdir file passes validation. Unlink that link only when its
761
+ * parent stays inside the repository, or when the path is an explicit external
762
+ * worktree. An in-repo path whose parent resolves outside the repository is
763
+ * left untouched. A real directory is still removed with `--force`.
764
+ */
765
+ function removeCreatedWorktree(repoRoot: string, worktreePath: string): void {
766
+ let stat: fs.Stats | undefined;
767
+ try {
768
+ stat = fs.lstatSync(worktreePath);
769
+ } catch (error) {
770
+ if (fileSystemErrorCode(error) !== "ENOENT") {
771
+ pruneWorktreeRegistration(repoRoot);
772
+ return;
773
+ }
774
+ }
775
+ // A retargeted in-repo bucket can place this path's parent outside the repository.
776
+ // Do not unlink that outside entry. An explicit external worktree is not this case.
777
+ if (
778
+ lexicalPathInsideRepository(repoRoot, worktreePath) &&
779
+ realpathEscapesRepository(repoRoot, path.dirname(worktreePath))
780
+ ) {
781
+ pruneWorktreeRegistration(repoRoot);
782
+ return;
783
+ }
784
+ if (stat?.isSymbolicLink()) {
710
785
  try {
711
- runGit(plan.repoRoot, ["worktree", "prune"]);
786
+ fs.unlinkSync(worktreePath);
712
787
  } catch {
713
- // Best-effort rollback; the restore failure below is the actionable error.
788
+ // The link may already be gone.
714
789
  }
790
+ pruneWorktreeRegistration(repoRoot);
791
+ return;
792
+ }
793
+ if (
794
+ stat?.isDirectory() &&
795
+ lexicalPathInsideRepository(repoRoot, worktreePath) &&
796
+ realpathEscapesRepository(repoRoot, worktreePath)
797
+ ) {
798
+ pruneWorktreeRegistration(repoRoot);
799
+ return;
715
800
  }
801
+ try {
802
+ runGit(repoRoot, ["worktree", "remove", "--force", worktreePath]);
803
+ } catch {
804
+ pruneWorktreeRegistration(repoRoot);
805
+ }
806
+ }
807
+
808
+ /** Best-effort removal of a worktree GJC just created, used when a later step must roll it back. */
809
+ function rollbackCreatedWorktree(plan: GjcLaunchWorktreePlan): void {
810
+ removeCreatedWorktree(plan.repoRoot, plan.worktreePath);
716
811
  }
717
812
 
718
813
  /**
@@ -1205,15 +1300,7 @@ export function removeOwnedLaunchWorktree(
1205
1300
  receipt: { created: boolean; reused: boolean; createdBranch: boolean },
1206
1301
  ): void {
1207
1302
  if (!receipt.created || receipt.reused) return;
1208
- try {
1209
- runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
1210
- } catch {
1211
- try {
1212
- runGit(plan.repoRoot, ["worktree", "prune"]);
1213
- } catch {
1214
- // Proof stays on the ledger; leftover classify is best-effort after abort.
1215
- }
1216
- }
1303
+ removeCreatedWorktree(plan.repoRoot, plan.worktreePath);
1217
1304
  if (receipt.createdBranch && plan.branchName) {
1218
1305
  try {
1219
1306
  runGit(plan.repoRoot, ["branch", "-D", plan.branchName]);
@@ -2,8 +2,10 @@ import { Buffer } from "node:buffer";
2
2
  import * as crypto from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
+ import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
5
6
  import type { ManagedOwnerBinding, ManagedOwnerSigabrtReceipt } from "./managed-owner-supervisor";
6
7
  import { assertSafePathComponent } from "./session-layout";
8
+ import { trustedCoordinatorEnv } from "./sidecar-bootstrap-env";
7
9
  import { lifecyclePaths } from "./tmux-owner-isolation";
8
10
  import {
9
11
  persistUltragoalRecoveryDecision,
@@ -23,6 +25,48 @@ export const MANAGED_OWNER_PREDECESSOR_GENERATION_ENV = "GJC_MANAGED_OWNER_PREDE
23
25
  export const MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV = "GJC_MANAGED_OWNER_PREDECESSOR_RUN_ID";
24
26
  export const MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV = "GJC_MANAGED_OWNER_PREDECESSOR_INCARNATION";
25
27
  export const MANAGED_OWNER_TRANSCRIPT_PATH_ENV = "GJC_MANAGED_OWNER_TRANSCRIPT_PATH";
28
+ const MANAGED_OWNER_SESSION_FILE_ENV = "GJC_SESSION_FILE";
29
+
30
+ /**
31
+ * Operator environment only. A project dotenv value, including a dynamic
32
+ * declaration, must not choose the recovery transcript. A static
33
+ * `GJC_SESSION_FILE` declaration is the same source: the launcher copies it
34
+ * into the transcript variable. The operator bytes are kept, including
35
+ * surrounding whitespace; provenance is not path normalization.
36
+ */
37
+ function operatorOwnedEnvValue(name: string, raw: string | undefined, snapshot: ProjectEnvSnapshot): string {
38
+ if (!raw) return "";
39
+ const key = canonicalEnvKey(name);
40
+ const declared = snapshot.values[key];
41
+ if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw)) return "";
42
+ const verdict = trustedCoordinatorEnv(name, { [name]: raw }, snapshot);
43
+ if (verdict === undefined && raw.trim().length > 0) return "";
44
+ return raw;
45
+ }
46
+
47
+ export function trustedManagedOwnerTranscriptPath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
48
+ const snapshot = projectEnvSnapshot(cwd);
49
+ const raw = operatorOwnedEnvValue(
50
+ MANAGED_OWNER_TRANSCRIPT_PATH_ENV,
51
+ env[MANAGED_OWNER_TRANSCRIPT_PATH_ENV],
52
+ snapshot,
53
+ );
54
+ if (!raw) return "";
55
+ const sessionKey = canonicalEnvKey(MANAGED_OWNER_SESSION_FILE_ENV);
56
+ if (snapshot.dynamic.has(sessionKey)) return raw;
57
+ const declared = snapshot.values[sessionKey];
58
+ if (declared !== undefined && raw === declared) return "";
59
+ return raw;
60
+ }
61
+
62
+ /** Session file forwarded to a replacement child. Same provenance rule. */
63
+ export function trustedManagedOwnerLaunchSessionFile(env: NodeJS.ProcessEnv, cwd: string): string {
64
+ return operatorOwnedEnvValue(
65
+ MANAGED_OWNER_SESSION_FILE_ENV,
66
+ env[MANAGED_OWNER_SESSION_FILE_ENV],
67
+ projectEnvSnapshot(cwd),
68
+ );
69
+ }
26
70
 
27
71
  export interface ManagedOwnerRecoveryContext {
28
72
  root: string;
@@ -295,7 +339,7 @@ export async function admitManagedOwnerBeforeCli(): Promise<ManagedOwnerAdmissio
295
339
  },
296
340
  receipt,
297
341
  admission,
298
- transcriptPath: process.env[MANAGED_OWNER_TRANSCRIPT_PATH_ENV] ?? "",
342
+ transcriptPath: trustedManagedOwnerTranscriptPath(),
299
343
  });
300
344
  await persistUltragoalRecoveryDecision({
301
345
  cwd: process.cwd(),
@@ -330,7 +374,7 @@ export async function completeManagedOwnerRecovery(
330
374
  binding: recoveryBinding,
331
375
  receipt: context.receipt,
332
376
  admission: context.admission,
333
- transcriptPath: process.env[MANAGED_OWNER_TRANSCRIPT_PATH_ENV] ?? "",
377
+ transcriptPath: trustedManagedOwnerTranscriptPath(),
334
378
  protectedPaths: context.decision.snapshot?.protectedPaths,
335
379
  sanctionedDeltas: context.decision.snapshot?.sanctionedDeltas,
336
380
  absentArtifacts: context.decision.snapshot?.absentArtifacts,
@@ -29,6 +29,14 @@ import {
29
29
  /** Window within which two activity timestamps are treated as an ambiguous tie. */
30
30
  export const LATEST_SESSION_TIE_WINDOW_MS = 1000;
31
31
 
32
+ /**
33
+ * Local writers stamp `Date.now()`. A parsed `updated_at` further ahead than this
34
+ * is planted marker content, not activity, and is not a candidate. Marker mtime
35
+ * is not used in that case: a checkout or touch refreshes mtime without rewriting
36
+ * a future timestamp.
37
+ */
38
+ const ACTIVITY_FUTURE_SKEW_MS = 2 * 60 * 1000;
39
+
32
40
  export interface SessionIdSources {
33
41
  /** Raw `--session-id` value: `undefined` = flag absent; `""` = present-but-blank (invalid). */
34
42
  flagValue?: string | undefined;
@@ -46,8 +54,12 @@ export class SessionResolutionError extends Error {
46
54
  }
47
55
  }
48
56
 
57
+ function isUnsafeResolvedSessionId(sessionId: string): boolean {
58
+ return sessionId === "." || sessionId === ".." || /[/\\]/.test(sessionId);
59
+ }
60
+
49
61
  function assertSafeResolvedSessionId(sessionId: string): void {
50
- if (sessionId === "." || sessionId === ".." || /[/\\]/.test(sessionId)) {
62
+ if (isUnsafeResolvedSessionId(sessionId)) {
51
63
  throw new SessionResolutionError(
52
64
  "session id must be a single path component (no separators or traversal)",
53
65
  "unsafe_session",
@@ -135,8 +147,17 @@ interface SessionCandidate {
135
147
  * or an ambiguous tie.
136
148
  */
137
149
  export async function detectLatestSession(cwd: string): Promise<GjcSessionContext> {
138
- const candidates = await collectActiveSessionCandidates(cwd);
150
+ const found = await collectActiveSessionCandidates(cwd);
151
+ const candidates = found.candidates;
139
152
  if (candidates.length === 0) {
153
+ // No safe session was selectable. Keep the unsafe-id error when a crafted
154
+ // directory was the reason nothing could be chosen, matching explicit ids.
155
+ if (found.sawUnsafe) {
156
+ throw new SessionResolutionError(
157
+ "session id must be a single path component (no separators or traversal)",
158
+ "unsafe_session",
159
+ );
160
+ }
140
161
  throw new SessionResolutionError(
141
162
  "no active GJC session found: pass --session-id or set GJC_SESSION_ID",
142
163
  "no_session",
@@ -156,19 +177,27 @@ export async function detectLatestSession(cwd: string): Promise<GjcSessionContex
156
177
  return { gjcSessionId: first.gjcSessionId, sessionRoot: first.sessionRoot, source: "latest" };
157
178
  }
158
179
 
159
- async function collectActiveSessionCandidates(cwd: string): Promise<SessionCandidate[]> {
180
+ async function collectActiveSessionCandidates(
181
+ cwd: string,
182
+ ): Promise<{ candidates: SessionCandidate[]; sawUnsafe: boolean }> {
160
183
  const root = gjcRoot(cwd);
161
184
  let entries: import("node:fs").Dirent[];
162
185
  try {
163
186
  entries = await fs.readdir(root, { withFileTypes: true });
164
187
  } catch {
165
- return [];
188
+ return { candidates: [], sawUnsafe: false };
166
189
  }
167
190
  const candidates: SessionCandidate[] = [];
191
+ let sawUnsafe = false;
168
192
  for (const entry of entries) {
169
193
  if (!entry.isDirectory()) continue;
170
194
  const gjcSessionId = sessionIdFromDirName(entry.name);
171
195
  if (!gjcSessionId) continue;
196
+ // Skip; throwing here would stop discovery for every other session.
197
+ if (isUnsafeResolvedSessionId(gjcSessionId)) {
198
+ sawUnsafe = true;
199
+ continue;
200
+ }
172
201
  assertSafeResolvedSessionId(gjcSessionId);
173
202
  const dir = path.join(root, entry.name);
174
203
  const activityMs = await readActivityMs(path.join(dir, GJC_SESSION_ACTIVITY_FILE));
@@ -177,7 +206,7 @@ async function collectActiveSessionCandidates(cwd: string): Promise<SessionCandi
177
206
  if (activityMs === undefined) continue;
178
207
  candidates.push({ gjcSessionId, sessionRoot: dir, activityMs });
179
208
  }
180
- return candidates;
209
+ return { candidates, sawUnsafe };
181
210
  }
182
211
 
183
212
  async function readActivityMs(markerPath: string): Promise<number | undefined> {
@@ -191,7 +220,10 @@ async function readActivityMs(markerPath: string): Promise<number | undefined> {
191
220
  const parsed = JSON.parse(raw) as { updated_at?: unknown };
192
221
  if (typeof parsed.updated_at === "string") {
193
222
  const ms = Date.parse(parsed.updated_at);
194
- if (!Number.isNaN(ms)) return ms;
223
+ if (!Number.isNaN(ms)) {
224
+ if (ms > Date.now() + ACTIVITY_FUTURE_SKEW_MS) return undefined;
225
+ return ms;
226
+ }
195
227
  }
196
228
  } catch {
197
229
  // fall through to mtime
@@ -300,9 +300,12 @@ async function repairPendingOwnerRecord(pending: PendingOwnerRepair): Promise<bo
300
300
  const handle = pending.repairHandle;
301
301
  if (!handle || pending.repairBytes === undefined) return true;
302
302
  try {
303
- await writeOwnerBytes(handle, Buffer.from(pending.repairBytes, "utf8"));
303
+ const written = await writeOwnerBytes(handle, Buffer.from(pending.repairBytes, "utf8"), undefined);
304
304
  const stat = await handle.stat({ bigint: true });
305
- pending.repairSnapshot = ownerSnapshotFrom(stat, Buffer.from(pending.repairBytes, "utf8"));
305
+ // The rewritten record may have been padded back to its previous length, so the
306
+ // identity that authorizes later cleanup is the bytes that landed, not the bytes
307
+ // requested.
308
+ pending.repairSnapshot = ownerSnapshotFrom(stat, written);
306
309
  await handle.close();
307
310
  pending.repairHandle = undefined;
308
311
  return true;
@@ -533,6 +536,8 @@ export const SessionStateLockTestHooks: {
533
536
  afterCurrentOwnerValidation?: (file: string) => void | Promise<void>;
534
537
  /** @internal Fault seam immediately before a held owner record rewrite mutates bytes. */
535
538
  beforeOwnerRecordRewrite?: (file: string) => void | Promise<void>;
539
+ /** @internal Fault seam immediately before the pre-rewrite fstat on the owner record handle. */
540
+ beforeOwnerRecordPrewriteStat?: (file: string) => void | Promise<void>;
536
541
  /** @internal Fault seam immediately before final transition-generation validation. */
537
542
  beforeTransitionReleaseLstat?: (transitionDir: string) => void | Promise<void>;
538
543
  /** @internal Fault seam immediately before setup generation capture. */
@@ -1491,13 +1496,21 @@ async function createOwnerLock(
1491
1496
  // pair names the record this call brought into existence and nothing else.
1492
1497
  let created: OpenOwnerIdentity | null = null;
1493
1498
  try {
1494
- await handle.chmod(0o600);
1495
1499
  created = await openOwnerIdentity(handle);
1496
1500
  await SessionStateLockTestHooks.ownerRecordWriteFault?.(file);
1501
+ // The record's bytes land before anything else is asked of this descriptor. A
1502
+ // freshly created record is empty until they do, and an empty record carries no
1503
+ // `owner_host_id`, so every syscall placed in front of this write is a window in
1504
+ // which a kill leaves debris no later contender may reclaim. `chmod` is the one
1505
+ // that can actually stall — it is a metadata round-trip that a File Provider,
1506
+ // NFS, or CIFS mount may hold for seconds — so it moves behind the write. The
1507
+ // unrestricted mode it corrected for is already excluded by the create's own
1508
+ // mode argument, so the descriptor is never more permissive than 0o600 here.
1497
1509
  await handle.writeFile(bytes);
1498
1510
  const stat = await handle.stat({ bigint: true });
1499
1511
  if (!stat.isFile() || stat.size !== BigInt(bytes.byteLength))
1500
1512
  throw new SessionStateLockUnavailableError(new Error("Owner record did not land as a regular file."));
1513
+ await handle.chmod(0o600);
1501
1514
  return ownerSnapshotFrom(stat, bytes);
1502
1515
  } catch (error) {
1503
1516
  // Deliberately BEFORE any close: releasing the descriptor first would free the
@@ -1520,16 +1533,48 @@ const POSIX_OWNER_REWRITE_FLAGS = POSIX_NOFOLLOW_AVAILABLE
1520
1533
  ? fsSync.constants.O_RDWR | fsSync.constants.O_NONBLOCK | fsSync.constants.O_NOFOLLOW
1521
1534
  : undefined;
1522
1535
 
1523
- async function writeOwnerBytes(handle: fs.FileHandle, bytes: Buffer): Promise<void> {
1524
- await handle.truncate(0);
1536
+ /**
1537
+ * The one writer of an owner record's bytes: overwrite in place, never shorter.
1538
+ *
1539
+ * `truncate(0)` followed by a write looks atomic and is not. The bytes a truncate
1540
+ * publishes are EMPTY, and an empty record carries no `owner_host_id` at all, so a
1541
+ * process that dies inside that window (a repeated Ctrl-C reaches `postmortem.quit`,
1542
+ * which deliberately performs no lock release) leaves a record with no attributable
1543
+ * owner. `malformedOwnerProvenance` classifies it `unprovenanced`, the reclaim refuses
1544
+ * it, and every later contender fails closed forever: `transition_claim_timeout` with
1545
+ * `claim_or_owner_unreadable`, and coordinator state persistence for that session is
1546
+ * lost for good — the failure class #5159 and #5216 both land on.
1547
+ *
1548
+ * So the replacement is written over the HEAD of the record and the previous byte
1549
+ * length is kept instead of discarded. A JSON document ignores trailing whitespace, so
1550
+ * a padded record is the same record to every reader — `JSON.parse`, `validLockOwner`,
1551
+ * `malformedOwnerProvenance`, and the identity-bound unlink all see exactly the
1552
+ * replacement — while the bytes on disk always carry the full ownership preamble. That
1553
+ * leaves a torn single write (which needs the kernel to short-write a ~200 byte payload)
1554
+ * as the only state the record cannot be attributed from, instead of the guaranteed
1555
+ * empty window between two syscalls.
1556
+ *
1557
+ * @returns the exact bytes now on disk. A padded record's size and SHA-256 are the
1558
+ * padded bytes', so this — not the requested payload — is what identifies the record
1559
+ * for every later capture, comparison, and replay.
1560
+ */
1561
+ async function writeOwnerBytes(handle: fs.FileHandle, bytes: Buffer, file?: string): Promise<Buffer> {
1562
+ if (file) await SessionStateLockTestHooks.beforeOwnerRecordPrewriteStat?.(file);
1563
+ const previous = await handle.stat({ bigint: true });
1564
+ const previousSize = previous.isFile() === true ? Number(previous.size) : 0;
1565
+ const padding = Math.max(0, previousSize - bytes.byteLength);
1566
+ const payload = padding === 0 ? bytes : Buffer.concat([bytes, Buffer.alloc(padding, 0x20)]);
1525
1567
  let offset = 0;
1526
- while (offset < bytes.byteLength) {
1527
- const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, offset);
1568
+ while (offset < payload.byteLength) {
1569
+ const { bytesWritten } = await handle.write(payload, offset, payload.byteLength - offset, offset);
1528
1570
  if (bytesWritten <= 0) throw new Error("Owner record rewrite made no progress.");
1529
1571
  offset += bytesWritten;
1530
1572
  }
1531
- await handle.truncate(bytes.byteLength);
1573
+ // `payload` is never shorter than the record already was, so this can only remove a
1574
+ // tail that outgrew the bytes just written; it can never cut the record itself.
1575
+ await handle.truncate(payload.byteLength);
1532
1576
  await handle.sync();
1577
+ return payload;
1533
1578
  }
1534
1579
 
1535
1580
  async function rewriteHeldOwnerRecord(
@@ -1581,15 +1626,15 @@ async function rewriteHeldOwnerRecord(
1581
1626
  mutationStarted = true;
1582
1627
  await SessionStateLockTestHooks.beforeOwnerRecordRewrite?.(file);
1583
1628
  rewriteHookPassed = true;
1584
- await writeOwnerBytes(handle, replacementBytes);
1629
+ const writtenBytes = await writeOwnerBytes(handle, replacementBytes, file);
1585
1630
  const canonical = await captureRegularLockOwner(file);
1586
- // On Windows, an in-place truncate/write can expose refreshed descriptor
1631
+ // On Windows, an in-place rewrite can expose refreshed descriptor
1587
1632
  // metadata even though the file object is unchanged. The descriptor opened
1588
1633
  // before mutation is the stable identity bracket; the path capture still
1589
1634
  // proves that the pathname resolves to that same object after the rewrite.
1590
1635
  if (!canonical || canonical.dev !== opened.dev || canonical.ino !== opened.ino)
1591
1636
  throw new SessionStateLockUnavailableError(new Error("Owner pathname changed after rewrite."));
1592
- if (canonical.bytes !== replacementBytes.toString("utf8")) {
1637
+ if (canonical.bytes !== writtenBytes.toString("utf8")) {
1593
1638
  let successor: unknown;
1594
1639
  try {
1595
1640
  successor = JSON.parse(canonical.bytes);
@@ -1602,7 +1647,7 @@ async function rewriteHeldOwnerRecord(
1602
1647
  return canonical;
1603
1648
  } catch (error) {
1604
1649
  // Keep the descriptor as the repair authority until the held bytes have
1605
- // been restored. A truncate/write/fsync fault may leave a malformed or
1650
+ // been restored. A write/fsync fault may leave a malformed or
1606
1651
  // partially committed record; closing first would force recovery to trust
1607
1652
  // an unproven pathname and can wedge a successor behind the transition.
1608
1653
  let repairNeeded = mutationStarted;
@@ -1616,9 +1661,9 @@ async function rewriteHeldOwnerRecord(
1616
1661
  if (repairNeeded) {
1617
1662
  let repaired: LockOwnerSnapshot;
1618
1663
  try {
1619
- await writeOwnerBytes(handle, Buffer.from(held.bytes, "utf8"));
1664
+ const restored = await writeOwnerBytes(handle, Buffer.from(held.bytes, "utf8"), file);
1620
1665
  const repairedStat = await handle.stat({ bigint: true });
1621
- repaired = ownerSnapshotFrom(repairedStat, Buffer.from(held.bytes, "utf8"));
1666
+ repaired = ownerSnapshotFrom(repairedStat, restored);
1622
1667
  } catch (repairError) {
1623
1668
  const aggregate = new AggregateError(
1624
1669
  [error, repairError],