harness-dispatch 0.7.9 → 0.9.0

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 (153) hide show
  1. package/CHANGELOG.md +1170 -1
  2. package/README.md +52 -6
  3. package/config.default.yaml +49 -5
  4. package/dist/auth.d.ts +29 -0
  5. package/dist/auth.d.ts.map +1 -1
  6. package/dist/auth.js +58 -5
  7. package/dist/auth.js.map +1 -1
  8. package/dist/billing.d.ts +24 -0
  9. package/dist/billing.d.ts.map +1 -1
  10. package/dist/billing.js +24 -0
  11. package/dist/billing.js.map +1 -1
  12. package/dist/bin.d.ts.map +1 -1
  13. package/dist/bin.js +529 -31
  14. package/dist/bin.js.map +1 -1
  15. package/dist/breaker-store.d.ts +25 -1
  16. package/dist/breaker-store.d.ts.map +1 -1
  17. package/dist/breaker-store.js +193 -25
  18. package/dist/breaker-store.js.map +1 -1
  19. package/dist/circuit-breaker.d.ts +9 -1
  20. package/dist/circuit-breaker.d.ts.map +1 -1
  21. package/dist/circuit-breaker.js +15 -1
  22. package/dist/circuit-breaker.js.map +1 -1
  23. package/dist/client-register.d.ts +157 -0
  24. package/dist/client-register.d.ts.map +1 -0
  25. package/dist/client-register.js +389 -0
  26. package/dist/client-register.js.map +1 -0
  27. package/dist/config/validation.d.ts +41 -0
  28. package/dist/config/validation.d.ts.map +1 -1
  29. package/dist/config/validation.js +215 -1
  30. package/dist/config/validation.js.map +1 -1
  31. package/dist/config.d.ts +4 -2
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +143 -6
  34. package/dist/config.js.map +1 -1
  35. package/dist/configure-yaml.d.ts +2 -0
  36. package/dist/configure-yaml.d.ts.map +1 -1
  37. package/dist/configure-yaml.js +69 -3
  38. package/dist/configure-yaml.js.map +1 -1
  39. package/dist/dispatch-log.d.ts +14 -0
  40. package/dist/dispatch-log.d.ts.map +1 -1
  41. package/dist/dispatch-log.js +3 -0
  42. package/dist/dispatch-log.js.map +1 -1
  43. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  44. package/dist/dispatchers/generic-cli.js +92 -15
  45. package/dist/dispatchers/generic-cli.js.map +1 -1
  46. package/dist/dispatchers/openai-compatible.d.ts +20 -4
  47. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  48. package/dist/dispatchers/openai-compatible.js +167 -19
  49. package/dist/dispatchers/openai-compatible.js.map +1 -1
  50. package/dist/dispatchers/shared/harness-login.d.ts +25 -0
  51. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
  52. package/dist/dispatchers/shared/harness-login.js +68 -0
  53. package/dist/dispatchers/shared/harness-login.js.map +1 -0
  54. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  55. package/dist/dispatchers/shared/stream-subprocess.js +70 -4
  56. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  57. package/dist/file-lock.d.ts +29 -1
  58. package/dist/file-lock.d.ts.map +1 -1
  59. package/dist/file-lock.js +22 -1
  60. package/dist/file-lock.js.map +1 -1
  61. package/dist/http/answer-stream.d.ts +52 -0
  62. package/dist/http/answer-stream.d.ts.map +1 -0
  63. package/dist/http/answer-stream.js +47 -0
  64. package/dist/http/answer-stream.js.map +1 -0
  65. package/dist/http/parse.d.ts.map +1 -1
  66. package/dist/http/parse.js +41 -0
  67. package/dist/http/parse.js.map +1 -1
  68. package/dist/http/server.d.ts +2 -0
  69. package/dist/http/server.d.ts.map +1 -1
  70. package/dist/http/server.js +281 -23
  71. package/dist/http/server.js.map +1 -1
  72. package/dist/jobs/context.d.ts +0 -7
  73. package/dist/jobs/context.d.ts.map +1 -1
  74. package/dist/jobs/context.js +95 -7
  75. package/dist/jobs/context.js.map +1 -1
  76. package/dist/jobs/store.d.ts.map +1 -1
  77. package/dist/jobs/store.js +45 -4
  78. package/dist/jobs/store.js.map +1 -1
  79. package/dist/jobs/types.d.ts +8 -1
  80. package/dist/jobs/types.d.ts.map +1 -1
  81. package/dist/jobs.d.ts +39 -0
  82. package/dist/jobs.d.ts.map +1 -1
  83. package/dist/jobs.js +207 -15
  84. package/dist/jobs.js.map +1 -1
  85. package/dist/leaderboard.d.ts.map +1 -1
  86. package/dist/leaderboard.js +39 -27
  87. package/dist/leaderboard.js.map +1 -1
  88. package/dist/mcp/config-hot-reload.d.ts +14 -3
  89. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  90. package/dist/mcp/config-hot-reload.js +33 -5
  91. package/dist/mcp/config-hot-reload.js.map +1 -1
  92. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  93. package/dist/mcp/dispatcher-factory.js +14 -3
  94. package/dist/mcp/dispatcher-factory.js.map +1 -1
  95. package/dist/mcp/near-miss-guard.d.ts +45 -0
  96. package/dist/mcp/near-miss-guard.d.ts.map +1 -0
  97. package/dist/mcp/near-miss-guard.js +98 -0
  98. package/dist/mcp/near-miss-guard.js.map +1 -0
  99. package/dist/mcp/server.d.ts.map +1 -1
  100. package/dist/mcp/server.js +36 -0
  101. package/dist/mcp/server.js.map +1 -1
  102. package/dist/mcp/tool-schemas.d.ts +1 -1
  103. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  104. package/dist/mcp/tool-schemas.js +27 -7
  105. package/dist/mcp/tool-schemas.js.map +1 -1
  106. package/dist/mcp/tools.d.ts +33 -4
  107. package/dist/mcp/tools.d.ts.map +1 -1
  108. package/dist/mcp/tools.js +85 -8
  109. package/dist/mcp/tools.js.map +1 -1
  110. package/dist/mcp-clients.d.ts +72 -0
  111. package/dist/mcp-clients.d.ts.map +1 -0
  112. package/dist/mcp-clients.js +121 -0
  113. package/dist/mcp-clients.js.map +1 -0
  114. package/dist/near-miss.d.ts +63 -0
  115. package/dist/near-miss.d.ts.map +1 -0
  116. package/dist/near-miss.js +132 -0
  117. package/dist/near-miss.js.map +1 -0
  118. package/dist/quota.d.ts +31 -1
  119. package/dist/quota.d.ts.map +1 -1
  120. package/dist/quota.js +84 -8
  121. package/dist/quota.js.map +1 -1
  122. package/dist/route-policy.d.ts +12 -1
  123. package/dist/route-policy.d.ts.map +1 -1
  124. package/dist/route-policy.js +74 -2
  125. package/dist/route-policy.js.map +1 -1
  126. package/dist/router.d.ts +61 -2
  127. package/dist/router.d.ts.map +1 -1
  128. package/dist/router.js +160 -29
  129. package/dist/router.js.map +1 -1
  130. package/dist/safety.d.ts.map +1 -1
  131. package/dist/safety.js +37 -1
  132. package/dist/safety.js.map +1 -1
  133. package/dist/state-dir.d.ts +10 -0
  134. package/dist/state-dir.d.ts.map +1 -1
  135. package/dist/state-dir.js +12 -0
  136. package/dist/state-dir.js.map +1 -1
  137. package/dist/status.d.ts +30 -21
  138. package/dist/status.d.ts.map +1 -1
  139. package/dist/status.js +152 -8
  140. package/dist/status.js.map +1 -1
  141. package/dist/types.d.ts +49 -2
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/working-dir.d.ts.map +1 -1
  144. package/dist/working-dir.js +16 -0
  145. package/dist/working-dir.js.map +1 -1
  146. package/dist/workspace-resolve.d.ts.map +1 -1
  147. package/dist/workspace-resolve.js +196 -15
  148. package/dist/workspace-resolve.js.map +1 -1
  149. package/dist/workspaces.d.ts +98 -0
  150. package/dist/workspaces.d.ts.map +1 -1
  151. package/dist/workspaces.js +609 -20
  152. package/dist/workspaces.js.map +1 -1
  153. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { execFile as execFileCb } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { constants as fsConstants, existsSync } from "node:fs";
