@bermudi/pi-delegate 0.1.2 → 0.1.4
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/README.md +27 -1
- package/concurrency.ts +20 -3
- package/config.ts +62 -1
- package/delegate.ts +4 -0
- package/dispatch.ts +94 -15
- package/extension.ts +292 -61
- package/format.ts +5 -1
- package/host-compat.ts +1 -0
- package/lifecycle.ts +235 -25
- package/manual.ts +3 -2
- package/package.json +1 -1
- package/schema.ts +16 -2
- package/task-resolution.ts +6 -0
- package/telemetry.ts +743 -0
- package/tickets.ts +34 -10
- package/types.ts +22 -0
- package/usage.ts +19 -0
- package/workspace.ts +672 -0
package/workspace.ts
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { scheduleDeadline } from "./timer.ts";
|
|
5
|
+
|
|
6
|
+
const SCRATCH_PREFIX = ".pi-delegate-scratch-";
|
|
7
|
+
const SCRATCH_TREE_NAME = "project";
|
|
8
|
+
const SCRATCH_OWNER_NAME = ".owner";
|
|
9
|
+
const COPY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export interface ScratchWorkspace {
|
|
12
|
+
/** Canonical project root copied into the scratch directory. */
|
|
13
|
+
sourceRoot: string;
|
|
14
|
+
/** Canonical cwd requested by the caller. */
|
|
15
|
+
sourceCwd: string;
|
|
16
|
+
/** Root of the disposable reflink copy. */
|
|
17
|
+
scratchRoot: string;
|
|
18
|
+
/** sourceCwd translated into scratchRoot. */
|
|
19
|
+
cwd: string;
|
|
20
|
+
mapPathToSource(candidate: string): string;
|
|
21
|
+
/** Resolve a reported path physically, mapping disposable paths back to the
|
|
22
|
+
* source tree and preserving external host paths after cleanup. */
|
|
23
|
+
resolveReportedPath(candidate: string): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve an explicitly attributed path. Disposable paths return undefined;
|
|
26
|
+
* external paths are returned in physical (realpath) form so symlink aliases
|
|
27
|
+
* compare with the host path they actually touched.
|
|
28
|
+
*/
|
|
29
|
+
resolveAttributedPath(candidate: string): Promise<string | undefined>;
|
|
30
|
+
/** True when the existing path resolves inside the disposable tree. */
|
|
31
|
+
isDisposablePath(candidate: string): Promise<boolean>;
|
|
32
|
+
cleanup(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ScratchSetupError extends Error {}
|
|
36
|
+
|
|
37
|
+
export class ScratchDeadlineError extends ScratchSetupError {}
|
|
38
|
+
|
|
39
|
+
class CommandError extends Error {
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
readonly stderr: string,
|
|
43
|
+
options: ErrorOptions,
|
|
44
|
+
) {
|
|
45
|
+
super(message, options);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runFile(
|
|
50
|
+
file: string,
|
|
51
|
+
args: string[],
|
|
52
|
+
options: { cwd?: string; signal?: AbortSignal; timeout?: number } = {},
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
execFile(
|
|
56
|
+
file,
|
|
57
|
+
args,
|
|
58
|
+
{
|
|
59
|
+
cwd: options.cwd,
|
|
60
|
+
signal: options.signal,
|
|
61
|
+
timeout: options.timeout,
|
|
62
|
+
maxBuffer: 1024 * 1024,
|
|
63
|
+
},
|
|
64
|
+
(error, stdout, stderr) => {
|
|
65
|
+
if (error) {
|
|
66
|
+
const detail = stderr.trim();
|
|
67
|
+
reject(
|
|
68
|
+
new CommandError(
|
|
69
|
+
detail ? `${file}: ${detail}` : `${file}: ${error.message}`,
|
|
70
|
+
detail,
|
|
71
|
+
{ cause: error },
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
resolve(stdout);
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function findCopyRoot(cwd: string, signal: AbortSignal): Promise<string> {
|
|
83
|
+
try {
|
|
84
|
+
const root = (
|
|
85
|
+
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
86
|
+
cwd,
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
signal,
|
|
89
|
+
})
|
|
90
|
+
).trim();
|
|
91
|
+
if (!root) {
|
|
92
|
+
throw new ScratchSetupError("Git returned an empty repository root.");
|
|
93
|
+
}
|
|
94
|
+
return await fs.promises.realpath(root);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Only Git's explicit "not a repository" result permits treating cwd as a
|
|
97
|
+
// plain directory. Missing Git, timeouts, dubious ownership, malformed
|
|
98
|
+
// metadata, and every other failure stop scratch creation: falling back
|
|
99
|
+
// could leave an ancestor repository or linked-worktree metadata reachable.
|
|
100
|
+
if (
|
|
101
|
+
error instanceof CommandError &&
|
|
102
|
+
/not a git repository/i.test(error.stderr)
|
|
103
|
+
) {
|
|
104
|
+
return cwd;
|
|
105
|
+
}
|
|
106
|
+
throw new ScratchSetupError(
|
|
107
|
+
"Could not safely determine the scratch project root.",
|
|
108
|
+
{
|
|
109
|
+
cause: error,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function throwIfSetupCancelled(
|
|
116
|
+
signal: AbortSignal,
|
|
117
|
+
parentSignal: AbortSignal | undefined,
|
|
118
|
+
): void {
|
|
119
|
+
if (!signal.aborted) return;
|
|
120
|
+
if (parentSignal?.aborted) {
|
|
121
|
+
throw new Error("Scratch workspace creation was aborted.");
|
|
122
|
+
}
|
|
123
|
+
throw new ScratchDeadlineError(
|
|
124
|
+
"Scratch workspace creation exceeded the task deadline.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Validate the completed copy before any subagent receives its path. */
|
|
129
|
+
async function validateCopiedTree(
|
|
130
|
+
root: string,
|
|
131
|
+
signal: AbortSignal,
|
|
132
|
+
parentSignal: AbortSignal | undefined,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const pending = [root];
|
|
135
|
+
while (pending.length) {
|
|
136
|
+
throwIfSetupCancelled(signal, parentSignal);
|
|
137
|
+
const directory = pending.pop()!;
|
|
138
|
+
for (const entry of await fs.promises.readdir(directory, {
|
|
139
|
+
withFileTypes: true,
|
|
140
|
+
})) {
|
|
141
|
+
throwIfSetupCancelled(signal, parentSignal);
|
|
142
|
+
const candidate = path.join(directory, entry.name);
|
|
143
|
+
// The root repository is validated below. A non-directory .git entry can
|
|
144
|
+
// redirect metadata outside the copy. Nested repositories are rejected as
|
|
145
|
+
// unsupported because their own config, alternates, and worktree settings
|
|
146
|
+
// would each need the same independent validation as the root repository.
|
|
147
|
+
if (entry.name === ".git") {
|
|
148
|
+
if (!entry.isDirectory()) {
|
|
149
|
+
throw new ScratchSetupError(
|
|
150
|
+
`Scratch workspace cannot safely copy linked Git metadata at '${path.relative(root, candidate)}'.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (directory !== root) {
|
|
154
|
+
throw new ScratchSetupError(
|
|
155
|
+
`Scratch workspace does not support nested Git repositories at '${path.relative(root, candidate)}'.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (entry.isDirectory()) {
|
|
160
|
+
pending.push(candidate);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!entry.isSymbolicLink()) continue;
|
|
164
|
+
const target = await fs.promises.readlink(candidate);
|
|
165
|
+
const resolvedTarget = path.resolve(path.dirname(candidate), target);
|
|
166
|
+
if (path.isAbsolute(target) || !isWithin(root, resolvedTarget)) {
|
|
167
|
+
throw new ScratchSetupError(
|
|
168
|
+
`Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}' because it points outside the project.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
176
|
+
const relative = path.relative(root, candidate);
|
|
177
|
+
return (
|
|
178
|
+
relative === "" ||
|
|
179
|
+
(relative !== ".." &&
|
|
180
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
181
|
+
!path.isAbsolute(relative))
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isProcessAlive(pid: number): boolean {
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
return true;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return !(
|
|
191
|
+
error instanceof Error &&
|
|
192
|
+
"code" in error &&
|
|
193
|
+
error.code === "ESRCH"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Remove leases left behind by a process that is no longer running.
|
|
199
|
+
*
|
|
200
|
+
* The owner marker distinguishes our leases from unrelated prefix-matching
|
|
201
|
+
* directories. Live owners are never touched. The final removal still goes
|
|
202
|
+
* through opened descriptors and a non-recursive rmdir, so a replacement or
|
|
203
|
+
* active workspace fails closed.
|
|
204
|
+
*/
|
|
205
|
+
async function sweepStaleScratchLeases(parent: string): Promise<void> {
|
|
206
|
+
const uid = process.getuid?.();
|
|
207
|
+
if (uid === undefined) return;
|
|
208
|
+
|
|
209
|
+
let entries: fs.Dirent[];
|
|
210
|
+
try {
|
|
211
|
+
entries = await fs.promises.readdir(parent, { withFileTypes: true });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error("[delegate] scratch lease sweep failed", error);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (!entry.name.startsWith(SCRATCH_PREFIX) || !entry.isDirectory()) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const leaseRoot = path.join(parent, entry.name);
|
|
222
|
+
try {
|
|
223
|
+
const leaseStat = await fs.promises.lstat(leaseRoot);
|
|
224
|
+
if (!leaseStat.isDirectory() || leaseStat.uid !== uid) continue;
|
|
225
|
+
const contents = await fs.promises.readdir(leaseRoot);
|
|
226
|
+
if (!contents.includes(SCRATCH_OWNER_NAME)) {
|
|
227
|
+
// Empty leases from versions without an owner marker are still safe
|
|
228
|
+
// to reclaim; anything else may be an unrelated directory.
|
|
229
|
+
if (contents.length === 0) await fs.promises.rmdir(leaseRoot);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const ownerPath = path.join(leaseRoot, SCRATCH_OWNER_NAME);
|
|
233
|
+
const ownerStat = await fs.promises.lstat(ownerPath);
|
|
234
|
+
if (
|
|
235
|
+
!ownerStat.isFile() ||
|
|
236
|
+
ownerStat.uid !== uid ||
|
|
237
|
+
ownerStat.mode & 0o077
|
|
238
|
+
) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const pid = Number.parseInt(
|
|
242
|
+
(await fs.promises.readFile(ownerPath, "utf8")).trim(),
|
|
243
|
+
10,
|
|
244
|
+
);
|
|
245
|
+
if (!Number.isSafeInteger(pid) || isProcessAlive(pid)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const projectRoot = path.join(leaseRoot, SCRATCH_TREE_NAME);
|
|
250
|
+
if (
|
|
251
|
+
contents.some(
|
|
252
|
+
(name) => name !== SCRATCH_OWNER_NAME && name !== SCRATCH_TREE_NAME,
|
|
253
|
+
)
|
|
254
|
+
) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
let projectStat: fs.Stats | undefined;
|
|
258
|
+
try {
|
|
259
|
+
projectStat = await fs.promises.lstat(projectRoot);
|
|
260
|
+
if (!projectStat.isDirectory()) continue;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!(
|
|
263
|
+
error instanceof Error &&
|
|
264
|
+
"code" in error &&
|
|
265
|
+
error.code === "ENOENT"
|
|
266
|
+
)) {
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Open the parent and lease before removing anything. This repeats the
|
|
272
|
+
// same identity checks as normal cleanup against the directory found by
|
|
273
|
+
// the initial scan, rather than trusting a pathname that may be replaced.
|
|
274
|
+
const parentHandle = await fs.promises.open(
|
|
275
|
+
parent,
|
|
276
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
277
|
+
);
|
|
278
|
+
let leaseHandle: Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
279
|
+
let projectHandle:
|
|
280
|
+
Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
281
|
+
try {
|
|
282
|
+
leaseHandle = await fs.promises.open(
|
|
283
|
+
path.join(`/proc/self/fd/${parentHandle.fd}`, entry.name),
|
|
284
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
285
|
+
);
|
|
286
|
+
const currentLeaseStat = await fs.promises.lstat(leaseRoot);
|
|
287
|
+
const openLeaseStat = await leaseHandle.stat();
|
|
288
|
+
if (
|
|
289
|
+
currentLeaseStat.dev !== leaseStat.dev ||
|
|
290
|
+
currentLeaseStat.ino !== leaseStat.ino ||
|
|
291
|
+
openLeaseStat.dev !== leaseStat.dev ||
|
|
292
|
+
openLeaseStat.ino !== leaseStat.ino
|
|
293
|
+
) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const currentOwnerPath = path.join(
|
|
297
|
+
`/proc/self/fd/${leaseHandle.fd}`,
|
|
298
|
+
SCRATCH_OWNER_NAME,
|
|
299
|
+
);
|
|
300
|
+
const currentOwnerStat = await fs.promises.lstat(currentOwnerPath);
|
|
301
|
+
const currentPid = Number.parseInt(
|
|
302
|
+
(await fs.promises.readFile(currentOwnerPath, "utf8")).trim(),
|
|
303
|
+
10,
|
|
304
|
+
);
|
|
305
|
+
if (
|
|
306
|
+
!currentOwnerStat.isFile() ||
|
|
307
|
+
currentOwnerStat.uid !== uid ||
|
|
308
|
+
currentOwnerStat.dev !== ownerStat.dev ||
|
|
309
|
+
currentOwnerStat.ino !== ownerStat.ino ||
|
|
310
|
+
!Number.isSafeInteger(currentPid) ||
|
|
311
|
+
isProcessAlive(currentPid)
|
|
312
|
+
) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (projectStat) {
|
|
316
|
+
projectHandle = await fs.promises.open(
|
|
317
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
318
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
319
|
+
);
|
|
320
|
+
const openProjectStat = await projectHandle.stat();
|
|
321
|
+
if (
|
|
322
|
+
openProjectStat.dev !== projectStat.dev ||
|
|
323
|
+
openProjectStat.ino !== projectStat.ino
|
|
324
|
+
) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await leaseHandle.chmod(0o700);
|
|
329
|
+
if (projectStat) {
|
|
330
|
+
await fs.promises.rm(
|
|
331
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
332
|
+
{ recursive: true, force: false },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
await fs.promises.rm(currentOwnerPath, { force: false });
|
|
336
|
+
await fs.promises.rmdir(
|
|
337
|
+
path.join(`/proc/self/fd/${parentHandle.fd}`, entry.name),
|
|
338
|
+
);
|
|
339
|
+
} finally {
|
|
340
|
+
await projectHandle?.close();
|
|
341
|
+
await leaseHandle?.close();
|
|
342
|
+
await parentHandle.close();
|
|
343
|
+
}
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (
|
|
346
|
+
error instanceof Error &&
|
|
347
|
+
"code" in error &&
|
|
348
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
349
|
+
) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// A concurrent creator/remover can legitimately win this race. Other
|
|
353
|
+
// failures are still reported, but must not block a new scratch task.
|
|
354
|
+
console.error(
|
|
355
|
+
`[delegate] failed to sweep stale scratch lease '${leaseRoot}'`,
|
|
356
|
+
error,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Make an ephemeral, same-filesystem CoW copy of the Git repository containing
|
|
364
|
+
* cwd (or cwd itself outside Git). This is accidental-write isolation, not a
|
|
365
|
+
* security boundary: absolute paths and commands can still reach the host.
|
|
366
|
+
*/
|
|
367
|
+
export async function createScratchWorkspace(
|
|
368
|
+
cwd: string,
|
|
369
|
+
signal?: AbortSignal,
|
|
370
|
+
deadlineAt?: number,
|
|
371
|
+
): Promise<ScratchWorkspace> {
|
|
372
|
+
// Creation requires GNU cp's reflink/archive flags, and cleanup deliberately
|
|
373
|
+
// uses Linux descriptor paths to avoid deleting a renamed/replaced tree.
|
|
374
|
+
if (process.platform !== "linux" || !fs.existsSync("/proc/self/fd")) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
"Scratch workspaces require Linux with GNU cp and /proc/self/fd available.",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const controller = new AbortController();
|
|
381
|
+
const abort = () => controller.abort(signal?.reason);
|
|
382
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
383
|
+
const deadlineAbort = () =>
|
|
384
|
+
controller.abort(
|
|
385
|
+
new Error("Scratch workspace creation exceeded the task deadline."),
|
|
386
|
+
);
|
|
387
|
+
const clearDeadline =
|
|
388
|
+
deadlineAt === undefined
|
|
389
|
+
? undefined
|
|
390
|
+
: scheduleDeadline(deadlineAt, deadlineAbort);
|
|
391
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt) deadlineAbort();
|
|
392
|
+
|
|
393
|
+
let sourceCwd: string;
|
|
394
|
+
let sourceRoot: string;
|
|
395
|
+
let leaseRoot: string | undefined;
|
|
396
|
+
let scratchRoot: string | undefined;
|
|
397
|
+
let copiedLeaseStat: fs.Stats | undefined;
|
|
398
|
+
let copiedRootStat: fs.Stats | undefined;
|
|
399
|
+
try {
|
|
400
|
+
if (signal?.aborted) controller.abort(signal.reason);
|
|
401
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
402
|
+
sourceCwd = await fs.promises.realpath(cwd);
|
|
403
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
404
|
+
sourceRoot = await findCopyRoot(sourceCwd, controller.signal);
|
|
405
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
406
|
+
if (!isWithin(sourceRoot, sourceCwd)) {
|
|
407
|
+
throw new ScratchSetupError(
|
|
408
|
+
"Scratch workspace could not map the task cwd into its project root.",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
await sweepStaleScratchLeases(path.dirname(sourceRoot));
|
|
413
|
+
leaseRoot = await fs.promises.mkdtemp(
|
|
414
|
+
path.join(path.dirname(sourceRoot), SCRATCH_PREFIX),
|
|
415
|
+
);
|
|
416
|
+
await fs.promises.chmod(leaseRoot, 0o700);
|
|
417
|
+
await fs.promises.writeFile(
|
|
418
|
+
path.join(leaseRoot, SCRATCH_OWNER_NAME),
|
|
419
|
+
`${process.pid}\n`,
|
|
420
|
+
{ mode: 0o600 },
|
|
421
|
+
);
|
|
422
|
+
scratchRoot = path.join(leaseRoot, SCRATCH_TREE_NAME);
|
|
423
|
+
await fs.promises.mkdir(scratchRoot, { mode: 0o700 });
|
|
424
|
+
// `source/.` copies the contents into the already-created private directory.
|
|
425
|
+
// `always` deliberately refuses a full-copy fallback for unexpectedly large
|
|
426
|
+
// projects or a destination on the wrong filesystem.
|
|
427
|
+
await runFile(
|
|
428
|
+
"cp",
|
|
429
|
+
[
|
|
430
|
+
"--archive",
|
|
431
|
+
"--reflink=always",
|
|
432
|
+
"--",
|
|
433
|
+
`${sourceRoot}${path.sep}.`,
|
|
434
|
+
scratchRoot,
|
|
435
|
+
],
|
|
436
|
+
{ signal: controller.signal, timeout: COPY_TIMEOUT_MS },
|
|
437
|
+
);
|
|
438
|
+
// GNU cp --archive applies the source root's mode to the destination.
|
|
439
|
+
// Restore the private boundary after it has finished copying metadata.
|
|
440
|
+
await fs.promises.chmod(scratchRoot, 0o700);
|
|
441
|
+
await validateCopiedTree(scratchRoot, controller.signal, signal);
|
|
442
|
+
if (
|
|
443
|
+
await fs.promises.stat(path.join(scratchRoot, ".git")).then(
|
|
444
|
+
(stat) => stat.isDirectory(),
|
|
445
|
+
() => false,
|
|
446
|
+
)
|
|
447
|
+
) {
|
|
448
|
+
const effectiveWorktree = path.resolve(
|
|
449
|
+
(
|
|
450
|
+
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
451
|
+
cwd: scratchRoot,
|
|
452
|
+
signal: controller.signal,
|
|
453
|
+
timeout: 5000,
|
|
454
|
+
})
|
|
455
|
+
).trim(),
|
|
456
|
+
);
|
|
457
|
+
const effectiveGitDir = path.resolve(
|
|
458
|
+
scratchRoot,
|
|
459
|
+
(
|
|
460
|
+
await runFile("git", ["rev-parse", "--absolute-git-dir"], {
|
|
461
|
+
cwd: scratchRoot,
|
|
462
|
+
signal: controller.signal,
|
|
463
|
+
timeout: 5000,
|
|
464
|
+
})
|
|
465
|
+
).trim(),
|
|
466
|
+
);
|
|
467
|
+
const effectiveCommonDir = path.resolve(
|
|
468
|
+
effectiveGitDir,
|
|
469
|
+
(
|
|
470
|
+
await runFile("git", ["rev-parse", "--git-common-dir"], {
|
|
471
|
+
cwd: scratchRoot,
|
|
472
|
+
signal: controller.signal,
|
|
473
|
+
timeout: 5000,
|
|
474
|
+
})
|
|
475
|
+
).trim(),
|
|
476
|
+
);
|
|
477
|
+
if (
|
|
478
|
+
effectiveWorktree !== scratchRoot ||
|
|
479
|
+
!isWithin(scratchRoot, effectiveGitDir) ||
|
|
480
|
+
!isWithin(scratchRoot, effectiveCommonDir)
|
|
481
|
+
) {
|
|
482
|
+
throw new ScratchSetupError(
|
|
483
|
+
"Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
488
|
+
// Keep the copied project writable, but make its private parent immutable
|
|
489
|
+
// to ordinary task commands. `mv "$PWD" …` then cannot unlink the project
|
|
490
|
+
// entry. This is accidental-write protection, not a same-user security
|
|
491
|
+
// boundary: unrestricted bash can deliberately chmod the parent again.
|
|
492
|
+
await fs.promises.chmod(leaseRoot, 0o500);
|
|
493
|
+
// Capture cleanup identities inside the guarded region: if either lookup
|
|
494
|
+
// fails, the catch below restores permissions and removes the partial copy.
|
|
495
|
+
copiedLeaseStat = await fs.promises.lstat(leaseRoot);
|
|
496
|
+
copiedRootStat = await fs.promises.lstat(scratchRoot);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (leaseRoot) {
|
|
499
|
+
try {
|
|
500
|
+
await fs.promises.chmod(leaseRoot, 0o700);
|
|
501
|
+
await fs.promises.rm(leaseRoot, { recursive: true, force: true });
|
|
502
|
+
} catch (cleanupError) {
|
|
503
|
+
console.error(
|
|
504
|
+
"[delegate] failed to clean partial scratch workspace",
|
|
505
|
+
cleanupError,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (controller.signal.aborted) {
|
|
510
|
+
if (signal?.aborted) {
|
|
511
|
+
throw new Error("Scratch workspace creation was aborted.", {
|
|
512
|
+
cause: error,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
throw new ScratchDeadlineError(
|
|
516
|
+
"Scratch workspace creation exceeded the task deadline.",
|
|
517
|
+
{ cause: error },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (error instanceof ScratchSetupError) throw error;
|
|
521
|
+
throw new Error(
|
|
522
|
+
"Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs).",
|
|
523
|
+
{ cause: error },
|
|
524
|
+
);
|
|
525
|
+
} finally {
|
|
526
|
+
signal?.removeEventListener("abort", abort);
|
|
527
|
+
clearDeadline?.();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Assigned before successful exit from the try block above.
|
|
531
|
+
const completedLeaseRoot = leaseRoot!;
|
|
532
|
+
const completedRoot = scratchRoot!;
|
|
533
|
+
const completedLeaseStat = copiedLeaseStat!;
|
|
534
|
+
const completedRootStat = copiedRootStat!;
|
|
535
|
+
const relativeCwd = path.relative(sourceRoot!, sourceCwd!);
|
|
536
|
+
let cleaned = false;
|
|
537
|
+
const resolveReportedPath = async (candidate: string): Promise<string> => {
|
|
538
|
+
const absolute = path.resolve(candidate);
|
|
539
|
+
try {
|
|
540
|
+
const real = await fs.promises.realpath(absolute);
|
|
541
|
+
return isWithin(completedRoot, real)
|
|
542
|
+
? path.join(sourceRoot!, path.relative(completedRoot, real))
|
|
543
|
+
: real;
|
|
544
|
+
} catch {
|
|
545
|
+
// A successful edit/write normally leaves a path behind. Keep the
|
|
546
|
+
// source mapping for a disposable path that was deleted immediately,
|
|
547
|
+
// while preserving an external path for later diagnostics.
|
|
548
|
+
return isWithin(completedRoot, absolute)
|
|
549
|
+
? path.join(sourceRoot!, path.relative(completedRoot, absolute))
|
|
550
|
+
: absolute;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const resolveAttributedPath = async (
|
|
554
|
+
candidate: string,
|
|
555
|
+
): Promise<string | undefined> => {
|
|
556
|
+
try {
|
|
557
|
+
const real = await fs.promises.realpath(candidate);
|
|
558
|
+
return isWithin(completedRoot, real) ? undefined : real;
|
|
559
|
+
} catch {
|
|
560
|
+
const absolute = path.resolve(candidate);
|
|
561
|
+
return isWithin(completedRoot, absolute) ? undefined : absolute;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
return {
|
|
565
|
+
sourceRoot: sourceRoot!,
|
|
566
|
+
sourceCwd: sourceCwd!,
|
|
567
|
+
scratchRoot: completedRoot,
|
|
568
|
+
cwd: path.join(completedRoot, relativeCwd),
|
|
569
|
+
mapPathToSource(candidate: string): string {
|
|
570
|
+
const absolute = path.resolve(candidate);
|
|
571
|
+
if (!isWithin(completedRoot, absolute)) return candidate;
|
|
572
|
+
return path.join(sourceRoot!, path.relative(completedRoot, absolute));
|
|
573
|
+
},
|
|
574
|
+
resolveReportedPath,
|
|
575
|
+
resolveAttributedPath,
|
|
576
|
+
async isDisposablePath(candidate: string): Promise<boolean> {
|
|
577
|
+
return (await resolveAttributedPath(candidate)) === undefined;
|
|
578
|
+
},
|
|
579
|
+
async cleanup(): Promise<void> {
|
|
580
|
+
if (cleaned) return;
|
|
581
|
+
try {
|
|
582
|
+
if (
|
|
583
|
+
path.dirname(completedLeaseRoot) !== path.dirname(sourceRoot!) ||
|
|
584
|
+
!path.basename(completedLeaseRoot).startsWith(SCRATCH_PREFIX) ||
|
|
585
|
+
path.dirname(completedRoot) !== completedLeaseRoot ||
|
|
586
|
+
path.basename(completedRoot) !== SCRATCH_TREE_NAME
|
|
587
|
+
) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
"Refusing to clean an unrecognised scratch workspace path.",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
// Open the parent first, then resolve the lease through that handle. The
|
|
593
|
+
// descriptor identifies the checked directory even if its pathname is
|
|
594
|
+
// renamed or replaced while cleanup is running.
|
|
595
|
+
const parentHandle = await fs.promises.open(
|
|
596
|
+
path.dirname(completedLeaseRoot),
|
|
597
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
598
|
+
);
|
|
599
|
+
let leaseHandle:
|
|
600
|
+
Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
601
|
+
let rootHandle:
|
|
602
|
+
Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
603
|
+
try {
|
|
604
|
+
leaseHandle = await fs.promises.open(
|
|
605
|
+
path.join(
|
|
606
|
+
`/proc/self/fd/${parentHandle.fd}`,
|
|
607
|
+
path.basename(completedLeaseRoot),
|
|
608
|
+
),
|
|
609
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
610
|
+
);
|
|
611
|
+
rootHandle = await fs.promises.open(
|
|
612
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
613
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
614
|
+
);
|
|
615
|
+
const currentLeaseStat = await fs.promises.lstat(completedLeaseRoot);
|
|
616
|
+
const currentRootStat = await fs.promises.lstat(completedRoot);
|
|
617
|
+
const openLeaseStat = await leaseHandle.stat();
|
|
618
|
+
const openRootStat = await rootHandle.stat();
|
|
619
|
+
if (
|
|
620
|
+
!currentLeaseStat.isDirectory() ||
|
|
621
|
+
currentLeaseStat.dev !== completedLeaseStat.dev ||
|
|
622
|
+
currentLeaseStat.ino !== completedLeaseStat.ino ||
|
|
623
|
+
!currentRootStat.isDirectory() ||
|
|
624
|
+
currentRootStat.dev !== completedRootStat.dev ||
|
|
625
|
+
currentRootStat.ino !== completedRootStat.ino ||
|
|
626
|
+
!openLeaseStat.isDirectory() ||
|
|
627
|
+
openLeaseStat.dev !== completedLeaseStat.dev ||
|
|
628
|
+
openLeaseStat.ino !== completedLeaseStat.ino ||
|
|
629
|
+
!openRootStat.isDirectory() ||
|
|
630
|
+
openRootStat.dev !== completedRootStat.dev ||
|
|
631
|
+
openRootStat.ino !== completedRootStat.ino
|
|
632
|
+
) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
"Scratch workspace root was moved or replaced; refusing to report cleanup success.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
await leaseHandle.chmod(0o700);
|
|
638
|
+
// Remove the project through the opened lease descriptor. The
|
|
639
|
+
// recursive operation never resolves the disposable root pathname.
|
|
640
|
+
await fs.promises.rm(
|
|
641
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
642
|
+
{ recursive: true, force: false },
|
|
643
|
+
);
|
|
644
|
+
await fs.promises.rm(
|
|
645
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_OWNER_NAME),
|
|
646
|
+
{ force: false },
|
|
647
|
+
);
|
|
648
|
+
// The lease is empty now. Remove only its directory entry through the
|
|
649
|
+
// opened parent. This is deliberately non-recursive: if a cooperating
|
|
650
|
+
// process replaced the lease with a populated directory, rmdir fails
|
|
651
|
+
// instead of deleting the replacement's contents.
|
|
652
|
+
await fs.promises.rmdir(
|
|
653
|
+
path.join(
|
|
654
|
+
`/proc/self/fd/${parentHandle.fd}`,
|
|
655
|
+
path.basename(completedLeaseRoot),
|
|
656
|
+
),
|
|
657
|
+
);
|
|
658
|
+
cleaned = true;
|
|
659
|
+
} finally {
|
|
660
|
+
await rootHandle?.close();
|
|
661
|
+
await leaseHandle?.close();
|
|
662
|
+
await parentHandle.close();
|
|
663
|
+
}
|
|
664
|
+
} catch (error) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`Scratch workspace cleanup failed for lease '${completedLeaseRoot}': ${error instanceof Error ? error.message : String(error)}`,
|
|
667
|
+
{ cause: error },
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|