pi-better-background-tasks 0.2.15 → 0.2.17
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 +40 -0
- package/package.json +1 -1
- package/src/conditions.ts +7 -0
- package/src/failures.ts +99 -0
- package/src/maintenance.ts +2 -0
- package/src/navigator-provider.ts +9 -3
- package/src/runtime.ts +76 -21
- package/src/sandbox.ts +74 -14
- package/src/shared-callback-batcher.ts +42 -16
- package/src/shared-failure-observations.ts +205 -0
- package/src/shared-navigator.ts +67 -0
- package/src/shared-sandbox-core.ts +283 -11
- package/src/tools.ts +30 -9
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* OS-level write sandbox mechanism shared by Pi extensions.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* the network
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Legacy policies are write-only: a sandboxed process may READ anywhere and use
|
|
6
|
+
* the network, but may only WRITE under a canonical root plus runtime paths.
|
|
7
|
+
* Optional permissions add capability restrictions for reads, writes, launches
|
|
8
|
+
* and network access. They cover known credential files, not OS keychains,
|
|
9
|
+
* credential services, or tokens inherited in the child environment.
|
|
10
10
|
*
|
|
11
11
|
* This module owns the mechanism only: backend discovery, canonical path
|
|
12
12
|
* containment, write-deny compilation, macOS SBPL profile construction, Linux
|
|
@@ -35,6 +35,14 @@ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
|
35
35
|
/** Identifies which kernel mechanism a plan will use. */
|
|
36
36
|
export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
|
|
37
37
|
|
|
38
|
+
export type SandboxPermissions = {
|
|
39
|
+
projectFiles: "off" | "read" | "read-write";
|
|
40
|
+
outsideProject: "off" | "read" | "read-write";
|
|
41
|
+
storedCredentials: "off" | "read" | "read-write";
|
|
42
|
+
commands: boolean;
|
|
43
|
+
network: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
38
46
|
/**
|
|
39
47
|
* What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
|
|
40
48
|
* be relative or contain symlinks; they are canonicalized before use.
|
|
@@ -55,6 +63,10 @@ export type SandboxWritePolicy = {
|
|
|
55
63
|
denyWrite?: readonly string[];
|
|
56
64
|
/** Home directory whose `~/.pi` state stays writable on macOS. */
|
|
57
65
|
home: string;
|
|
66
|
+
/** Optional capability profile; omission preserves the original write-only sandbox. */
|
|
67
|
+
permissions?: SandboxPermissions;
|
|
68
|
+
/** Trusted per-launch runtime state, never supplied by model tool arguments. */
|
|
69
|
+
runtimeWrite?: readonly string[];
|
|
58
70
|
};
|
|
59
71
|
|
|
60
72
|
/** The executable and argv to run inside the sandbox, preserved verbatim. */
|
|
@@ -109,6 +121,9 @@ export type CompiledSandboxWritePolicy = {
|
|
|
109
121
|
readonly writableRoot: string;
|
|
110
122
|
readonly denyWrite: readonly string[];
|
|
111
123
|
readonly home: string;
|
|
124
|
+
readonly permissions?: SandboxPermissions;
|
|
125
|
+
readonly credentialPaths?: readonly string[];
|
|
126
|
+
readonly runtimeWrite?: readonly string[];
|
|
112
127
|
};
|
|
113
128
|
|
|
114
129
|
/** Why a write target is or is not permitted by a compiled policy. */
|
|
@@ -117,11 +132,15 @@ export type WriteAccessDecision =
|
|
|
117
132
|
| {
|
|
118
133
|
allowed: false;
|
|
119
134
|
path: string;
|
|
120
|
-
reason: "outside-writable-root" | "write-denied";
|
|
135
|
+
reason: "outside-writable-root" | "write-denied" | "permission-denied";
|
|
121
136
|
/** The compiled deny entry that matched, for `write-denied` only. */
|
|
122
137
|
deniedBy?: string;
|
|
123
138
|
};
|
|
124
139
|
|
|
140
|
+
export type ReadAccessDecision =
|
|
141
|
+
| { allowed: true; path: string }
|
|
142
|
+
| { allowed: false; path: string; reason: "read-denied" };
|
|
143
|
+
|
|
125
144
|
/** What the current platform can enforce, and why it cannot when it cannot. */
|
|
126
145
|
export type SandboxSupport =
|
|
127
146
|
| { supported: true; platform: string; backend: SandboxBackendId; executable: string }
|
|
@@ -141,6 +160,29 @@ type SandboxBackend = {
|
|
|
141
160
|
|
|
142
161
|
const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
143
162
|
|
|
163
|
+
const CREDENTIAL_LOCATIONS = [
|
|
164
|
+
".ssh", ".aws", ".config/gh", ".config/gcloud", ".azure", ".kube",
|
|
165
|
+
".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
|
|
166
|
+
] as const;
|
|
167
|
+
|
|
168
|
+
// Executables, dynamic libraries and OS frameworks needed to start a child.
|
|
169
|
+
// This deliberately excludes the home directory, /etc, and credential stores.
|
|
170
|
+
// A process relying on /etc or /proc configuration (DNS, certificates, NSS)
|
|
171
|
+
// may not start or function under Linux outsideProject=off; callers must not
|
|
172
|
+
// silently remount these broad host trees to work around that failure.
|
|
173
|
+
const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Library", "/System/Cryptexes", "/Library/Apple", "/Library/Developer", "/opt/homebrew"];
|
|
174
|
+
const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
|
|
175
|
+
const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
|
|
176
|
+
|
|
177
|
+
/** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
|
|
178
|
+
export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
|
|
179
|
+
const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
|
|
180
|
+
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
181
|
+
if (configuredAgentDir) paths.push(join(configuredAgentDir.startsWith("~/")
|
|
182
|
+
? join(home, configuredAgentDir.slice(2)) : configuredAgentDir, "auth.json"));
|
|
183
|
+
return [...new Set(paths.map((path) => canonicalizePath(path, seams)))].sort();
|
|
184
|
+
}
|
|
185
|
+
|
|
144
186
|
function currentPlatform(seams: SandboxSeams): string {
|
|
145
187
|
return (seams.platform ?? osPlatform)();
|
|
146
188
|
}
|
|
@@ -154,7 +196,19 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
|
|
|
154
196
|
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
155
197
|
const absolute = resolve(path);
|
|
156
198
|
try {
|
|
157
|
-
|
|
199
|
+
const resolved = canonicalize(absolute);
|
|
200
|
+
// APFS firmlinks are not resolved by realpath. Normalize the Data-volume
|
|
201
|
+
// alias only when both names demonstrably refer to the same inode.
|
|
202
|
+
const dataPrefix = "/System/Volumes/Data";
|
|
203
|
+
if (!seams.canonicalize && currentPlatform(seams) === "darwin" && resolved.startsWith(`${dataPrefix}/`)) {
|
|
204
|
+
const candidate = resolved.slice(dataPrefix.length);
|
|
205
|
+
try {
|
|
206
|
+
const source = statSync(resolved);
|
|
207
|
+
const alias = statSync(candidate);
|
|
208
|
+
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
|
|
209
|
+
} catch { /* An unrelated Data-volume path keeps its original identity. */ }
|
|
210
|
+
}
|
|
211
|
+
return resolved;
|
|
158
212
|
} catch {
|
|
159
213
|
// Not created yet (or unreadable): canonicalize the parent instead.
|
|
160
214
|
}
|
|
@@ -179,7 +233,14 @@ function compile(
|
|
|
179
233
|
...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
|
|
180
234
|
].sort();
|
|
181
235
|
|
|
182
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
writableRoot, denyWrite, home: policy.home,
|
|
238
|
+
...(policy.permissions && {
|
|
239
|
+
permissions: { ...policy.permissions },
|
|
240
|
+
credentialPaths: credentialFilePaths(policy.home, seams),
|
|
241
|
+
runtimeWrite: (policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
183
244
|
}
|
|
184
245
|
|
|
185
246
|
/**
|
|
@@ -198,6 +259,31 @@ function contains(root: string, target: string): boolean {
|
|
|
198
259
|
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
199
260
|
}
|
|
200
261
|
|
|
262
|
+
function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
263
|
+
return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function runtimeRoots(seams: SandboxSeams): string[] {
|
|
267
|
+
return [...new Set(READ_RUNTIME_ROOTS.map((path) => canonicalizePath(path, seams)))];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Decide read access using the same canonical path and capability precedence as writes. */
|
|
271
|
+
export function evaluateReadAccess(
|
|
272
|
+
target: string,
|
|
273
|
+
policy: CompiledSandboxWritePolicy,
|
|
274
|
+
seams: SandboxSeams = {},
|
|
275
|
+
): ReadAccessDecision {
|
|
276
|
+
const path = canonicalizePath(target, seams);
|
|
277
|
+
const permissions = policy.permissions;
|
|
278
|
+
if (!permissions) return { allowed: true, path };
|
|
279
|
+
const mode = isCredential(path, policy) ? permissions.storedCredentials
|
|
280
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
281
|
+
: contains(policy.writableRoot, path) ? permissions.projectFiles
|
|
282
|
+
: path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
|
|
283
|
+
: permissions.outsideProject;
|
|
284
|
+
return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
285
|
+
}
|
|
286
|
+
|
|
201
287
|
/**
|
|
202
288
|
* Decide whether an in-process write to `target` is permitted by a compiled
|
|
203
289
|
* policy. This is the same containment rule the kernel backends enforce, for
|
|
@@ -209,7 +295,7 @@ export function evaluateWriteAccess(
|
|
|
209
295
|
seams: SandboxSeams = {},
|
|
210
296
|
): WriteAccessDecision {
|
|
211
297
|
const path = canonicalizePath(target, seams);
|
|
212
|
-
if (!contains(policy.writableRoot, path)) {
|
|
298
|
+
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
213
299
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
214
300
|
}
|
|
215
301
|
for (const denied of policy.denyWrite) {
|
|
@@ -217,6 +303,14 @@ export function evaluateWriteAccess(
|
|
|
217
303
|
return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
218
304
|
}
|
|
219
305
|
}
|
|
306
|
+
if (policy.permissions) {
|
|
307
|
+
const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
|
|
308
|
+
: policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
|
|
309
|
+
: contains(policy.writableRoot, path) ? policy.permissions.projectFiles
|
|
310
|
+
: contains(canonicalizePath("/dev", seams), path) ? "read-write"
|
|
311
|
+
: policy.permissions.outsideProject;
|
|
312
|
+
if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
|
|
313
|
+
}
|
|
220
314
|
return { allowed: true, path };
|
|
221
315
|
}
|
|
222
316
|
|
|
@@ -225,13 +319,70 @@ function sbpl(path: string): string {
|
|
|
225
319
|
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
226
320
|
}
|
|
227
321
|
|
|
322
|
+
function validateRuntimeHome(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): void {
|
|
323
|
+
if (policy.permissions?.outsideProject !== "off") return;
|
|
324
|
+
const home = canonicalizePath(policy.home, seams);
|
|
325
|
+
if (RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
|
|
326
|
+
throw new Error("outsideProject=off cannot expose a home under a required system runtime root; move the home or use another permission mode.");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function protectedAncestors(paths: readonly string[]): string[] {
|
|
331
|
+
const parents = new Set<string>();
|
|
332
|
+
for (const path of paths) {
|
|
333
|
+
for (let parent = dirname(path); dirname(parent) !== parent; parent = dirname(parent)) parents.add(parent);
|
|
334
|
+
}
|
|
335
|
+
return [...parents].sort((a, b) => a.length - b.length);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
|
|
339
|
+
validateRuntimeHome(policy, seams);
|
|
340
|
+
const permissions = policy.permissions!;
|
|
341
|
+
const protectedPaths = [
|
|
342
|
+
...policy.denyWrite,
|
|
343
|
+
...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
|
|
344
|
+
...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
|
|
345
|
+
];
|
|
346
|
+
const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
|
|
347
|
+
if (permissions.outsideProject === "off") {
|
|
348
|
+
rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
|
|
349
|
+
for (const root of runtimeRoots(seams)) {
|
|
350
|
+
rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (permissions.outsideProject === "read-write") rules.push("(allow file-write*)");
|
|
354
|
+
// Temporary host paths are not granted for off/read: doing so would expose
|
|
355
|
+
// other users' files in temp. /dev remains necessary for basic shell I/O.
|
|
356
|
+
for (const root of TEMP_ROOTS.filter((path) => permissions.outsideProject === "read-write" || path === "/dev")
|
|
357
|
+
.map((path) => canonicalizePath(path, seams))) {
|
|
358
|
+
rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
359
|
+
}
|
|
360
|
+
const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
|
|
361
|
+
if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
|
|
362
|
+
else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
|
|
363
|
+
if (mode === "read-write") rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
|
|
364
|
+
else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
|
|
365
|
+
};
|
|
366
|
+
// Last matching SBPL rule wins. Credential rules override project and outside;
|
|
367
|
+
// explicit denyWrite entries always override every write allowance.
|
|
368
|
+
scoped(policy.writableRoot, permissions.projectFiles);
|
|
369
|
+
for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
|
|
370
|
+
for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
|
|
371
|
+
for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
|
|
372
|
+
// Protect the directory entries, not their contents: unrelated children can
|
|
373
|
+
// still be created, while renaming a parent cannot move a denied subtree.
|
|
374
|
+
for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
|
|
375
|
+
if (!permissions.network) rules.push("(deny network*)");
|
|
376
|
+
return [...rules, ""].join("\n");
|
|
377
|
+
}
|
|
378
|
+
|
|
228
379
|
/** Build the macOS sandbox-exec wrapper and its SBPL profile. */
|
|
229
380
|
function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
|
|
230
381
|
// Match on the real (symlink-resolved) path — sandbox-exec evaluates the
|
|
231
382
|
// canonical path, so /tmp/x must be written as /private/tmp/x.
|
|
232
383
|
const policy = compile(args.policy, seams, false);
|
|
233
384
|
|
|
234
|
-
const profile = [
|
|
385
|
+
const profile = policy.permissions ? buildPermissionProfile(policy, seams) : [
|
|
235
386
|
"(version 1)",
|
|
236
387
|
"(allow default)", // permissive base: reads, exec, network
|
|
237
388
|
"(deny file-write*)", // ...then deny all writes...
|
|
@@ -344,6 +495,7 @@ function buildLinuxSandboxCommand(
|
|
|
344
495
|
// boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
|
|
345
496
|
// widening the writable root.
|
|
346
497
|
const policy = compile(args.policy, seams, true);
|
|
498
|
+
if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
|
|
347
499
|
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
348
500
|
const denyBinds = policy.denyWrite.flatMap((path) => {
|
|
349
501
|
const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
|
|
@@ -368,6 +520,113 @@ function buildLinuxSandboxCommand(
|
|
|
368
520
|
};
|
|
369
521
|
}
|
|
370
522
|
|
|
523
|
+
function buildLinuxPermissionCommand(
|
|
524
|
+
bwrap: string,
|
|
525
|
+
args: SandboxCommandArgs,
|
|
526
|
+
policy: CompiledSandboxWritePolicy,
|
|
527
|
+
seams: SandboxSeams,
|
|
528
|
+
): SandboxCommand {
|
|
529
|
+
const permissions = policy.permissions!;
|
|
530
|
+
validateRuntimeHome(policy, seams);
|
|
531
|
+
const project = policy.writableRoot;
|
|
532
|
+
const credentials = policy.credentialPaths ?? [];
|
|
533
|
+
const writableProject = permissions.projectFiles === "read-write";
|
|
534
|
+
const overlappingCredentials = credentials.filter((path) => contains(project, path));
|
|
535
|
+
if (credentials.some((path) => contains(path, project)) &&
|
|
536
|
+
permissions.projectFiles !== permissions.storedCredentials) {
|
|
537
|
+
throw new Error("Linux bubblewrap cannot apply differing project and credential permissions when a credential directory contains the project.");
|
|
538
|
+
}
|
|
539
|
+
if (writableProject && policy.denyWrite.some((path) => contains(path, project))) {
|
|
540
|
+
throw new Error("Linux bubblewrap cannot make a project writable inside a write-denied directory.");
|
|
541
|
+
}
|
|
542
|
+
// Protected leaves and their writable ancestors become mount points below.
|
|
543
|
+
// Linux refuses renaming mount points, preventing ancestor replacement.
|
|
544
|
+
if (permissions.outsideProject === "read-write" && (
|
|
545
|
+
permissions.projectFiles !== "read-write" || permissions.storedCredentials !== "read-write" ||
|
|
546
|
+
policy.denyWrite.length > 0
|
|
547
|
+
)) {
|
|
548
|
+
throw new Error("Linux bubblewrap cannot enforce restricted project/credential/denyWrite paths under a writable outsideProject mount.");
|
|
549
|
+
}
|
|
550
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "read-write" &&
|
|
551
|
+
credentials.some((path) => !contains(project, path))) {
|
|
552
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only outsideProject root.");
|
|
553
|
+
}
|
|
554
|
+
if (permissions.projectFiles === "read" && permissions.storedCredentials === "read-write" &&
|
|
555
|
+
overlappingCredentials.length > 0) {
|
|
556
|
+
throw new Error("Linux bubblewrap cannot write credential stores under a read-only project mount.");
|
|
557
|
+
}
|
|
558
|
+
if (permissions.outsideProject === "read" && permissions.storedCredentials === "off") {
|
|
559
|
+
throw new Error("Linux bubblewrap cannot hide stored credentials in a read-only whole-root bind.");
|
|
560
|
+
}
|
|
561
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "off" &&
|
|
562
|
+
credentials.some((path) => contains(project, path) && permissions.projectFiles !== "off")) {
|
|
563
|
+
throw new Error("Linux bubblewrap cannot hide credentials inside a visible read-only project.");
|
|
564
|
+
}
|
|
565
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
566
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), project))) {
|
|
567
|
+
throw new Error("Linux bubblewrap cannot hide a project nested under a required system runtime bind.");
|
|
568
|
+
}
|
|
569
|
+
if (permissions.outsideProject === "off" && credentials.some((path) =>
|
|
570
|
+
!contains(project, path) &&
|
|
571
|
+
RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), path)) &&
|
|
572
|
+
permissions.storedCredentials === "off")) {
|
|
573
|
+
throw new Error("Linux bubblewrap cannot hide credentials under a required system runtime bind.");
|
|
574
|
+
}
|
|
575
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read-write" &&
|
|
576
|
+
credentials.some((path) => !contains(project, path))) {
|
|
577
|
+
throw new Error("Linux bubblewrap cannot create or safely bind writable credential stores outside a hidden root.");
|
|
578
|
+
}
|
|
579
|
+
if (permissions.outsideProject === "off" && permissions.projectFiles === "off" &&
|
|
580
|
+
permissions.storedCredentials !== "off" && credentials.some((path) => contains(project, path))) {
|
|
581
|
+
throw new Error("Linux bubblewrap cannot expose credentials inside a hidden project without exposing the project.");
|
|
582
|
+
}
|
|
583
|
+
if (permissions.outsideProject !== "off" && permissions.projectFiles === "off") {
|
|
584
|
+
throw new Error("Linux bubblewrap cannot hide a project inside a visible outsideProject root.");
|
|
585
|
+
}
|
|
586
|
+
const mounts: string[] = permissions.outsideProject === "off" ? ["--tmpfs", "/"]
|
|
587
|
+
: [permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/", "/"];
|
|
588
|
+
if (permissions.outsideProject === "off") {
|
|
589
|
+
// Bounded system executable/library roots only. /tmp is private, not a
|
|
590
|
+
// host bind: otherwise outsideProject=off would expose user temp data.
|
|
591
|
+
for (const root of RUNTIME_ROOTS) {
|
|
592
|
+
if (existsSync(root)) mounts.push("--ro-bind", root, root);
|
|
593
|
+
}
|
|
594
|
+
mounts.push("--tmpfs", "/tmp");
|
|
595
|
+
} else {
|
|
596
|
+
mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
|
|
597
|
+
}
|
|
598
|
+
mounts.push("--dev", "/dev");
|
|
599
|
+
if (permissions.projectFiles !== "off") {
|
|
600
|
+
mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
|
|
601
|
+
}
|
|
602
|
+
if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
|
|
603
|
+
for (const path of credentials) {
|
|
604
|
+
if (existsSync(path) && !contains(project, path)) mounts.push("--ro-bind", path, path);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const path of policy.runtimeWrite ?? []) {
|
|
608
|
+
if ([...credentials, ...policy.denyWrite].some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path))) {
|
|
609
|
+
throw new Error("Runtime directory overlaps protected credentials or control paths.");
|
|
610
|
+
}
|
|
611
|
+
mounts.push("--bind", path, path);
|
|
612
|
+
}
|
|
613
|
+
const protectedPaths = [...policy.denyWrite,
|
|
614
|
+
...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
|
|
615
|
+
if (writableProject) {
|
|
616
|
+
const materialize = seams.materializeDenyPath ?? materializeDenyPath;
|
|
617
|
+
const leaves = protectedPaths.filter((path) => contains(project, path) && materialize(path));
|
|
618
|
+
for (const parent of protectedAncestors(leaves).filter((path) => contains(project, path) && path !== project)) {
|
|
619
|
+
mounts.push("--bind", parent, parent);
|
|
620
|
+
}
|
|
621
|
+
for (const path of leaves) mounts.push("--ro-bind", path, path);
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
file: bwrap,
|
|
625
|
+
fileArgs: [...mounts, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
626
|
+
"--", args.execPath, ...args.execArgs],
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
371
630
|
function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
|
|
372
631
|
const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
|
|
373
632
|
if (!bwrap) return undefined;
|
|
@@ -436,10 +695,16 @@ export function maybeBuildSandboxCommand(
|
|
|
436
695
|
request: SandboxRequest,
|
|
437
696
|
seams: SandboxSeams = {},
|
|
438
697
|
): SandboxCommand | undefined {
|
|
698
|
+
if (args.policy.permissions?.commands === false) {
|
|
699
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
700
|
+
}
|
|
439
701
|
if (!request.sandboxEnabled) return undefined;
|
|
440
702
|
|
|
441
703
|
const backend = selectedSandboxBackend(seams);
|
|
442
704
|
if (!backend) {
|
|
705
|
+
if (args.policy.permissions) {
|
|
706
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
707
|
+
}
|
|
443
708
|
if (request.explicitSandbox) {
|
|
444
709
|
const reason = sandboxUnavailableMessage(seams);
|
|
445
710
|
throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
|
|
@@ -458,5 +723,12 @@ export function buildSandboxCommand(
|
|
|
458
723
|
args: SandboxCommandArgs,
|
|
459
724
|
seams: SandboxSeams = {},
|
|
460
725
|
): SandboxCommand {
|
|
461
|
-
|
|
726
|
+
if (args.policy.permissions?.commands === false) {
|
|
727
|
+
throw new Error("Sandbox commands permission is off; enable commands before launching a sandboxed process (including bootstrap).");
|
|
728
|
+
}
|
|
729
|
+
const backend = selectedSandboxBackend(seams);
|
|
730
|
+
if (!backend && args.policy.permissions) {
|
|
731
|
+
throw new Error(`Cannot enforce sandbox permissions: ${sandboxUnavailableMessage(seams)}`);
|
|
732
|
+
}
|
|
733
|
+
return (backend ?? macOSSandboxBackend).buildCommand(args, seams);
|
|
462
734
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { failurePath, failureSummary } from "./failures.js";
|
|
4
|
+
import { readFailureState } from "./shared-failure-observations.js";
|
|
3
5
|
import { readLog } from "./logs.js";
|
|
4
6
|
import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
|
|
5
7
|
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
6
|
-
import {
|
|
8
|
+
import { listMetas, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
|
|
7
9
|
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
8
10
|
import { runTaskMaintenance } from "./maintenance.js";
|
|
9
11
|
import { ForegroundSandboxBlockedError } from "./sandbox.js";
|
|
10
12
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
11
13
|
import { isTerminalStatus } from "./types.js";
|
|
12
14
|
|
|
15
|
+
const JsonPathSchema = Type.String({
|
|
16
|
+
pattern: "^\\$",
|
|
17
|
+
description: "Root-prefixed JSON path, e.g. $.status, $.terminalFailure, or $.steps[0].status. Bare keys and wildcards are unsupported.",
|
|
18
|
+
});
|
|
19
|
+
|
|
13
20
|
const ConditionSchema = Type.Union([
|
|
14
21
|
Type.Object({ type: Type.Literal("exit_code"), equals: Type.Number() }),
|
|
15
22
|
Type.Object({ type: Type.Literal("stdout_contains"), value: Type.String() }),
|
|
16
23
|
Type.Object({ type: Type.Literal("stderr_contains"), value: Type.String() }),
|
|
17
|
-
Type.Object({ type: Type.Literal("json_path_equals"), path:
|
|
18
|
-
Type.Object({ type: Type.Literal("json_path_exists"), path:
|
|
24
|
+
Type.Object({ type: Type.Literal("json_path_equals"), path: JsonPathSchema, value: Type.Any() }),
|
|
25
|
+
Type.Object({ type: Type.Literal("json_path_exists"), path: JsonPathSchema }),
|
|
19
26
|
]);
|
|
20
27
|
|
|
21
28
|
const SshSchema = Type.Object({
|
|
@@ -115,7 +122,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
115
122
|
|
|
116
123
|
pi.on("session_start", async (_event, ctx) => {
|
|
117
124
|
activeSession = getCallbackOrigin(ctx);
|
|
118
|
-
for (const meta of
|
|
125
|
+
for (const meta of listMetasForOrigin(activeSession)) {
|
|
126
|
+
if (meta.status === "running" || (meta.callback !== false && !meta.callbackSentAt && !meta.callbackSuppressedAt)) {
|
|
127
|
+
resumeRunningTask(pi, meta, getActiveSession);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
119
130
|
runTaskMaintenance({ activeOrigin: activeSession });
|
|
120
131
|
});
|
|
121
132
|
pi.on("session_before_switch", () => {
|
|
@@ -326,23 +337,32 @@ export function formatLaunch(meta: BackgroundTaskMeta): string {
|
|
|
326
337
|
: "";
|
|
327
338
|
const setup = meta.remote?.bootstrapMessage ? ` Remote setup: ${meta.remote.bootstrapMessage}` : "";
|
|
328
339
|
const warning = meta.remote?.warning ? ` Warning: ${meta.remote.warning}` : "";
|
|
329
|
-
|
|
340
|
+
const failure = failureSummary(meta.id);
|
|
341
|
+
return `${failure ? `${failure}\n` : ""}Started background ${meta.kind} ${label}. Status: ${meta.status}.${remote}${setup}${warning} Log: ${meta.logPath}`;
|
|
330
342
|
}
|
|
331
343
|
|
|
332
344
|
function formatList(metas: BackgroundTaskMeta[]): string {
|
|
333
345
|
if (metas.length === 0) return "No background tasks found.";
|
|
334
346
|
return metas.map((meta) => {
|
|
347
|
+
const failure = failureSummary(meta.id);
|
|
335
348
|
const age = formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt);
|
|
336
349
|
const label = meta.name ? `${meta.name} ` : "";
|
|
337
350
|
const remote = meta.ssh ? ` ${meta.ssh.target}${meta.remote?.session ? ` ${meta.remote.session}` : ""}` : "";
|
|
338
|
-
return `${meta.id} ${label}${meta.kind} ${meta.status} ${age}${remote}`;
|
|
351
|
+
return `${failure ? `${failure}\n` : ""}${meta.id} ${label}${meta.kind} ${meta.status} ${age}${remote}`;
|
|
339
352
|
}).join("\n");
|
|
340
353
|
}
|
|
341
354
|
|
|
342
355
|
function formatStatus(meta: BackgroundTaskMeta | undefined, id?: string, options: { verbose?: boolean } = {}): string {
|
|
343
356
|
if (!meta) return `No background task found${id ? ` for id ${id}` : ""}.`;
|
|
344
|
-
|
|
345
|
-
|
|
357
|
+
const failure = failureSummary(meta.id);
|
|
358
|
+
if (!options.verbose) return [failure, formatCompactStatus(meta)].filter(Boolean).join("\n");
|
|
359
|
+
const state = readFailureState(failurePath(meta.id));
|
|
360
|
+
const observations = Object.values(state.observations);
|
|
361
|
+
return JSON.stringify(observations.length ? {
|
|
362
|
+
failureSummary: failure, failureJournal: failurePath(meta.id),
|
|
363
|
+
failureObservations: observations.map((observation) => ({ ...observation, attentionDeliveredAt: state.delivered[observation.id] })),
|
|
364
|
+
...meta,
|
|
365
|
+
} : meta, null, 2);
|
|
346
366
|
}
|
|
347
367
|
|
|
348
368
|
function formatCompactStatus(meta: BackgroundTaskMeta): string {
|
|
@@ -389,7 +409,8 @@ function formatLog(id: string, tailLines?: number): string {
|
|
|
389
409
|
if (!meta) return `No background task found for id ${id}.`;
|
|
390
410
|
const log = readLog(meta.logPath, tailLines ?? 5);
|
|
391
411
|
const prefix = log.truncated ? `[showing tail of ${meta.logPath}]\n` : `[${meta.logPath}]\n`;
|
|
392
|
-
|
|
412
|
+
const failure = failureSummary(id);
|
|
413
|
+
return `${failure ? `${failure}\n` : ""}${prefix}${log.text || "(log is empty)"}`;
|
|
393
414
|
}
|
|
394
415
|
|
|
395
416
|
function logText(id: string, tailLines?: number) {
|