4
- import { copyFile, mkdir, readdir, readFile, readlink, rm, stat, symlink, } from "node:fs/promises";
4
+ import { chmod, copyFile, lstat, mkdir, readdir, readFile, readlink, realpath, rm, stat, symlink, writeFile, } from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { promisify } from "node:util";
@@ -34,7 +34,17 @@ export function isIsolatedWorkspacePolicy(policy) {
34
34
  function safeName(value) {
35
35
  return value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 80) || "route";
36
36
  }
37
- function workspaceRunId(routeName) {
37
+ /**
38
+ * Exported for tests, which must build run directories with the SAME function
39
+ * that makes real ones rather than by hand.
40
+ *
41
+ * Three separate tests in this repo used a hand-written name the product
42
+ * cannot generate — run directories with four-character suffixes where a real
43
+ * one has eight hex — and each therefore asserted something about an input
44
+ * that never occurs. Two of them passed while the bug they claimed to cover
45
+ * was live. A fixture is only evidence if it is the thing.
46
+ */
47
+ export function workspaceRunId(routeName) {
38
48
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
39
49
  return `${stamp}-${process.pid}-${safeName(routeName)}-${randomUUID().slice(0, 8)}`;
40
50
  }
@@ -70,12 +80,22 @@ function resolveDir(workingDir) {
70
80
  * HARNESS_DISPATCH_WORKSPACES_DIR overrides it for anyone who wants the
71
81
  * workspaces on the project's volume.
72
82
  */
73
- /** The directory all per-project workspace roots hang off. */
74
- function workspacesBase() {
83
+ /**
84
+ * The directory all per-project workspace roots hang off.
85
+ *
86
+ * Exported because the apply-time dirty check has to know it too: with the
87
+ * override pointed inside the project — which README recommends, to keep the
88
+ * copy on one volume for reflinks — the workspaces directory is itself an
89
+ * untracked change, so `apply` refused on an otherwise pristine tree, every
90
+ * time. The same "feature blocked by its own leftovers" the recursion guard
91
+ * above was written for, one step further along.
92
+ */
93
+ export function workspacesBase() {
75
94
  return (process.env.HARNESS_DISPATCH_WORKSPACES_DIR ??
76
95
  path.join(os.tmpdir(), "harness-dispatch", "workspaces"));
77
96
  }
78
- function workspaceRootFor(originalWorkingDir) {
97
+ /** Exported for tests, for the same reason as `workspaceRunId`. */
98
+ export function workspaceRootFor(originalWorkingDir) {
79
99
  const base = workspacesBase();
80
100
  // The per-project segment applies to the override too. Without it, every
81
101
  // project pointed at one HARNESS_DISPATCH_WORKSPACES_DIR shared a flat
@@ -88,6 +108,305 @@ function workspaceRootFor(originalWorkingDir) {
88
108
  // basename stays in the name so the directory is still recognisable by eye.
89
109
  return path.join(base, `${safeName(path.basename(originalWorkingDir))}-${pathKey(originalWorkingDir)}`);
90
110
  }
111
+ /**
112
+ * Written into every project root this tool creates, so reclamation can delete
113
+ * a directory because it KNOWS it made it rather than because the name looks
114
+ * about right.
115
+ *
116
+ * The previous attempt matched the generated name shape, `-[0-9a-f]{8}$`. Eight
117
+ * decimal digits are valid hex, so any `<name>-<YYYYMMDD>` collided: a second
118
+ * acceptance pass planted `backup-20260401/data.bin` beside directories that
119
+ * survived and watched one dispatch delete it recursively. A heuristic cannot
120
+ * answer "did I create this" — only a mark can.
121
+ */
122
+ const ROOT_MARKER = ".harness-dispatch-root";
123
+ /**
124
+ * The shape `workspaceRunId` generates: an ISO stamp, pid, route, 8 hex.
125
+ * Used ONLY to recognise roots created before the marker existed, so those are
126
+ * still reclaimed instead of leaking forever.
127
+ */
128
+ const RUN_DIR_RE = /^\d{4}-\d{2}-\d{2}T[\d-]+Z-\d+-.+-[0-9a-f]{8}$/;
129
+ /**
130
+ * Bring an existing workspace root up to 0700, and refuse one we do not own.
131
+ *
132
+ * Two separate problems, both POSIX-only, both found by an acceptance pass
133
+ * measuring the shipped `mode:` fix on real Linux rather than trusting it:
134
+ *
135
+ * ALREADY THERE — `mkdir`'s mode applies only to directories it creates, so
136
+ * an existing 0755 root stayed 0755. Every pre-existing user was
137
+ * unaffected by the "fix". An explicit chmod is the only thing that
138
+ * changes them.
139
+ * SOMEBODY ELSE'S — the root path is fully deterministic
140
+ * (`<tmp>/harness-dispatch/workspaces/<basename>-<hash of path>`) inside a
141
+ * SHARED `os.tmpdir()`, so on the multi-user machine this whole guard
142
+ * exists for, another local user can create it first. Then it is theirs:
143
+ * chmod fails, and copying the project into it would hand them the
144
+ * source. That is not a mode to fix, it is a directory to refuse.
145
+ *
146
+ * Throws on the second case. `markProjectRoot`'s caller treats a marker
147
+ * failure as harmless — correctly, a root without its marker is merely not
148
+ * auto-reclaimed — but "another user owns the directory I am about to copy
149
+ * your code into" is not in that category and must not be swallowed.
150
+ *
151
+ * Windows is skipped deliberately: `uid` is 0 for every process, Node ignores
152
+ * mode, and `os.tmpdir()` is already per-user there.
153
+ */
154
+ /**
155
+ * Build the workspace root one segment at a time, and hand back the path that
156
+ * was actually created.
157
+ *
158
+ * THIS REPLACES A CHECK-THEN-USE GUARD, and the replacement is the point. Four
159
+ * consecutive releases patched a validator that inspected a path STRING and
160
+ * then let the rest of the module re-resolve that same string on every write.
161
+ * Each patch closed the hole it was shown and left the shape intact, so the
162
+ * next pass found another one:
163
+ *
164
+ * - `stat` followed the link it was checking (release 1);
165
+ * - the guard was applied to one of the two isolation policies (release 2);
166
+ * - it inspected only the last path segment (release 3);
167
+ * - it stopped AT `workspacesBase()` and never looked at that directory's
168
+ * own parent — the one the release notes said it now checked;
169
+ * - it compared with `startsWith` against an un-normalised base, so a
170
+ * trailing slash or a `..` in HARNESS_DISPATCH_WORKSPACES_DIR turned the
171
+ * whole guard off silently;
172
+ * - and validating once left every later write re-resolving the string, so
173
+ * swapping a directory for a link DURING the copy redirected it (3,877
174
+ * files landed in an attacker's directory in the reproduction).
175
+ *
176
+ * So the rule is no longer "look at the path and then trust it". Every segment
177
+ * from the anchor down is CREATED BY US with a non-recursive mkdir, which
178
+ * cannot traverse a link we did not make: if something is already there,
179
+ * `mkdir` fails and we inspect it deliberately rather than following it. The
180
+ * verified, fully-resolved directory is then RETURNED, and callers use that
181
+ * value instead of re-deriving the string.
182
+ *
183
+ * The anchor is the directory the user chose (HARNESS_DISPATCH_WORKSPACES_DIR)
184
+ * or the system temp directory. Above it is not ours to police —
185
+ * `os.tmpdir()` is legitimately a symlink on macOS (`/var` -> `/private/var`),
186
+ * which is exactly why the anchor is RESOLVED rather than refused. Everything
187
+ * below it is ours, and a link there is refused.
188
+ *
189
+ * What this still does not give: `mkdir`/`lstat` name a path, not an open
190
+ * handle, so a sufficiently fast swap between two syscalls remains
191
+ * theoretically possible — Node exposes no `openat`/`O_NOFOLLOW`. The window
192
+ * is now one syscall rather than the whole dispatch, and every destructive
193
+ * operation re-verifies (see `assertStillOurs`) instead of trusting a check
194
+ * made minutes earlier.
195
+ */
196
+ async function verifySegment(dir) {
197
+ const info = await lstat(dir);
198
+ // Symlink check on EVERY platform. This was skipped entirely on Windows for
199
+ // a stated reason that covers only the ownership half — uid is 0 there and
200
+ // mode is ignored. Junctions need no privileges, `lstat` reports them as
201
+ // symbolic links, and a junction planted at the workspace path was measured
202
+ // taking a whole project into the victim's directory on this maintainer's
203
+ // own machine.
204
+ if (info.isSymbolicLink()) {
205
+ throw new Error(`${dir} is a symbolic link, and this tool never creates one there. Refusing to use ` +
206
+ `it: following it would put your project — and this tool's recursive cleanup — ` +
207
+ `wherever the link points. Remove it, or set HARNESS_DISPATCH_WORKSPACES_DIR to a ` +
208
+ `location you control.`);
209
+ }
210
+ if (!info.isDirectory()) {
211
+ throw new Error(`${dir} exists and is not a directory. Refusing to use it as a workspace location. ` +
212
+ `Remove it, or set HARNESS_DISPATCH_WORKSPACES_DIR to a location you control.`);
213
+ }
214
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
215
+ if (uid !== undefined && info.uid !== uid) {
216
+ throw new Error(`${dir} is owned by another user (uid ${info.uid}, this process is uid ${uid}). ` +
217
+ `Refusing to put your project beneath it: this path is predictable, so a directory ` +
218
+ `you do not own may have been created there deliberately. Remove it, or set ` +
219
+ `HARNESS_DISPATCH_WORKSPACES_DIR to a location you control.`);
220
+ }
221
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0)
222
+ await chmod(dir, 0o700);
223
+ }
224
+ /**
225
+ * Re-check, immediately before a destructive or bulk operation, that a
226
+ * directory verified earlier is still the one we verified.
227
+ *
228
+ * The old code validated once at the start of a dispatch and then trusted the
229
+ * path string for everything that followed. This is the narrow version of that
230
+ * trust: it costs one `lstat` and it turns "checked minutes ago" into "checked
231
+ * a syscall ago".
232
+ */
233
+ export async function assertStillOurs(dir) {
234
+ await verifySegment(dir);
235
+ }
236
+ /**
237
+ * The anchor, resolved. Above this we do not police; below it we do.
238
+ *
239
+ * `path.resolve` is what fixes the guard being silently inert for a
240
+ * HARNESS_DISPATCH_WORKSPACES_DIR written with a trailing slash or containing
241
+ * `..` — the old comparison was `startsWith` against the raw string, so those
242
+ * spellings failed to match and the loop body never ran even once.
243
+ */
244
+ async function resolvedAnchor() {
245
+ const configured = process.env.HARNESS_DISPATCH_WORKSPACES_DIR;
246
+ const anchor = configured !== undefined ? path.resolve(configured) : os.tmpdir();
247
+ // VERIFY THE NEAREST EXISTING ANCESTOR BEFORE CREATING ANYTHING.
248
+ //
249
+ // Creating the anchor first was still a "touch, then check": with a link at
250
+ // `<tmp>/hd`, a recursive mkdir of `<tmp>/hd/workspaces` traversed it and
251
+ // left an empty directory inside the attacker's tree before the refusal
252
+ // arrived. Small, but it is the same mistake in miniature — and this
253
+ // function exists to stop making it.
254
+ //
255
+ // Walking up to what already exists, resolving THAT, and checking who owns
256
+ // it means the first thing we create is created somewhere we have already
257
+ // vouched for.
258
+ let existing = anchor;
259
+ while (!existsSync(existing)) {
260
+ const parent = path.dirname(existing);
261
+ if (parent === existing)
262
+ break;
263
+ existing = parent;
264
+ }
265
+ // realpath here, not lstat: the anchor is allowed to BE a link, because
266
+ // os.tmpdir() is one on macOS (`/var` -> `/private/var`) and a user pointing
267
+ // the override at a link is making a choice about their own machine. What
268
+ // must hold is that wherever it lands belongs to us.
269
+ const resolvedExisting = await realpath(existing);
270
+ const info = await lstat(resolvedExisting);
271
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
272
+ if (uid !== undefined && info.uid !== uid && info.uid !== 0) {
273
+ throw new Error(`${resolvedExisting} is owned by another user (uid ${info.uid}, this process is uid ` +
274
+ `${uid}), and the workspace location resolves beneath it. Refusing to create ` +
275
+ `anything there. Set HARNESS_DISPATCH_WORKSPACES_DIR to a location you control.`);
276
+ }
277
+ // Segments computed in DECLARED space and created in DECLARED space.
278
+ //
279
+ // Computing them between the resolved ancestor and the declared anchor
280
+ // produced `..` components whenever the two differed — i.e. on every macOS
281
+ // machine — and the loop then walked upwards creating nonsense. The resolve
282
+ // happens once, at the end, after the chain exists.
283
+ const relative = path.relative(existing, path.resolve(anchor));
284
+ let current = existing;
285
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
286
+ current = path.join(current, segment);
287
+ try {
288
+ await mkdir(current, { mode: 0o700 });
289
+ }
290
+ catch (err) {
291
+ if (err?.code !== "EEXIST")
292
+ throw err;
293
+ await verifySegment(current);
294
+ }
295
+ }
296
+ // BOTH values are returned, and the distinction is load-bearing.
297
+ //
298
+ // `declared` is what the rest of the module derives paths from
299
+ // (`workspacesBase()` builds on the unresolved `os.tmpdir()`), and
300
+ // `resolved` is where those paths actually land. Comparing a declared target
301
+ // against the RESOLVED anchor is wrong on macOS, where `os.tmpdir()` is
302
+ // `/var/folders/...` and resolves to `/private/var/folders/...`: every
303
+ // legitimate run then looks like it is outside the anchor and is refused.
304
+ // Caught in a container before CI, by running the macOS path shape
305
+ // deliberately rather than assuming POSIX is POSIX.
306
+ return { declared: path.resolve(anchor), resolved: await realpath(current) };
307
+ }
308
+ /**
309
+ * Create and verify every segment from the anchor down to `root`, and return
310
+ * the verified path.
311
+ */
312
+ export async function prepareVerifiedRoot(root) {
313
+ const anchor = await resolvedAnchor();
314
+ const target = path.resolve(root);
315
+ // Relative to the DECLARED anchor (how the caller built the path), then
316
+ // created beneath the RESOLVED one (where it really lives).
317
+ const relative = path.relative(anchor.declared, target);
318
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
319
+ // Not beneath the anchor at all. Only reachable if the caller derived the
320
+ // path from something other than workspacesBase(); refusing beats
321
+ // silently operating outside the area this function can vouch for.
322
+ throw new Error(`Refusing to use ${target} as a workspace location: it is not beneath ` +
323
+ `${anchor.declared}, so this tool cannot vouch for the path it would write and ` +
324
+ `delete under.`);
325
+ }
326
+ let current = anchor.resolved;
327
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
328
+ current = path.join(current, segment);
329
+ // Non-recursive on purpose: a recursive mkdir traverses whatever is
330
+ // already there, including a link. This creates each level itself, so the
331
+ // only way a link enters the chain is if it existed first — in which case
332
+ // mkdir fails with EEXIST and we inspect it rather than following it.
333
+ try {
334
+ await mkdir(current, { mode: 0o700 });
335
+ }
336
+ catch (err) {
337
+ if (err?.code !== "EEXIST")
338
+ throw err;
339
+ await verifySegment(current);
340
+ continue;
341
+ }
342
+ }
343
+ return current;
344
+ }
345
+ /**
346
+ * Create the project's workspace root and mark it as ours, returning the
347
+ * VERIFIED path that was actually created.
348
+ *
349
+ * Callers must use the returned value rather than the string they passed in.
350
+ * That is the whole change: the old signature returned nothing, so every
351
+ * caller went on using its own copy of the path and re-resolved it on each
352
+ * write — which is how a directory swapped for a symlink mid-copy redirected
353
+ * the rest of it.
354
+ *
355
+ * The MARKER stays best effort: a root without one is merely not reclaimed
356
+ * automatically, which is the safe direction to fail in. Creating and
357
+ * verifying the directory is not, and must not be swallowed.
358
+ */
359
+ async function markProjectRoot(root) {
360
+ const verified = await prepareVerifiedRoot(root);
361
+ try {
362
+ const marker = path.join(verified, ROOT_MARKER);
363
+ if (!existsSync(marker)) {
364
+ await writeFile(marker, "Created by harness-dispatch. This directory and its dated run\n" +
365
+ "subdirectories are managed — and eventually deleted — by it.\n", "utf8");
366
+ }
367
+ }
368
+ catch {
369
+ // A root without its marker is merely not reclaimed automatically, which
370
+ // is the safe direction to fail in.
371
+ }
372
+ return verified;
373
+ }
374
+ /**
375
+ * Is this directory one we created, and therefore ours to delete?
376
+ *
377
+ * The marker settles it. The fallback covers roots created before the marker
378
+ * existed: every child must be a generated run directory, and there must be at
379
+ * least one — an empty unmarked directory is somebody else's empty directory,
380
+ * not ours. A foreign directory passes only if everything inside it happens to
381
+ * be named like a timestamped run, which is not a thing that happens by
382
+ * accident. This deletes recursively and has now been wrong twice, so it takes
383
+ * positive evidence rather than the absence of a reason to stop.
384
+ */
385
+ const PROJECT_ROOT_RE = /^.+-[0-9a-f]{8}$/;
386
+ function isOurProjectRoot(full, children) {
387
+ // The NAME must fit too, not just the marker.
388
+ //
389
+ // The marker is an ordinary file, and anything that can write to the
390
+ // workspaces base can create one — including a delegated agent, when
391
+ // HARNESS_DISPATCH_WORKSPACES_DIR points inside the project, which README
392
+ // recommends and the walkthrough exercises. An acceptance pass used exactly
393
+ // that to get an unrelated directory holding the only copy of its contents
394
+ // recursively deleted.
395
+ //
396
+ // Requiring the generated shape as WELL as the marker does not make this
397
+ // unforgeable — a name is guessable and the marker is writable — but it
398
+ // turns "create a file called .harness-dispatch-root" into "also name the
399
+ // directory the way pathKey would have". Combined with the reclamation only
400
+ // ever running inside our own base, that is the honest limit of what a
401
+ // same-uid check can promise here: the caller and the attacker are the same
402
+ // user, so no permission check can separate them.
403
+ if (!PROJECT_ROOT_RE.test(path.basename(full)))
404
+ return false;
405
+ if (existsSync(path.join(full, ROOT_MARKER)))
406
+ return true;
407
+ const runs = children.filter((name) => name !== ROOT_MARKER);
408
+ return runs.length > 0 && runs.every((name) => RUN_DIR_RE.test(name));
409
+ }
91
410
  /** Short stable digest of a project path, to keep same-named projects apart. */
92
411
  function pathKey(dir) {
93
412
  return createHash("sha256").update(path.resolve(dir)).digest("hex").slice(0, 8);
@@ -121,6 +440,19 @@ function workspaceMaxAgeMs() {
121
440
  */
122
441
  async function pruneStaleCopyWorkspaces(root, copyProjectGitRoot) {
123
442
  const maxAgeMs = workspaceMaxAgeMs();
443
+ // Before the early return below: a project dispatching for the FIRST time
444
+ // has no root of its own to sweep, and that is exactly the caller most
445
+ // likely to be running on a machine full of other projects' leftovers.
446
+ // The base is derived from `root`, NOT from workspacesBase().
447
+ //
448
+ // `root` is now the VERIFIED, fully-resolved path, while `workspacesBase()`
449
+ // returns the DECLARED one. On any machine where the two differ — every
450
+ // macOS box, since `os.tmpdir()` resolves through `/private` — the
451
+ // "exclude our own root" comparison inside this sweep compared strings from
452
+ // two different spaces, failed to match, and deleted the directory the
453
+ // dispatch had just created. Caught in a container running the macOS path
454
+ // shape; it would have been a total failure of `copy` on macOS.
455
+ await pruneAbandonedProjectRoots(path.dirname(root), root);
124
456
  let entries;
125
457
  try {
126
458
  entries = await readdir(root, { withFileTypes: true });
@@ -133,6 +465,18 @@ async function pruneStaleCopyWorkspaces(root, copyProjectGitRoot) {
133
465
  for (const entry of entries) {
134
466
  if (!entry.isDirectory())
135
467
  continue;
468
+ // Only directories WE named. This loop deletes recursively and had no
469
+ // check of any kind — not a name, not a marker, not an owner — while its
470
+ // sibling `pruneAbandonedProjectRoots` has all three under a comment
471
+ // saying "this deletes directories nothing else is watching". With a
472
+ // symlink planted at the root, that gap swept a user's own files.
473
+ //
474
+ // The same guard `pruneStaleJobs` already uses, and for the same reason:
475
+ // a recursive delete takes positive evidence that the thing is ours,
476
+ // rather than the absence of a reason to stop. `RUN_DIR_RE` is the exact
477
+ // shape `workspaceRunId` generates.
478
+ if (!RUN_DIR_RE.test(entry.name))
479
+ continue;
136
480
  const full = path.join(root, entry.name);
137
481
  try {
138
482
  const info = await stat(full);
@@ -161,8 +505,103 @@ async function pruneStaleCopyWorkspaces(root, copyProjectGitRoot) {
161
505
  await git(["worktree", "prune"], copyProjectGitRoot).catch(() => undefined);
162
506
  }
163
507
  }
508
+ /**
509
+ * Reclaim the per-project directories of projects that never dispatch again.
510
+ *
511
+ * The sweep above only ever looks INSIDE one project's root, and only runs
512
+ * when a dispatch happens for that same project. So a project dispatched once
513
+ * and then renamed, deleted, or — most commonly — created as a temp directory
514
+ * by the test suite keeps its stale runs forever: the code that would reclaim
515
+ * them is reachable only by the project that no longer exists.
516
+ *
517
+ * Measured on the maintainer's machine before this existed: 840 project roots,
518
+ * 839 of them still holding run directories five days past a 24-hour
519
+ * retention window. This project has already lost a disk to leaked scratch
520
+ * directories once — tests/setup-env.ts records 2,605 orphans and 0 bytes free
521
+ * on a 931 GB volume — which is why an unbounded leak gets fixed rather than
522
+ * noted.
523
+ *
524
+ * Deliberately conservative, because this deletes directories nothing else is
525
+ * watching:
526
+ * - never the caller's own root, which is about to be written into;
527
+ * - only when EVERY run inside is past retention, so one live run keeps its
528
+ * project root alive;
529
+ * - an empty root is removed only if it carries our marker. Unmarked and
530
+ * empty means there is nothing to identify it by, and an unidentified
531
+ * directory is somebody else's — measured, because this line previously
532
+ * claimed empty roots are removed full stop, which stopped being true when
533
+ * ownership moved from a name shape to a marker;
534
+ * - best effort throughout — a prune failure must never fail a dispatch.
535
+ *
536
+ * A git_worktree root is left alone here. Removing one behind git's back
537
+ * strands `.git/worktrees` metadata, and the sweep above only knows how to
538
+ * unregister worktrees for the repository the CURRENT dispatch belongs to.
539
+ */
540
+ async function pruneAbandonedProjectRoots(base, currentRoot) {
541
+ const maxAgeMs = workspaceMaxAgeMs();
542
+ let entries;
543
+ try {
544
+ entries = await readdir(base, { withFileTypes: true });
545
+ }
546
+ catch {
547
+ return;
548
+ }
549
+ const now = Date.now();
550
+ for (const entry of entries) {
551
+ if (!entry.isDirectory())
552
+ continue;
553
+ const full = path.join(base, entry.name);
554
+ if (path.resolve(full) === path.resolve(currentRoot))
555
+ continue;
556
+ try {
557
+ const runs = await readdir(full, { withFileTypes: true });
558
+ // Age is not evidence of ownership, and this deletes recursively.
559
+ // HARNESS_DISPATCH_WORKSPACES_DIR is a setting the README actively
560
+ // recommends ("on the project's own volume, for instance"), so the base
561
+ // is not necessarily ours alone and anything else living there is
562
+ // somebody's data.
563
+ if (!isOurProjectRoot(full, runs.map((r) => r.name)))
564
+ continue;
565
+ let allStale = true;
566
+ for (const run of runs) {
567
+ if (run.name === ROOT_MARKER)
568
+ continue;
569
+ const runPath = path.join(full, run.name);
570
+ // A worktree run needs git's own removal; leave the whole project
571
+ // root to the owning repository rather than stranding metadata.
572
+ if (existsSync(path.join(runPath, "worktree"))) {
573
+ allStale = false;
574
+ break;
575
+ }
576
+ const info = await stat(runPath);
577
+ if (now - info.mtimeMs <= maxAgeMs) {
578
+ allStale = false;
579
+ break;
580
+ }
581
+ }
582
+ if (allStale)
583
+ await rm(full, { recursive: true, force: true });
584
+ }
585
+ catch {
586
+ // best effort — locked, vanished, or permission-denied entries are skipped
587
+ }
588
+ }
589
+ }
164
590
  async function pruneStaleGitWorktrees(gitRoot, root) {
165
591
  const maxAgeMs = workspaceMaxAgeMs();
592
+ // Same reclamation the copy path does, for a machine that only ever
593
+ // dispatches under git_worktree. Abandoned worktree roots are left alone by
594
+ // that sweep either way — see pruneAbandonedProjectRoots.
595
+ // The base is derived from `root`, NOT from workspacesBase().
596
+ //
597
+ // `root` is now the VERIFIED, fully-resolved path, while `workspacesBase()`
598
+ // returns the DECLARED one. On any machine where the two differ — every
599
+ // macOS box, since `os.tmpdir()` resolves through `/private` — the
600
+ // "exclude our own root" comparison inside this sweep compared strings from
601
+ // two different spaces, failed to match, and deleted the directory the
602
+ // dispatch had just created. Caught in a container running the macOS path
603
+ // shape; it would have been a total failure of `copy` on macOS.
604
+ await pruneAbandonedProjectRoots(path.dirname(root), root);
166
605
  let entries;
167
606
  try {
168
607
  entries = await readdir(root, { withFileTypes: true });
@@ -175,6 +614,11 @@ async function pruneStaleGitWorktrees(gitRoot, root) {
175
614
  for (const entry of entries) {
176
615
  if (!entry.isDirectory())
177
616
  continue;
617
+ // Only directories WE named — the same guard the copy sweep and
618
+ // pruneStaleJobs use. This loop `rm -rf`s recursively and had no name,
619
+ // marker or ownership check of any kind.
620
+ if (!RUN_DIR_RE.test(entry.name))
621
+ continue;
178
622
  const workspaceRoot = path.join(root, entry.name);
179
623
  try {
180
624
  const info = await stat(workspaceRoot);
@@ -227,7 +671,7 @@ function shouldExclude(relPath, direntName) {
227
671
  * was spelled, and it covers the whole workspaces root rather than this run's
228
672
  * directory alone — a sibling run's workspace is no more copyable than our own.
229
673
  */
230
- function isUnderOrEqual(candidate, root) {
674
+ export function isUnderOrEqual(candidate, root) {
231
675
  const c = path.resolve(candidate);
232
676
  const r = path.resolve(root);
233
677
  if (c === r)
@@ -279,7 +723,18 @@ async function copyLink(sourceRoot, destRoot, childRel, skipped) {
279
723
  // missing in-tree link is a far smaller problem than an escaping one.
280
724
  }
281
725
  }
282
- async function copyTree(sourceRoot, destRoot, rel = "", skipped = [], vanished = [], excludeRoots = []) {
726
+ async function copyTree(sourceRoot, destRoot, rel = "", skipped = [], vanished = [], excludeRoots = [],
727
+ /**
728
+ * EXCLUDED_DIRS entries that actually existed and were left out. Collected
729
+ * because the omission was invisible: `bin`, `dist`, `build`, `target`,
730
+ * `obj` and `.venv` are all on that list and all plausible SOURCE
731
+ * directories, and an acceptance pass watched a delegate "edit" a committed
732
+ * `bin/tool.sh` that was never in its workspace — the run then reported one
733
+ * changed file, the patch held one file, and apply landed one file, with
734
+ * nothing anywhere saying the rest of the tree had been withheld. The agent
735
+ * also reasons from an incomplete tree, which is the worse half.
736
+ */
737
+ excludedDirs = []) {
283
738
  const sourceDir = rel ? path.join(sourceRoot, rel) : sourceRoot;
284
739
  const destDir = rel ? path.join(destRoot, rel) : destRoot;
285
740
  await mkdir(destDir, { recursive: true });
@@ -288,12 +743,20 @@ async function copyTree(sourceRoot, destRoot, rel = "", skipped = [], vanished =
288
743
  const childRel = rel ? path.join(rel, entry.name) : entry.name;
289
744
  try {
290
745
  if (entry.isDirectory()) {
291
- if (shouldExclude(childRel, entry.name))
746
+ if (shouldExclude(childRel, entry.name)) {
747
+ // Only the name-list exclusions are reported. The workspaces-root
748
+ // ones below are this tool's own scratch space and mean nothing to
749
+ // the user; `.git` is excluded on every branch and would be noise on
750
+ // every single run.
751
+ if (EXCLUDED_DIRS.has(entry.name) && entry.name !== ".git") {
752
+ excludedDirs.push(childRel.split(path.sep).join("/"));
753
+ }
292
754
  continue;
755
+ }
293
756
  const childAbs = path.join(sourceDir, entry.name);
294
757
  if (excludeRoots.some((root) => isUnderOrEqual(childAbs, root)))
295
758
  continue;
296
- await copyTree(sourceRoot, destRoot, childRel, skipped, vanished, excludeRoots);
759
+ await copyTree(sourceRoot, destRoot, childRel, skipped, vanished, excludeRoots, excludedDirs);
297
760
  continue;
298
761
  }
299
762
  if (entry.isFile()) {
@@ -498,18 +961,34 @@ async function prepareCopyWorkspace(routeName, workingDir, files) {
498
961
  const projectGitRoot = await git(["rev-parse", "--show-toplevel"], originalWorkingDir)
499
962
  .then((out) => out || undefined)
500
963
  .catch(() => undefined);
501
- await pruneStaleCopyWorkspaces(root, projectGitRoot);
502
- const workspaceRoot = path.join(root, workspaceRunId(routeName));
964
+ // SECURE BEFORE PRUNING. The order was the other way round, and the guard
965
+ // could not protect the one operation that deletes: `pruneStaleCopyWorkspaces`
966
+ // ran first and `rm -rf`d every aged subdirectory of `root` — a root the
967
+ // guard had not yet looked at. With a symlink planted at that path, the
968
+ // victim's own directory was swept. Reproduced end to end.
969
+ //
970
+ // `markProjectRoot` is what creates and secures the root, so calling it
971
+ // first means the prune can only ever run against a directory that exists,
972
+ // is not a link, and belongs to this user.
973
+ // Everything below uses the VERIFIED path, not the string computed above.
974
+ // Re-deriving the string is what let a swapped directory redirect the copy.
975
+ const verifiedRoot = await markProjectRoot(root);
976
+ await pruneStaleCopyWorkspaces(verifiedRoot, projectGitRoot);
977
+ const workspaceRoot = path.join(verifiedRoot, workspaceRunId(routeName));
978
+ // Created here, and verified, rather than left to copyTree's recursive
979
+ // mkdir — the run directory is the segment an attacker would swap between
980
+ // the prune and the copy.
981
+ await mkdir(workspaceRoot, { recursive: false, mode: 0o700 });
982
+ await assertStillOurs(workspaceRoot);
503
983
  const effectiveWorkingDir = path.join(workspaceRoot, "workspace");
504
984
  const skippedLinks = [];
505
985
  const vanishedFiles = [];
986
+ const excludedDirs = [];
506
987
  // The whole workspaces BASE, not this run's directory and not even this
507
988
  // project's root under it. A sibling run's workspace is no more copyable
508
989
  // than our own, and another project's is no more copyable than a sibling's —
509
990
  // all of them sit inside the source tree whenever the override points there.
510
- await copyTree(originalWorkingDir, effectiveWorkingDir, "", skippedLinks, vanishedFiles, [
511
- workspacesBase(),
512
- ]);
991
+ await copyTree(originalWorkingDir, effectiveWorkingDir, "", skippedLinks, vanishedFiles, [workspacesBase()], excludedDirs);
513
992
  const before = await fingerprintTree(effectiveWorkingDir);
514
993
  return {
515
994
  policy: "copy",
@@ -542,6 +1021,16 @@ async function prepareCopyWorkspace(routeName, workingDir, files) {
542
1021
  `directory that was being written to.`,
543
1022
  ]
544
1023
  : []),
1024
+ ...(excludedDirs.length > 0
1025
+ ? [
1026
+ `${excludedDirs.length} director(ies) were NOT copied into the workspace and were ` +
1027
+ `invisible to the agent: ${excludedDirs.slice(0, 8).join(", ")}` +
1028
+ `${excludedDirs.length > 8 ? ", …" : ""}. These names are excluded as build ` +
1029
+ `output or dependencies, but some of them (bin, dist, build, target, obj) are ` +
1030
+ `real source directories in some projects — if the task needed one, the agent ` +
1031
+ `worked from an incomplete tree and no change to it can appear in the patch.`,
1032
+ ]
1033
+ : []),
545
1034
  ...(skippedLinks.length > 0
546
1035
  ? [
547
1036
  `Dropped ${skippedLinks.length} symlink(s) pointing outside the workspace, which would ` +
@@ -554,20 +1043,71 @@ async function prepareCopyWorkspace(routeName, workingDir, files) {
554
1043
  },
555
1044
  };
556
1045
  }
1046
+ /**
1047
+ * Decline git's OPTIONAL locks.
1048
+ *
1049
+ * git runs background maintenance, which creates and removes
1050
+ * `.git/objects/maintenance.lock` underneath whatever else is reading the
1051
+ * repository. A CI leg failed with `stat '.../maintenance.lock': No such file
1052
+ * or directory` from an ordinary diff — the lock vanished mid-command. We only
1053
+ * ever ask git to read, or to apply a patch we already hold, so declining
1054
+ * optional locks costs nothing and removes a race we do not control.
1055
+ */
1056
+ export const GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
557
1057
  async function git(args, cwd) {
558
- const { stdout } = await execFile("git", args, { cwd, windowsHide: true });
1058
+ const { stdout } = await execFile("git", args, { cwd, windowsHide: true, env: GIT_ENV });
559
1059
  return String(stdout).trim();
560
1060
  }
561
1061
  async function prepareGitWorktreeWorkspace(routeName, workingDir, files) {
562
1062
  const originalWorkingDir = resolveDir(workingDir);
563
- const gitRoot = await git(["rev-parse", "--show-toplevel"], originalWorkingDir);
1063
+ // Preconditions answered as themselves, not as whatever git printed.
1064
+ //
1065
+ // The resolve path (`workspace diff`/`apply`) explains a missing git and a
1066
+ // long path; the DISPATCH path had none of it, so the three ordinary ways
1067
+ // this cannot start reached the caller as raw git internals with no route
1068
+ // taken and no mention of the alternative — an acceptance pass measured
1069
+ // `spawn git ENOENT`, `fatal: not a git repository`, and
1070
+ // `fatal: ambiguous argument 'HEAD'` on a freshly-initialised project, which
1071
+ // is an ordinary state rather than an error.
1072
+ const gitRoot = await git(["rev-parse", "--show-toplevel"], originalWorkingDir).catch((err) => {
1073
+ if (err?.code === "ENOENT") {
1074
+ throw new Error("workspace_policy: git_worktree needs git on PATH, and it was not found. Install " +
1075
+ "git, or use workspace_policy: copy, which needs no git. `doctor` reports whether " +
1076
+ "it found one.");
1077
+ }
1078
+ throw new Error(`workspace_policy: git_worktree needs ${originalWorkingDir} to be inside a git ` +
1079
+ `repository, and it is not. Use workspace_policy: copy for a directory that is not ` +
1080
+ `version-controlled.`);
1081
+ });
564
1082
  const prefix = await git(["rev-parse", "--show-prefix"], originalWorkingDir);
565
1083
  const gitWorkspaceRoot = gitWorkspaceRootFor(gitRoot);
566
- await pruneStaleGitWorktrees(gitRoot, gitWorkspaceRoot);
567
- const workspaceRoot = path.join(gitWorkspaceRoot, workspaceRunId(routeName));
1084
+ // SECURE BEFORE PRUNING, exactly as the copy path does.
1085
+ //
1086
+ // This ordering fix and the name check inside the sweep were applied to
1087
+ // `copy` only, so the identical attack still worked here: same predictable
1088
+ // path, same delete-before-validate. Reproduced as two real users — with a
1089
+ // symlink planted at the root, the copy policy refused and the worktree
1090
+ // policy DELETED the victim's directory, then raised the guard's error
1091
+ // afterwards. The release that fixed `copy` claimed the class was closed.
1092
+ //
1093
+ // Two policies, one hazard: whenever one of these gets a guard, check the
1094
+ // other in the same edit.
1095
+ // Same as the copy path: use the VERIFIED root from here down.
1096
+ const verifiedGitRoot = await markProjectRoot(gitWorkspaceRoot);
1097
+ await pruneStaleGitWorktrees(gitRoot, verifiedGitRoot);
1098
+ const workspaceRoot = path.join(verifiedGitRoot, workspaceRunId(routeName));
568
1099
  const worktreeRoot = path.join(workspaceRoot, "worktree");
569
- await mkdir(workspaceRoot, { recursive: true });
570
- const baseCommit = await git(["rev-parse", "HEAD"], gitRoot);
1100
+ // Non-recursive, then verified: this run's directory holds the worktree
1101
+ // checkout, i.e. the project's source, and it is the segment an attacker
1102
+ // would swap between the prune and `git worktree add`.
1103
+ await mkdir(workspaceRoot, { recursive: false, mode: 0o700 });
1104
+ await assertStillOurs(workspaceRoot);
1105
+ // A repository with no commits yet is an ordinary state, not a fault, and
1106
+ // `git worktree add` has nothing to branch from in it.
1107
+ const baseCommit = await git(["rev-parse", "HEAD"], gitRoot).catch(() => {
1108
+ throw new Error(`workspace_policy: git_worktree needs at least one commit to branch a worktree from, ` +
1109
+ `and ${gitRoot} has none yet. Make an initial commit, or use workspace_policy: copy.`);
1110
+ });
571
1111
  await git(["worktree", "add", "--detach", worktreeRoot, baseCommit], gitRoot);
572
1112
  const effectiveWorkingDir = prefix ? path.join(worktreeRoot, prefix) : worktreeRoot;
573
1113
  await stat(effectiveWorkingDir);
@@ -582,6 +1122,55 @@ async function prepareGitWorktreeWorkspace(routeName, workingDir, files) {
582
1122
  async finish(result) {
583
1123
  const after = await fingerprintTree(worktreeRoot);
584
1124
  const changedFiles = diffFingerprints(before, after);
1125
+ // A failed attempt that changed nothing leaves nothing to inspect, and
1126
+ // its worktree is a registration inside the USER's repository that
1127
+ // retention will never reclaim — the sweep deliberately refuses to
1128
+ // remove worktrees, because unregistering one needs git and only the
1129
+ // owning repo can do it.
1130
+ //
1131
+ // So they accumulate per attempt, and the ones nobody knows about are
1132
+ // the worst: a fallback arm that fails is not named in the response at
1133
+ // all, so its worktree has no cleanupHint anywhere. An acceptance pass
1134
+ // measured one HTTP request leaving TWO entries in `git worktree list`.
1135
+ // This project has already paid for one unbounded directory leak.
1136
+ //
1137
+ // Only when the attempt both failed AND changed nothing. A failure that
1138
+ // wrote files may still hold work worth recovering, and deleting that
1139
+ // to tidy up would be the trade this codebase keeps refusing.
1140
+ if (!result.success && changedFiles.length === 0) {
1141
+ // The directory goes only if GIT let go of it first.
1142
+ //
1143
+ // The first version swallowed the result of `git worktree remove`,
1144
+ // deleted the directory regardless, and reported "unregistered and
1145
+ // removed" either way. When git fails — an index lock, a concurrent
1146
+ // git operation — that strands `.git/worktrees/<name>` inside the
1147
+ // user's repository, which is the exact outcome the comment above
1148
+ // says this refuses to cause, while telling them it did not happen.
1149
+ // An acceptance pass caught it by reading, inside the fix that
1150
+ // introduced it.
1151
+ const unregistered = await git(["worktree", "remove", "--force", worktreeRoot], gitRoot)
1152
+ .then(() => true)
1153
+ .catch(() => false);
1154
+ if (unregistered) {
1155
+ await rm(workspaceRoot, { recursive: true, force: true }).catch(() => undefined);
1156
+ return attachWorkspace(result, {
1157
+ policy: "git_worktree",
1158
+ originalWorkingDir,
1159
+ effectiveWorkingDir,
1160
+ baseCommit,
1161
+ isolated: true,
1162
+ securityBoundary: "project_state_and_process_cwd",
1163
+ changedFiles,
1164
+ diffSummary: diffSummary(changedFiles),
1165
+ notes: [
1166
+ "This attempt failed without changing any file, so its git worktree was " +
1167
+ "unregistered and removed rather than left in your repository.",
1168
+ ],
1169
+ });
1170
+ }
1171
+ // Fall through: git still owns it, so it is reported like any other
1172
+ // retained worktree, with the hint that names how to remove it.
1173
+ }
585
1174
  return attachWorkspace(result, {
586
1175
  policy: "git_worktree",
587
1176
  originalWorkingDir,