pi-better-background-tasks 0.4.0 → 0.6.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/README.md +57 -4
- package/package.json +1 -1
- package/src/failures.ts +9 -1
- package/src/navigator-provider.ts +9 -6
- package/src/output.ts +101 -8
- package/src/runtime.ts +189 -21
- package/src/sandbox.ts +3 -0
- package/src/shared-failure-observations.ts +70 -21
- package/src/shared-log-utils.ts +6 -12
- package/src/shared-navigator.ts +224 -125
- package/src/shared-sandbox-core.ts +194 -22
- package/src/tools.ts +53 -11
- package/src/types.ts +20 -0
|
@@ -109,8 +109,12 @@ export type SandboxCommandArgs = SandboxTarget & {
|
|
|
109
109
|
internalHelperExecutable?: boolean;
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
112
|
+
/**
|
|
113
|
+
* The wrapper command to spawn: the backend executable and its full argv.
|
|
114
|
+
* `notices` are lines the caller shows with the launch output, such as a
|
|
115
|
+
* placeholder the Linux backend had to leave in the user's files.
|
|
116
|
+
*/
|
|
117
|
+
export type SandboxCommand = { file: string; fileArgs: string[]; notices?: string[] };
|
|
114
118
|
|
|
115
119
|
/** The caller's default-on / explicit-request / opt-out decision. */
|
|
116
120
|
export type SandboxRequest = {
|
|
@@ -131,7 +135,11 @@ export type SandboxSeams = {
|
|
|
131
135
|
platform?: () => string;
|
|
132
136
|
/** Defaults to a PATH scan that stats and access-checks without executing. */
|
|
133
137
|
lookupExecutable?: (name: string) => string | undefined;
|
|
134
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Defaults to `fs.realpathSync.native`, which (unlike the JS `realpathSync`)
|
|
140
|
+
* returns the on-disk case of every existing component on a case-insensitive
|
|
141
|
+
* volume, as the kernel sees it. Must throw when the path does not exist.
|
|
142
|
+
*/
|
|
135
143
|
canonicalize?: (path: string) => string;
|
|
136
144
|
/** Defaults to `fs.writeFileSync`. */
|
|
137
145
|
writeProfile?: (path: string, contents: string) => void;
|
|
@@ -325,7 +333,7 @@ function currentPlatform(seams: SandboxSeams): string {
|
|
|
325
333
|
* canonicalizes through its real parent chain.
|
|
326
334
|
*/
|
|
327
335
|
export function canonicalizePath(path: string, seams: SandboxSeams = {}): string {
|
|
328
|
-
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
336
|
+
const canonicalize = seams.canonicalize ?? realpathSync.native;
|
|
329
337
|
const absolute = resolve(path);
|
|
330
338
|
try {
|
|
331
339
|
const resolved = canonicalize(absolute);
|
|
@@ -337,7 +345,7 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
|
|
|
337
345
|
try {
|
|
338
346
|
const source = statSync(resolved);
|
|
339
347
|
const alias = statSync(candidate);
|
|
340
|
-
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
|
|
348
|
+
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync.native(candidate);
|
|
341
349
|
} catch { /* An unrelated Data-volume path keeps its original identity. */ }
|
|
342
350
|
}
|
|
343
351
|
return resolved;
|
|
@@ -358,7 +366,7 @@ function compile(
|
|
|
358
366
|
// bind-mounts it; the macOS backend has always tolerated a not-yet-created
|
|
359
367
|
// one. Keep both behaviors rather than unifying them here.
|
|
360
368
|
const writableRoot = strictRoot
|
|
361
|
-
? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
|
|
369
|
+
? (seams.canonicalize ?? realpathSync.native)(policy.writableRoot)
|
|
362
370
|
: canonicalizePath(policy.writableRoot, seams);
|
|
363
371
|
|
|
364
372
|
// The broad profile also keeps each literal entry: its removal grants would
|
|
@@ -414,13 +422,22 @@ function lexicalAndCanonical(home: string, name: string, seams: SandboxSeams): s
|
|
|
414
422
|
|
|
415
423
|
/**
|
|
416
424
|
* Every symlink directory entry met while resolving `path`, component by
|
|
417
|
-
* component (as `readlink` would follow them)
|
|
425
|
+
* component (as `readlink` would follow them), plus where a dangling chain
|
|
426
|
+
* would land. A chain such as
|
|
418
427
|
* `~/.zshrc -> ~/.dotfiles/zshrc -> ~/dotfiles/zshrc` has an intermediate link
|
|
419
428
|
* in a removable directory; replacing it would retarget the protected entry, so
|
|
420
|
-
* each hop the user could replace is protected too.
|
|
421
|
-
*
|
|
429
|
+
* each hop the user could replace is protected too. When a followed link
|
|
430
|
+
* points at something missing, creating it would give the entry content, so
|
|
431
|
+
* the first missing component (which covers every missing directory below it)
|
|
432
|
+
* and the full path it would resolve to are protected as well. Bounded: a loop
|
|
433
|
+
* or more than 40 hops stops the walk (such a path resolves to nothing).
|
|
422
434
|
*/
|
|
423
435
|
function symlinkHops(path: string): string[] {
|
|
436
|
+
const { hops, unresolved } = walkSymlinks(path);
|
|
437
|
+
return [...hops, ...unresolved];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function walkSymlinks(path: string): { hops: string[]; unresolved: string[] } {
|
|
424
441
|
const hops: string[] = [];
|
|
425
442
|
const seen = new Set<string>();
|
|
426
443
|
let followed = 0;
|
|
@@ -433,7 +450,13 @@ function symlinkHops(path: string): string[] {
|
|
|
433
450
|
if (component === "..") { current = dirname(current); continue; }
|
|
434
451
|
const entry = join(current, component);
|
|
435
452
|
let link: boolean;
|
|
436
|
-
try {
|
|
453
|
+
try {
|
|
454
|
+
link = lstatSync(entry).isSymbolicLink();
|
|
455
|
+
} catch {
|
|
456
|
+
// Missing. Only a followed link makes this a dangling target; a
|
|
457
|
+
// plain absent path is guarded as it is.
|
|
458
|
+
return { hops, unresolved: followed ? [...new Set([entry, resolve(entry, ...pending)])] : [] };
|
|
459
|
+
}
|
|
437
460
|
if (!link) { current = entry; continue; }
|
|
438
461
|
if (++followed > 40 || seen.has(entry)) break;
|
|
439
462
|
seen.add(entry);
|
|
@@ -448,7 +471,12 @@ function symlinkHops(path: string): string[] {
|
|
|
448
471
|
pending = [...target.split(sep), ...pending];
|
|
449
472
|
}
|
|
450
473
|
}
|
|
451
|
-
return hops;
|
|
474
|
+
return { hops, unresolved: [] };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Whether a symlink resolves to nothing: dangling (ENOENT), or looping or over 40 hops (ELOOP). */
|
|
478
|
+
function unresolvable(path: string): boolean {
|
|
479
|
+
try { statSync(path); return false; } catch { return true; }
|
|
452
480
|
}
|
|
453
481
|
|
|
454
482
|
function broadCredentialPaths(home: string, seams: SandboxSeams): string[] {
|
|
@@ -524,6 +552,67 @@ function contains(root: string, target: string): boolean {
|
|
|
524
552
|
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
525
553
|
}
|
|
526
554
|
|
|
555
|
+
/** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
|
|
556
|
+
const caseFoldingByDevice = new Map<number, boolean>();
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Whether the volume holding `path` (its nearest existing ancestor) ignores
|
|
560
|
+
* case: an existing component whose case-swapped spelling names the same
|
|
561
|
+
* inode proves it. macOS only; false whenever it cannot be shown.
|
|
562
|
+
*/
|
|
563
|
+
function caseInsensitiveVolume(path: string): boolean {
|
|
564
|
+
if (process.platform !== "darwin") return false;
|
|
565
|
+
let current = path;
|
|
566
|
+
while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
|
|
567
|
+
let device: number;
|
|
568
|
+
try { device = statSync(current).dev; } catch { return false; }
|
|
569
|
+
const known = caseFoldingByDevice.get(device);
|
|
570
|
+
if (known !== undefined) return known;
|
|
571
|
+
for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
|
|
572
|
+
const name = basename(probe);
|
|
573
|
+
const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
|
|
574
|
+
if (swapped === name) continue;
|
|
575
|
+
try {
|
|
576
|
+
const original = statSync(probe);
|
|
577
|
+
if (original.dev !== device) break;
|
|
578
|
+
let folded = false;
|
|
579
|
+
try {
|
|
580
|
+
const other = statSync(join(dirname(probe), swapped));
|
|
581
|
+
folded = other.dev === original.dev && other.ino === original.ino;
|
|
582
|
+
} catch { /* The swapped name is absent: case-sensitive. */ }
|
|
583
|
+
caseFoldingByDevice.set(device, folded);
|
|
584
|
+
return folded;
|
|
585
|
+
} catch { return false; }
|
|
586
|
+
}
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Spell `path` the way the policy spells it when they differ only in case on a
|
|
592
|
+
* case-insensitive volume. `canonicalizePath` fixes the case of every existing
|
|
593
|
+
* component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
|
|
594
|
+
* the caller's case, and the kernel still treats it as the protected name. The
|
|
595
|
+
* longest policy path that matches case-insensitively wins, so the pre-check
|
|
596
|
+
* decides as the kernel does (#354).
|
|
597
|
+
*/
|
|
598
|
+
function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
|
|
599
|
+
const broad = policy.broad;
|
|
600
|
+
const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
|
|
601
|
+
...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
|
|
602
|
+
...(broad ? [broad.home, ...broad.tempRoots, ...broad.deleteRoots, ...broad.codePaths] : [])];
|
|
603
|
+
const lower = path.toLowerCase();
|
|
604
|
+
if (lower.length !== path.length) return path;
|
|
605
|
+
let best: string | undefined;
|
|
606
|
+
for (const candidate of candidates) {
|
|
607
|
+
const folded = candidate.toLowerCase();
|
|
608
|
+
if (folded.length !== candidate.length || !contains(folded, lower)) continue;
|
|
609
|
+
if (!best || candidate.length > best.length) best = candidate;
|
|
610
|
+
}
|
|
611
|
+
// Already spelled as the policy spells it: nothing to probe.
|
|
612
|
+
if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
|
|
613
|
+
return `${best}${path.slice(best.length)}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
527
616
|
function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
528
617
|
return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
|
|
529
618
|
}
|
|
@@ -538,7 +627,7 @@ export function evaluateReadAccess(
|
|
|
538
627
|
policy: CompiledSandboxWritePolicy,
|
|
539
628
|
seams: SandboxSeams = {},
|
|
540
629
|
): ReadAccessDecision {
|
|
541
|
-
const path = canonicalizePath(target, seams);
|
|
630
|
+
const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
|
|
542
631
|
const permissions = policy.permissions;
|
|
543
632
|
if (!permissions) return { allowed: true, path };
|
|
544
633
|
if (policy.broad) {
|
|
@@ -565,7 +654,7 @@ export function evaluateWriteAccess(
|
|
|
565
654
|
policy: CompiledSandboxWritePolicy,
|
|
566
655
|
seams: SandboxSeams = {},
|
|
567
656
|
): WriteAccessDecision {
|
|
568
|
-
const path = canonicalizePath(target, seams);
|
|
657
|
+
const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
|
|
569
658
|
if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
|
|
570
659
|
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
571
660
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
@@ -1296,7 +1385,10 @@ function maskSources(profilePath: string): { directory: string; file: string } {
|
|
|
1296
1385
|
* home itself stay read-only, so sibling repositories cannot even be edited in
|
|
1297
1386
|
* place there. Credentials are masked by empty mode-000 mounts; code that runs
|
|
1298
1387
|
* later and caller-denied paths are read-only binds. Every writable bind and
|
|
1299
|
-
* protected leaf is a mount point, which Linux refuses to rename.
|
|
1388
|
+
* protected leaf is a mount point, which Linux refuses to rename. A protected
|
|
1389
|
+
* symlink inside a writable directory (a stow link in `~/.config`) makes that
|
|
1390
|
+
* directory read-only with each existing entry bound writable again, so only
|
|
1391
|
+
* new top-level entries there are refused.
|
|
1300
1392
|
*/
|
|
1301
1393
|
function buildLinuxBroadCommand(
|
|
1302
1394
|
bwrap: string,
|
|
@@ -1347,15 +1439,74 @@ function buildLinuxBroadCommand(
|
|
|
1347
1439
|
const readOnly = new Set<string>();
|
|
1348
1440
|
const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
|
|
1349
1441
|
.some((name) => canonicalizePath(join(broad.home, name), seams) === path);
|
|
1442
|
+
// The literal fixed-list entry (a dangling `~/.ssh` link) or its canonical path.
|
|
1443
|
+
const directoryEntry = (path: string) => directoryLocation(path) ||
|
|
1444
|
+
[...DIRECTORY_LOCATIONS].some((name) => join(broad.home, name) === path);
|
|
1445
|
+
// Directories holding a protected symlink: read-only, so the link cannot be
|
|
1446
|
+
// replaced, with their existing entries bound writable again below.
|
|
1447
|
+
const linkParents = new Set<string>();
|
|
1448
|
+
// Where dangling links would land. Only the first missing component of each
|
|
1449
|
+
// needs a guard: nothing can be created below a read-only mount or a locked
|
|
1450
|
+
// parent. It is a directory when more of the path lies below it, or when the
|
|
1451
|
+
// link is a directory-type entry (`~/.ssh -> ~/work/ssh`).
|
|
1452
|
+
const firstMissing = new Map<string, boolean>();
|
|
1453
|
+
const belowFirstMissing = new Set<string>();
|
|
1454
|
+
for (const path of protectedPaths) {
|
|
1455
|
+
const unresolved = walkSymlinks(path).unresolved;
|
|
1456
|
+
if (!unresolved.length) continue;
|
|
1457
|
+
const first = unresolved[0]!;
|
|
1458
|
+
const full = unresolved.at(-1)!;
|
|
1459
|
+
firstMissing.set(first, firstMissing.get(first) === true || first !== full || directoryEntry(path));
|
|
1460
|
+
if (full !== first) belowFirstMissing.add(full);
|
|
1461
|
+
}
|
|
1462
|
+
const created: string[] = [];
|
|
1463
|
+
const makeDirectory = seams.makeDirectory ?? ((target: string) => mkdirSync(target, { recursive: true, mode: 0o700 }));
|
|
1350
1464
|
const guard = (path: string, hide: boolean) => {
|
|
1351
1465
|
let directory: boolean | undefined;
|
|
1352
|
-
|
|
1466
|
+
let link = false;
|
|
1467
|
+
try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
|
|
1468
|
+
if (canonicalizePath(path, seams) !== path || (link && unresolvable(path))) {
|
|
1353
1469
|
// A literal twin of a canonical deny entry: its target is guarded on
|
|
1354
|
-
// its own. A
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
if (link && dirname(path) !== broad.home && writableAt(dirname(path)))
|
|
1470
|
+
// its own. A looping link resolves to nothing (ELOOP), and a dangling
|
|
1471
|
+
// one's missing target is guarded below, so only the link itself
|
|
1472
|
+
// needs guarding here. A symlink leaf inside a writable root could be
|
|
1473
|
+
// replaced, so its directory is protected; home already is.
|
|
1474
|
+
if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (belowFirstMissing.has(path) && !firstMissing.has(path)) return; // Its first missing component is guarded.
|
|
1478
|
+
if (firstMissing.has(path)) {
|
|
1479
|
+
// Where a dangling link would land: nothing may be created there.
|
|
1480
|
+
// A missing directory gets an empty read-only placeholder directory,
|
|
1481
|
+
// so only that one name is taken, not every new entry beside it
|
|
1482
|
+
// (`~/.cache/<tool>/…` would otherwise lock all of `~/.cache`). A
|
|
1483
|
+
// missing file is not planted in the user's files: its parent is
|
|
1484
|
+
// locked instead (entries stay writable, see below), except in the
|
|
1485
|
+
// workspace root and temp, which must keep accepting new entries,
|
|
1486
|
+
// so the file placeholder is bound there. Each placeholder made is
|
|
1487
|
+
// reported, and the workspace and temp fail closed without one.
|
|
1488
|
+
if (!writableAt(path)) return;
|
|
1489
|
+
const ancestor = dirname(path);
|
|
1490
|
+
if (ancestor === broad.home) return; // Home is read-only already.
|
|
1491
|
+
const failClosed = ancestor === project || broad.tempRoots.includes(ancestor);
|
|
1492
|
+
const directory = firstMissing.get(path)!;
|
|
1493
|
+
if (directory || failClosed) {
|
|
1494
|
+
const existed = exists(path);
|
|
1495
|
+
let made: boolean;
|
|
1496
|
+
if (directory) {
|
|
1497
|
+
try { makeDirectory(path); } catch { /* Re-checked below. */ }
|
|
1498
|
+
made = exists(path);
|
|
1499
|
+
} else {
|
|
1500
|
+
made = materialize(path);
|
|
1501
|
+
}
|
|
1502
|
+
if (made) {
|
|
1503
|
+
if (!existed) created.push(`${path}${directory ? "/" : ""}`);
|
|
1504
|
+
readOnly.add(path);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (failClosed) throw new Error(`Cannot protect denied path: ${path}`);
|
|
1508
|
+
}
|
|
1509
|
+
linkParents.add(ancestor);
|
|
1359
1510
|
return;
|
|
1360
1511
|
}
|
|
1361
1512
|
if (!exists(path)) {
|
|
@@ -1384,19 +1535,37 @@ function buildLinuxBroadCommand(
|
|
|
1384
1535
|
for (const path of writeDenied) guard(path, false);
|
|
1385
1536
|
if (permissions.projectFiles === "read") readOnly.add(project);
|
|
1386
1537
|
|
|
1538
|
+
// A read-only link parent keeps its existing entries writable: each one is
|
|
1539
|
+
// bound back read-write (a mount point, so it cannot be renamed away either).
|
|
1540
|
+
// Only new top-level entries in that directory are lost. Protected entries
|
|
1541
|
+
// keep their own mounts, and a symlink entry is never bound: a bind follows
|
|
1542
|
+
// it, and writes through it already meet its target's own mounts.
|
|
1543
|
+
const rebound = new Set<string>();
|
|
1544
|
+
for (const parent of linkParents) readOnly.add(parent);
|
|
1545
|
+
for (const parent of linkParents) {
|
|
1546
|
+
for (const entry of list(parent)) {
|
|
1547
|
+
if (entry.symlink) continue;
|
|
1548
|
+
const path = join(parent, entry.name);
|
|
1549
|
+
if (insideProtected(path) || readOnly.has(path) || writable.has(path)) continue;
|
|
1550
|
+
rebound.add(path);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1387
1554
|
// Intermediate directories between a writable root and anything bound
|
|
1388
1555
|
// inside it become mount points too, so none of them can be renamed away.
|
|
1389
1556
|
const anchors = new Set<string>();
|
|
1390
1557
|
for (const target of [...writableRoots, ...readOnly, ...hidden.map((entry) => entry.path)]) {
|
|
1391
1558
|
for (const parent of protectedAncestors([target])) {
|
|
1392
1559
|
if (writableRoots.some((root) => root !== parent && contains(root, parent)) &&
|
|
1393
|
-
!readOnly.has(parent) && !writable.has(parent)) anchors.add(parent);
|
|
1560
|
+
!readOnly.has(parent) && !writable.has(parent) && !rebound.has(parent)) anchors.add(parent);
|
|
1394
1561
|
}
|
|
1395
1562
|
}
|
|
1396
|
-
type Mount = { option: "--bind" | "--ro-bind"; source: string; path: string; rank: number };
|
|
1563
|
+
type Mount = { option: "--bind" | "--bind-try" | "--ro-bind"; source: string; path: string; rank: number };
|
|
1397
1564
|
const mounts: Mount[] = [
|
|
1398
1565
|
...[...anchors].map((path) => ({ option: "--bind" as const, source: path, path, rank: 0 })),
|
|
1399
1566
|
...writableRoots.map((path) => ({ option: "--bind" as const, source: path, path, rank: 1 })),
|
|
1567
|
+
// `-try`: an entry removed before a resumed launch stays read-only instead of failing it.
|
|
1568
|
+
...[...rebound].map((path) => ({ option: "--bind-try" as const, source: path, path, rank: 1 })),
|
|
1400
1569
|
...[...readOnly].map((path) => ({ option: "--ro-bind" as const, source: path, path, rank: 2 })),
|
|
1401
1570
|
...hidden.map(({ path, source }) => ({ option: "--ro-bind" as const, source, path, rank: 3 })),
|
|
1402
1571
|
].sort((a, b) => a.path.length - b.path.length || a.rank - b.rank);
|
|
@@ -1412,6 +1581,9 @@ function buildLinuxBroadCommand(
|
|
|
1412
1581
|
file: bwrap,
|
|
1413
1582
|
fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
1414
1583
|
"--", args.execPath, ...args.execArgs],
|
|
1584
|
+
...(created.length && { notices: [
|
|
1585
|
+
`Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
|
|
1586
|
+
] }),
|
|
1415
1587
|
};
|
|
1416
1588
|
}
|
|
1417
1589
|
|
package/src/tools.ts
CHANGED
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
} from "./output.js";
|
|
15
15
|
import { readOutputControls } from "./shared-log-utils.js";
|
|
16
16
|
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
17
|
-
import { inspectMeta, listMetasForOrigin, writeMeta } from "./registry.js";
|
|
18
|
-
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
17
|
+
import { inspectMeta, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
|
|
18
|
+
import { awaitFirstWatchCheck, FIRST_WATCH_CHECK_WAIT_MS, resumeRunningTask, spawnTask, startWatchTask, stopTask, type WatchTaskParams } from "./runtime.js";
|
|
19
19
|
import { runTaskMaintenance } from "./maintenance.js";
|
|
20
20
|
import { ForegroundSandboxBlockedError } from "./sandbox.js";
|
|
21
21
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
@@ -71,7 +71,7 @@ const RemoteSchema = Type.Object({
|
|
|
71
71
|
// TypeBox union, and a Null-first union reports a malformed array as "must be null".
|
|
72
72
|
const IntentFields = {
|
|
73
73
|
operation_id: Type.Optional(Type.Unsafe<string | null>({ anyOf: [{ type: "null" }, { type: "string" }], description: "Optional stable id (letters, digits, . _ : / -; up to 64) for one logical operation. Reuse it when you retry with a changed command, scope, or timeout: when a later task with the same operation_id succeeds, earlier failures of that operation in this session are recovered." })),
|
|
74
|
-
expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum:
|
|
74
|
+
expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: 16 }, { type: "null" }], description: "Optional distinct non-zero exit codes that are intentional for this command (e.g. [1] for a no-match probe). 0 is allowed and ignored: exit 0 is already success. Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
const CommandFields = {
|
|
@@ -91,11 +91,23 @@ const CommandFields = {
|
|
|
91
91
|
|
|
92
92
|
const SpawnParams = Type.Object(CommandFields);
|
|
93
93
|
|
|
94
|
+
const BlindChecksField = Type.Optional(Type.Integer({ minimum: 0, description: "Flag the watch as possibly blind after this many checks in a row exit 0, write stderr, and match neither success_when nor failure_when: one incident that needs action, with the latest stderr line. The watch keeps running; a check with no stderr or a matched condition recovers it. Default 3; 0 turns it off." }));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How to write a watch check (#359): a check that swallows its own errors reports "pending"
|
|
98
|
+
* forever. Shared by bg_task_watch and the bg_task wrapper.
|
|
99
|
+
*/
|
|
100
|
+
const WATCH_CHECK_GUIDANCE = "Waits up to 15s for the first check and returns its exit code with stdout and stderr tails; if it is still running, says so. "
|
|
101
|
+
+ "Write the check so a broken check is visible: do not end it with `exit 0` or `|| true`, because a check that exits non-zero is recorded and escalates. "
|
|
102
|
+
+ "Map an unknown or unparseable state to failure (exit non-zero), not to pending. Prefer structured output, e.g. `--format=json | jq -er '.status'`, over fragile format strings. "
|
|
103
|
+
+ "A check that exits 0 but writes stderr without matching a condition for blind_checks (default 3) checks in a row is flagged as needing action; if that stderr is expected, redirect it (2>/dev/null) or set blind_checks:0.";
|
|
104
|
+
|
|
94
105
|
const WatchParams = Type.Object({
|
|
95
106
|
...CommandFields,
|
|
96
107
|
interval_seconds: Type.Optional(Type.Number({ description: "Polling interval in seconds. Default 30." })),
|
|
97
108
|
success_when: ConditionSchema,
|
|
98
109
|
failure_when: Type.Optional(ConditionSchema),
|
|
110
|
+
blind_checks: BlindChecksField,
|
|
99
111
|
});
|
|
100
112
|
|
|
101
113
|
const CursorFields = {
|
|
@@ -152,6 +164,7 @@ const ActionParams = Type.Object({
|
|
|
152
164
|
interval_seconds: Type.Optional(Type.Number()),
|
|
153
165
|
success_when: Type.Optional(ConditionSchema),
|
|
154
166
|
failure_when: Type.Optional(ConditionSchema),
|
|
167
|
+
blind_checks: BlindChecksField,
|
|
155
168
|
raw: Type.Optional(Type.Boolean()),
|
|
156
169
|
...HistoryField,
|
|
157
170
|
...CursorFields,
|
|
@@ -215,12 +228,12 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
215
228
|
pi.registerTool({
|
|
216
229
|
name: "bg_task_watch",
|
|
217
230
|
label: "BG Watch",
|
|
218
|
-
description:
|
|
231
|
+
description: `Poll a command in the background until success_when, failure_when, or timeout matches. For remote work, prefer structured ssh: pass ssh:{host,user} and provide the remote command in command; each interval opens a direct one-shot SSH poll without tmux installation. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. Returns its task id once the first check finishes. ${WATCH_CHECK_GUIDANCE} Default timeout 900 seconds; pass timeout_seconds:0 to disable.`,
|
|
219
232
|
promptGuidelines: BACKGROUND_ORCHESTRATION_GUIDELINES,
|
|
220
233
|
parameters: WatchParams,
|
|
221
|
-
async execute(_toolCallId, params,
|
|
234
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
222
235
|
activeSession = getCallbackOrigin(ctx);
|
|
223
|
-
const launched =
|
|
236
|
+
const launched = await launchWatch(pi, params, ctx.cwd, activeSession, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal);
|
|
224
237
|
refreshBackgroundTasksNavigator(ctx);
|
|
225
238
|
return text(launched);
|
|
226
239
|
},
|
|
@@ -278,15 +291,15 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
278
291
|
pi.registerTool({
|
|
279
292
|
name: "bg_task",
|
|
280
293
|
label: "BG Task",
|
|
281
|
-
description: "Action wrapper for background tasks: spawn, watch, list, status, log, stop, or clear. For remote work, prefer structured ssh: pass ssh:{host,user} and provide the remote command in command. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. SSH spawn defaults to durable tmux; SSH watches use direct one-shot polls without tmux installation; remote.session=direct is a weaker-stop spawn escape hatch. Spawn
|
|
294
|
+
description: "Action wrapper for background tasks: spawn, watch, list, status, log, stop, or clear. For remote work, prefer structured ssh: pass ssh:{host,user} and provide the remote command in command. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. SSH spawn defaults to durable tmux; SSH watches use direct one-shot polls without tmux installation; remote.session=direct is a weaker-stop spawn escape hatch. Spawn returns immediately; do not poll in foreground. For action:watch: " + WATCH_CHECK_GUIDANCE + " For action:status, default compact output and use verbose:true only for full metadata. For action:log, default compact tail and use lines:0 to page retained raw bytes. Standalone tools and these wrappers share the same output assembler. List/status/log default to the current session; pass all:true to override. Stop and clear change only current-session tasks: clear dismisses every owned terminal task, or one task with id (all:true allows another session's task by id).",
|
|
282
295
|
promptGuidelines: BACKGROUND_ORCHESTRATION_GUIDELINES,
|
|
283
296
|
parameters: ActionParams,
|
|
284
297
|
renderResult(result: unknown, options: unknown, theme: unknown) {
|
|
285
298
|
return renderBackgroundTaskLogDisplay(result, options, theme);
|
|
286
299
|
},
|
|
287
|
-
async execute(_toolCallId, params,
|
|
300
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
288
301
|
activeSession = getCallbackOrigin(ctx);
|
|
289
|
-
return actionText(pi, params, ctx, activeSession, getActiveSession);
|
|
302
|
+
return actionText(pi, params, ctx, activeSession, getActiveSession, signal);
|
|
290
303
|
},
|
|
291
304
|
});
|
|
292
305
|
|
|
@@ -356,11 +369,12 @@ async function actionText(
|
|
|
356
369
|
ctx: ExtensionContext,
|
|
357
370
|
callbackOrigin: BackgroundTaskCallbackOrigin,
|
|
358
371
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
372
|
+
signal?: AbortSignal,
|
|
359
373
|
) {
|
|
360
374
|
if (params.action === "log" && params.id) {
|
|
361
375
|
return logText(String(params.id), logOptions(params, callbackOrigin));
|
|
362
376
|
}
|
|
363
|
-
return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession));
|
|
377
|
+
return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession, signal));
|
|
364
378
|
}
|
|
365
379
|
|
|
366
380
|
async function runAction(
|
|
@@ -369,13 +383,14 @@ async function runAction(
|
|
|
369
383
|
ctx: ExtensionContext,
|
|
370
384
|
callbackOrigin: BackgroundTaskCallbackOrigin,
|
|
371
385
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
386
|
+
signal?: AbortSignal,
|
|
372
387
|
): Promise<string> {
|
|
373
388
|
switch (params.action) {
|
|
374
389
|
case "spawn":
|
|
375
390
|
return withNavigatorRefresh(ctx, reportLaunch(() => spawnTask(pi, params, ctx.cwd, callbackOrigin, getActiveSession)));
|
|
376
391
|
case "watch":
|
|
377
392
|
if (!params.success_when) return "Invalid parameters: watch requires success_when.";
|
|
378
|
-
return withNavigatorRefresh(ctx,
|
|
393
|
+
return withNavigatorRefresh(ctx, await launchWatch(pi, params as unknown as WatchTaskParams, ctx.cwd, callbackOrigin, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal));
|
|
379
394
|
case "list":
|
|
380
395
|
return formatList(listOptions(params, callbackOrigin));
|
|
381
396
|
case "status":
|
|
@@ -409,6 +424,33 @@ function reportLaunch(launch: () => BackgroundTaskMeta): string {
|
|
|
409
424
|
}
|
|
410
425
|
}
|
|
411
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Start a watch and wait, bounded by `waitMs`, for its first check, so the launch result
|
|
429
|
+
* shows what the check really prints (#359). Local and SSH watches share this path.
|
|
430
|
+
*/
|
|
431
|
+
export async function launchWatch(
|
|
432
|
+
pi: ExtensionAPI,
|
|
433
|
+
params: WatchTaskParams,
|
|
434
|
+
cwd: string,
|
|
435
|
+
callbackOrigin: BackgroundTaskCallbackOrigin | undefined,
|
|
436
|
+
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
437
|
+
waitMs = FIRST_WATCH_CHECK_WAIT_MS,
|
|
438
|
+
signal?: AbortSignal,
|
|
439
|
+
): Promise<string> {
|
|
440
|
+
let meta: BackgroundTaskMeta;
|
|
441
|
+
try {
|
|
442
|
+
meta = startWatchTask(pi, params, cwd, callbackOrigin, getActiveSession);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error instanceof ForegroundSandboxBlockedError) return error.message;
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
// Esc (the tool's abort signal) ends the wait at once; the watch itself keeps running.
|
|
448
|
+
const outcome = await awaitFirstWatchCheck(meta.id, waitMs, signal);
|
|
449
|
+
const latest = readMeta(meta.id) ?? meta;
|
|
450
|
+
if (outcome && !("pending" in outcome && isTerminalStatus(latest.status))) return formatLaunch(latest, outcome);
|
|
451
|
+
return formatLaunch(latest);
|
|
452
|
+
}
|
|
453
|
+
|
|
412
454
|
function withNavigatorRefresh(ctx: ExtensionContext, result: string): string {
|
|
413
455
|
refreshBackgroundTasksNavigator(ctx);
|
|
414
456
|
return result;
|
package/src/types.ts
CHANGED
|
@@ -42,6 +42,18 @@ export interface CommandResult {
|
|
|
42
42
|
captureTruncated?: boolean;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** A watch's first check, as reported in the launch tool result (#359). */
|
|
46
|
+
export interface FirstWatchCheck {
|
|
47
|
+
exitCode: number | null;
|
|
48
|
+
signal: NodeJS.Signals | null;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
timedOut?: boolean;
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
/** Set when the check itself could not run (e.g. an SSH runner error). */
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
export interface RemoteTaskParams {
|
|
46
58
|
session?: "tmux" | "direct";
|
|
47
59
|
install_tmux?: boolean;
|
|
@@ -124,6 +136,14 @@ export interface BackgroundTaskMeta {
|
|
|
124
136
|
spawnPidStartTime?: string;
|
|
125
137
|
successWhen?: Condition;
|
|
126
138
|
failureWhen?: Condition;
|
|
139
|
+
/**
|
|
140
|
+
* Watch only (#359): consecutive exit-0 checks that write stderr and match neither
|
|
141
|
+
* condition before the watch is flagged as possibly blind. 0 turns the rule off;
|
|
142
|
+
* undefined means DEFAULT_BLIND_CHECKS.
|
|
143
|
+
*/
|
|
144
|
+
blindChecks?: number;
|
|
145
|
+
/** Current run of such checks; reset by any other check. */
|
|
146
|
+
blindCheckStreak?: number;
|
|
127
147
|
/**
|
|
128
148
|
* Caller-declared structured intent (#325), validated before launch. A later task of the same
|
|
129
149
|
* kind, cwd, SSH target, and session with the same operationId that succeeds recovers this
|