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.
- package/CHANGELOG.md +1170 -1
- package/README.md +52 -6
- package/config.default.yaml +49 -5
- package/dist/auth.d.ts +29 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +58 -5
- package/dist/auth.js.map +1 -1
- package/dist/billing.d.ts +24 -0
- package/dist/billing.d.ts.map +1 -1
- package/dist/billing.js +24 -0
- package/dist/billing.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +529 -31
- package/dist/bin.js.map +1 -1
- package/dist/breaker-store.d.ts +25 -1
- package/dist/breaker-store.d.ts.map +1 -1
- package/dist/breaker-store.js +193 -25
- package/dist/breaker-store.js.map +1 -1
- package/dist/circuit-breaker.d.ts +9 -1
- package/dist/circuit-breaker.d.ts.map +1 -1
- package/dist/circuit-breaker.js +15 -1
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/client-register.d.ts +157 -0
- package/dist/client-register.d.ts.map +1 -0
- package/dist/client-register.js +389 -0
- package/dist/client-register.js.map +1 -0
- package/dist/config/validation.d.ts +41 -0
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +215 -1
- package/dist/config/validation.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +143 -6
- package/dist/config.js.map +1 -1
- package/dist/configure-yaml.d.ts +2 -0
- package/dist/configure-yaml.d.ts.map +1 -1
- package/dist/configure-yaml.js +69 -3
- package/dist/configure-yaml.js.map +1 -1
- package/dist/dispatch-log.d.ts +14 -0
- package/dist/dispatch-log.d.ts.map +1 -1
- package/dist/dispatch-log.js +3 -0
- package/dist/dispatch-log.js.map +1 -1
- package/dist/dispatchers/generic-cli.d.ts.map +1 -1
- package/dist/dispatchers/generic-cli.js +92 -15
- package/dist/dispatchers/generic-cli.js.map +1 -1
- package/dist/dispatchers/openai-compatible.d.ts +20 -4
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
- package/dist/dispatchers/openai-compatible.js +167 -19
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/harness-login.d.ts +25 -0
- package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
- package/dist/dispatchers/shared/harness-login.js +68 -0
- package/dist/dispatchers/shared/harness-login.js.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +70 -4
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
- package/dist/file-lock.d.ts +29 -1
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +22 -1
- package/dist/file-lock.js.map +1 -1
- package/dist/http/answer-stream.d.ts +52 -0
- package/dist/http/answer-stream.d.ts.map +1 -0
- package/dist/http/answer-stream.js +47 -0
- package/dist/http/answer-stream.js.map +1 -0
- package/dist/http/parse.d.ts.map +1 -1
- package/dist/http/parse.js +41 -0
- package/dist/http/parse.js.map +1 -1
- package/dist/http/server.d.ts +2 -0
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +281 -23
- package/dist/http/server.js.map +1 -1
- package/dist/jobs/context.d.ts +0 -7
- package/dist/jobs/context.d.ts.map +1 -1
- package/dist/jobs/context.js +95 -7
- package/dist/jobs/context.js.map +1 -1
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +45 -4
- package/dist/jobs/store.js.map +1 -1
- package/dist/jobs/types.d.ts +8 -1
- package/dist/jobs/types.d.ts.map +1 -1
- package/dist/jobs.d.ts +39 -0
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +207 -15
- package/dist/jobs.js.map +1 -1
- package/dist/leaderboard.d.ts.map +1 -1
- package/dist/leaderboard.js +39 -27
- package/dist/leaderboard.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts +14 -3
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +33 -5
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
- package/dist/mcp/dispatcher-factory.js +14 -3
- package/dist/mcp/dispatcher-factory.js.map +1 -1
- package/dist/mcp/near-miss-guard.d.ts +45 -0
- package/dist/mcp/near-miss-guard.d.ts.map +1 -0
- package/dist/mcp/near-miss-guard.js +98 -0
- package/dist/mcp/near-miss-guard.js.map +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +36 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-schemas.d.ts +1 -1
- package/dist/mcp/tool-schemas.d.ts.map +1 -1
- package/dist/mcp/tool-schemas.js +27 -7
- package/dist/mcp/tool-schemas.js.map +1 -1
- package/dist/mcp/tools.d.ts +33 -4
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +85 -8
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp-clients.d.ts +72 -0
- package/dist/mcp-clients.d.ts.map +1 -0
- package/dist/mcp-clients.js +121 -0
- package/dist/mcp-clients.js.map +1 -0
- package/dist/near-miss.d.ts +63 -0
- package/dist/near-miss.d.ts.map +1 -0
- package/dist/near-miss.js +132 -0
- package/dist/near-miss.js.map +1 -0
- package/dist/quota.d.ts +31 -1
- package/dist/quota.d.ts.map +1 -1
- package/dist/quota.js +84 -8
- package/dist/quota.js.map +1 -1
- package/dist/route-policy.d.ts +12 -1
- package/dist/route-policy.d.ts.map +1 -1
- package/dist/route-policy.js +74 -2
- package/dist/route-policy.js.map +1 -1
- package/dist/router.d.ts +61 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +160 -29
- package/dist/router.js.map +1 -1
- package/dist/safety.d.ts.map +1 -1
- package/dist/safety.js +37 -1
- package/dist/safety.js.map +1 -1
- package/dist/state-dir.d.ts +10 -0
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +12 -0
- package/dist/state-dir.js.map +1 -1
- package/dist/status.d.ts +30 -21
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +152 -8
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +49 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/working-dir.d.ts.map +1 -1
- package/dist/working-dir.js +16 -0
- package/dist/working-dir.js.map +1 -1
- package/dist/workspace-resolve.d.ts.map +1 -1
- package/dist/workspace-resolve.js +196 -15
- package/dist/workspace-resolve.js.map +1 -1
- package/dist/workspaces.d.ts +98 -0
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +609 -20
- package/dist/workspaces.js.map +1 -1
- package/package.json +1 -1
package/dist/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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,
|