pi-t3-bridge 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/pstack/skills/poteto-mode/brief.md +3 -0
- package/src/bridge/state.ts +1 -0
- package/src/bridge/steer.ts +39 -21
- package/src/pstack/brief.ts +53 -12
- package/src/pstack/child.ts +77 -11
- package/src/pstack/sandbox.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Change log
|
|
2
2
|
|
|
3
|
+
## 0.8.3, 6 November 2026
|
|
4
|
+
|
|
5
|
+
Version 0.8.3 hardens the child sandbox from a large orchestration run. A bullet under "Files you may write" may now list several paths; every token with a slash or a glob counts. The disk limit no longer counts `node_modules`, which pnpm fills from the store, so a 500 MB limit no longer fails on a pnpm monorepo install. On Linux the child runs in its own PID namespace and cannot see or signal host processes, and anything a command starts in the background ends with that command. The child env now sets `TMPDIR`. Chrome, Electron and headless shells need `--no-sandbox` inside the sandbox, because their own namespace sandbox cannot nest under bwrap, and the sandbox already confines them; this is now documented, along with `PI_CODING_AGENT_DIR` for running `pi` inside a child.
|
|
6
|
+
|
|
7
|
+
## 0.8.2, 6 November 2026
|
|
8
|
+
|
|
9
|
+
Version 0.8.2 closes a hole in the child sandbox. A delegated child whose brief missed a section or a header field ran with no sandbox and no limits, because the bridge treated it as the user's own session. Such a child could rewrite the parent's git hooks. Now every tool in that child is blocked, and the block lists what the brief is missing, so the parent can resend a complete one. A top-level session is never blocked, even when its first message starts with `# Task:`.
|
|
10
|
+
|
|
3
11
|
## 0.8.1, 6 November 2026
|
|
4
12
|
|
|
5
13
|
Version 0.8.1 lets a delegated child run `pnpm install` and `git commit` in a pnpm and lefthook repo, under `bwrap` on Linux and `sandbox-exec` on macOS. Each child gets its own `XDG_RUNTIME_DIR` in the temp folder, where pnpm 12 takes its store lock. The sandbox can now write to the store that `pnpm store path` names for the workdir, and the child uses that store, so it shares the parent's store and does not build a second one in the workdir.
|
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ A delegated child runs its shell commands in a sandbox, which is `sandbox-exec`
|
|
|
31
31
|
|
|
32
32
|
Inside the sandbox a child can write only to its workdir, its git directory, the tool caches, the pnpm store, and the system temp folder. The git hooks and git config stay read-only, because the parent runs them later outside the sandbox. Each child gets its own `XDG_RUNTIME_DIR` in the temp folder, where pnpm takes its store lock. The child also gets `pnpm_config_store_dir` set to the store that `pnpm store path` names for the workdir outside the sandbox, so it shares the parent's store. Under `bwrap`, pnpm copies packages from the store, because Linux refuses hardlinks across the sandbox's mounts. Children skip install scripts (`npm_config_ignore_scripts` and `pnpm_config_ignore_scripts` are `true`), so a root `prepare` such as `lefthook install` does not run. With this setting npm also skips the pre and post scripts of `npm run` and `npm test`, while pnpm still runs them. Steps that only a postinstall runs must run through the build or typecheck the brief names.
|
|
33
33
|
|
|
34
|
+
The child also gets `TMPDIR` set. On Linux the child cannot see or signal processes outside its own commands, so a stray `pkill` cannot reach a daemon on the host, and anything a command starts in the background dies when that command ends. Chrome, Electron and headless shells need `--no-sandbox` inside the sandbox, because their own namespace sandbox cannot nest under bwrap; the sandbox already confines them. A `pi` run inside the sandbox needs `PI_CODING_AGENT_DIR` pointing at a writable temp copy of `~/.pi/agent`, because the real one is read-only. The disk limit in a brief ignores `node_modules`, which pnpm fills from the store.
|
|
35
|
+
|
|
34
36
|
Native Windows and WSL1 have no sandbox. Where no sandbox can run, the bridge blocks a child's shell commands, unless you set `childSandbox` to `"off"` in the settings file.
|
|
35
37
|
|
|
36
38
|
## Install
|
|
@@ -150,7 +152,9 @@ on(run about to stop)
|
|
|
150
152
|
ask the classifier how the run ended
|
|
151
153
|
if the classifier is down or takes more than 5 seconds
|
|
152
154
|
let the run stop
|
|
153
|
-
if the agent gave up partway
|
|
155
|
+
if the agent gave up partway
|
|
156
|
+
if a stop note was already sent for the current prompt
|
|
157
|
+
log the check as capped in steer.jsonl and let the run stop
|
|
154
158
|
add a note
|
|
155
159
|
continue for one more turn
|
|
156
160
|
|
|
@@ -230,7 +234,7 @@ To choose your own models, run the `setup-pstack` skill, which writes a model sh
|
|
|
230
234
|
|
|
231
235
|
### Limits for delegated children
|
|
232
236
|
|
|
233
|
-
When the first message of a session is a complete pstack brief, the extension treats the session as a delegated child and holds it to the limits in the brief. The child can't lift the limits through anything it writes.
|
|
237
|
+
When the first message of a session is a complete pstack brief, the extension treats the session as a delegated child and holds it to the limits in the brief. The child can't lift the limits through anything it writes. A T3-delegated child whose brief has a `# Task:` heading but misses a section or a header field gets every tool blocked, with a list of what is missing, so it never runs without the sandbox.
|
|
234
238
|
|
|
235
239
|
```mermaid
|
|
236
240
|
sequenceDiagram
|
package/package.json
CHANGED
|
@@ -61,6 +61,9 @@ ISSUES or BLOCKED: one line each with file:line and the command that shows it
|
|
|
61
61
|
The child's own pstack extension reads this brief from its first message and enforces it, whatever the model does:
|
|
62
62
|
|
|
63
63
|
- Every shell command runs in a sandbox (`sandbox-exec` on macOS, `bwrap` on Linux) that can write only to the workdir, its git directory, the tool caches, the pnpm store and the system temp folder. Git hooks and git config stay read-only.
|
|
64
|
+
- All seven sections of this template are required. A delegated child whose brief misses one gets every tool blocked, so the parent must resend a complete brief.
|
|
65
|
+
- In "Files you may write", a bullet may list several paths. Every token with a slash or a glob counts, as in `- packages/a/** and packages/b/c.ts`.
|
|
66
|
+
- The disk limit ignores `node_modules`, which pnpm fills from the store, but build output still counts.
|
|
64
67
|
- Install scripts are skipped, so `pnpm install` or `npm install` runs no `prepare` or `postinstall`. Don't ask for `prepare` to pass. If a postinstall generates something, such as `next typegen`, name the build or typecheck that runs it in the acceptance command. npm also skips `pre` and `post` scripts of `npm run` and `npm test`, so name those too, as in `npm run prebuild && npm test`.
|
|
65
68
|
- `write` and `edit` only touch the listed files and `.scratch/`.
|
|
66
69
|
- Shell timeouts stop at the time limit. After it, or past the disk limit, every tool is refused and the child can only report.
|
package/src/bridge/state.ts
CHANGED
|
@@ -121,6 +121,7 @@ export const appendLedger = (
|
|
|
121
121
|
// One line per steer check, beside the ledger. It holds the verdict and never
|
|
122
122
|
// transcript text. A log that cannot be written must not break a session.
|
|
123
123
|
export const appendSteerLog = (line: {
|
|
124
|
+
readonly capped?: boolean;
|
|
124
125
|
readonly error?: string;
|
|
125
126
|
readonly fired?: readonly string[];
|
|
126
127
|
readonly kind: string;
|
package/src/bridge/steer.ts
CHANGED
|
@@ -37,8 +37,13 @@ import { isDelegatedChild } from "./thread.ts";
|
|
|
37
37
|
// github.com/dbreunig/pi-mast-steer), which only adds notes and never
|
|
38
38
|
// continues a stopped run.
|
|
39
39
|
|
|
40
|
-
// Per user prompt, so a model that keeps quitting cannot loop.
|
|
41
|
-
|
|
40
|
+
// Per user prompt, so a model that keeps quitting cannot loop. A model that
|
|
41
|
+
// stops again after a stop nudge has already reconsidered, and every second
|
|
42
|
+
// stop nudge in the user's sessions was wrong. A reply cut off at the output
|
|
43
|
+
// limit can need more than one continuation.
|
|
44
|
+
const NUDGE_LIMITS = { length: 2, stop: 1 } as const;
|
|
45
|
+
|
|
46
|
+
type NudgeGroup = keyof typeof NUDGE_LIMITS;
|
|
42
47
|
|
|
43
48
|
const UNAWARE_OF_TERMINATION = "1.5";
|
|
44
49
|
|
|
@@ -79,17 +84,28 @@ const nudge = (
|
|
|
79
84
|
],
|
|
80
85
|
});
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
const noteDetails = compile(obj({ kind: opt(str()) }, { open: true }));
|
|
88
|
+
|
|
89
|
+
const groupOf = (entry: SessionEntry): NudgeGroup | undefined => {
|
|
90
|
+
if (entry.type !== "custom_message" || entry.customType !== STEER_NOTE) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return noteDetails.check(entry.details) && entry.details.kind === "length"
|
|
95
|
+
? "length"
|
|
96
|
+
: "stop";
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Counted from the branch, so the limit holds across a reload. A nudge is a
|
|
83
100
|
// custom message, not a user message, so it does not reset the count.
|
|
84
|
-
const
|
|
101
|
+
const atLimit = (ctx: ExtensionContext, group: NudgeGroup): boolean => {
|
|
85
102
|
const branch = ctx.sessionManager.getBranch();
|
|
86
103
|
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(entry) =>
|
|
91
|
-
|
|
92
|
-
).length;
|
|
104
|
+
return (
|
|
105
|
+
branch
|
|
106
|
+
.slice(branch.findLastIndex(isUser) + 1)
|
|
107
|
+
.filter((entry) => groupOf(entry) === group).length >= NUDGE_LIMITS[group]
|
|
108
|
+
);
|
|
93
109
|
};
|
|
94
110
|
|
|
95
111
|
// The newest note this feature added in the prompt, of either kind. MAST 1.5
|
|
@@ -441,23 +457,21 @@ export const registerSteer = (pi: ExtensionAPI): void => {
|
|
|
441
457
|
return;
|
|
442
458
|
}
|
|
443
459
|
|
|
444
|
-
if (nudgesThisPrompt(ctx) >= MAX_NUDGES) {
|
|
445
|
-
tell(ctx, "steer: limit reached", "info");
|
|
446
|
-
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
460
|
const base = {
|
|
451
461
|
mode,
|
|
452
462
|
model: last.model,
|
|
453
463
|
session: ctx.sessionManager.getSessionId(),
|
|
454
464
|
};
|
|
455
465
|
|
|
456
|
-
// Shadow mode only judges and logs, so it never continues a run.
|
|
466
|
+
// Shadow mode only judges and logs, so it never continues a run. A nudge
|
|
467
|
+
// the limit holds back is logged as capped and not shown.
|
|
457
468
|
if (last.stopReason === "length") {
|
|
458
|
-
|
|
469
|
+
const capped = mode === "on" && atLimit(ctx, "length");
|
|
470
|
+
const nudged = mode === "on" && !capped;
|
|
471
|
+
|
|
472
|
+
appendSteerLog({ ...base, capped, kind: "length", nudged });
|
|
459
473
|
|
|
460
|
-
return
|
|
474
|
+
return nudged ? nudge(event, "length") : undefined;
|
|
461
475
|
}
|
|
462
476
|
|
|
463
477
|
const input =
|
|
@@ -488,9 +502,13 @@ export const registerSteer = (pi: ExtensionAPI): void => {
|
|
|
488
502
|
}
|
|
489
503
|
|
|
490
504
|
const chosen = chooseNudge(ctx, verdict);
|
|
491
|
-
const nudged = mode === "on" && chosen !== undefined;
|
|
492
505
|
|
|
493
|
-
|
|
506
|
+
const capped =
|
|
507
|
+
mode === "on" && chosen !== undefined && atLimit(ctx, "stop");
|
|
508
|
+
|
|
509
|
+
const nudged = mode === "on" && chosen !== undefined && !capped;
|
|
510
|
+
|
|
511
|
+
appendSteerLog({ ...base, capped, kind: "stop", nudged, verdict });
|
|
494
512
|
|
|
495
513
|
return nudged ? nudge(event, chosen.kind, chosen.probability) : undefined;
|
|
496
514
|
});
|
package/src/pstack/brief.ts
CHANGED
|
@@ -64,9 +64,26 @@ const listItems = (section: string | undefined): string[] =>
|
|
|
64
64
|
return [];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// A bullet may list several paths. Every token with a slash or a glob
|
|
68
|
+
// counts, so "a/** and b/c.ts" writes both. A bullet with no such
|
|
69
|
+
// token keeps only its first word, which stays the old behavior for
|
|
70
|
+
// plain file names.
|
|
71
|
+
const tokens = item
|
|
72
|
+
.replaceAll("`", "")
|
|
73
|
+
.trim()
|
|
74
|
+
.split(/[\s,]+/u)
|
|
75
|
+
.map((token) =>
|
|
76
|
+
token
|
|
77
|
+
.replace(/^[([{\u201C']+/u, "")
|
|
78
|
+
.replace(/[.,;)\]}\u201D']+$/u, "")
|
|
79
|
+
)
|
|
80
|
+
.filter((token) => token !== "");
|
|
81
|
+
|
|
82
|
+
const paths = tokens.filter(
|
|
83
|
+
(token) => token.includes("/") || token.includes("*")
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return paths.length > 0 ? paths : [tokens[0] ?? ""];
|
|
70
87
|
})
|
|
71
88
|
.filter((item) => item !== "");
|
|
72
89
|
|
|
@@ -253,10 +270,9 @@ const dispatchable = (brief: Brief): boolean =>
|
|
|
253
270
|
brief.diskMb !== undefined &&
|
|
254
271
|
SECTIONS.every((section) => brief.sections.has(section));
|
|
255
272
|
|
|
256
|
-
|
|
257
|
-
export const sessionBrief = (
|
|
273
|
+
const firstMessage = (
|
|
258
274
|
ctx: ExtensionContext
|
|
259
|
-
): { readonly
|
|
275
|
+
): { readonly at: number; readonly text: string } | undefined => {
|
|
260
276
|
const first = ctx.sessionManager
|
|
261
277
|
.getBranch()
|
|
262
278
|
.find((entry) => entry.type === "message" && entry.message.role === "user");
|
|
@@ -267,13 +283,38 @@ export const sessionBrief = (
|
|
|
267
283
|
|
|
268
284
|
const { content } = first.message;
|
|
269
285
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
286
|
+
return {
|
|
287
|
+
at: Date.parse(first.timestamp),
|
|
288
|
+
text: isString(content)
|
|
289
|
+
? content
|
|
290
|
+
: content
|
|
291
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
292
|
+
.join("\n"),
|
|
293
|
+
};
|
|
294
|
+
};
|
|
273
295
|
|
|
274
|
-
|
|
296
|
+
// The brief that started this session, when it is a delegated child.
|
|
297
|
+
export const sessionBrief = (
|
|
298
|
+
ctx: ExtensionContext
|
|
299
|
+
): { readonly brief: Brief; readonly startedAt: number } | undefined => {
|
|
300
|
+
const first = firstMessage(ctx);
|
|
301
|
+
const brief = first === undefined ? undefined : parseBrief(first.text);
|
|
302
|
+
|
|
303
|
+
return first === undefined || brief === undefined || !dispatchable(brief)
|
|
304
|
+
? undefined
|
|
305
|
+
: { brief, startedAt: first.at };
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// What is wrong with a brief that has a `# Task:` heading but is not complete
|
|
309
|
+
// enough to hold a child to. Undefined when there is no such brief.
|
|
310
|
+
export const incompleteBrief = (
|
|
311
|
+
ctx: ExtensionContext,
|
|
312
|
+
rules: BriefRules
|
|
313
|
+
): string[] | undefined => {
|
|
314
|
+
const first = firstMessage(ctx);
|
|
315
|
+
const brief = first === undefined ? undefined : parseBrief(first.text);
|
|
275
316
|
|
|
276
|
-
return brief === undefined ||
|
|
317
|
+
return first === undefined || brief === undefined || dispatchable(brief)
|
|
277
318
|
? undefined
|
|
278
|
-
:
|
|
319
|
+
: briefProblems(first.text, rules);
|
|
279
320
|
};
|
package/src/pstack/child.ts
CHANGED
|
@@ -22,9 +22,9 @@ import {
|
|
|
22
22
|
|
|
23
23
|
import { bashTimeoutSeconds } from "../bridge/bash-timeout.ts";
|
|
24
24
|
import { appendLedger } from "../bridge/state.ts";
|
|
25
|
-
import { ownThreadId } from "../bridge/thread.ts";
|
|
26
|
-
import { sessionBrief } from "./brief.ts";
|
|
27
|
-
import type { Brief } from "./brief.ts";
|
|
25
|
+
import { isDelegatedChild, ownThreadId } from "../bridge/thread.ts";
|
|
26
|
+
import { incompleteBrief, sessionBrief } from "./brief.ts";
|
|
27
|
+
import type { Brief, BriefRules } from "./brief.ts";
|
|
28
28
|
import { policyPath } from "./resources.ts";
|
|
29
29
|
import {
|
|
30
30
|
chooseSandbox,
|
|
@@ -93,13 +93,33 @@ export const allowedFile = (brief: Brief, relative: string): boolean =>
|
|
|
93
93
|
relative.startsWith(SCRATCH) ||
|
|
94
94
|
brief.files.some((file) => globToRegExp(file).test(relative));
|
|
95
95
|
|
|
96
|
+
const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
97
|
+
|
|
96
98
|
const duKb = async (target: string): Promise<number | undefined> => {
|
|
97
99
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
100
|
+
// node_modules is left out: pnpm fills it from the store, so its size is
|
|
101
|
+
// bounded by the lockfile and says nothing about runaway usage.
|
|
102
|
+
const { stdout } = await run(
|
|
103
|
+
"/bin/bash",
|
|
104
|
+
[
|
|
105
|
+
"-c",
|
|
106
|
+
`du -sk ${quote(target)} && find ${quote(target)} -name node_modules -prune -type d -exec du -sk {} +`,
|
|
107
|
+
],
|
|
108
|
+
{ timeout: DU_TIMEOUT_MS }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const lines = stdout.trim().split("\n");
|
|
112
|
+
const total = Number(lines[0]?.split(/\s/u)[0]);
|
|
101
113
|
|
|
102
|
-
|
|
114
|
+
if (!Number.isFinite(total)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const modules = lines
|
|
119
|
+
.slice(1)
|
|
120
|
+
.reduce((sum, line) => sum + Number(line.split(/\s/u)[0]), 0);
|
|
121
|
+
|
|
122
|
+
return total >= modules ? total - modules : total;
|
|
103
123
|
} catch {
|
|
104
124
|
return undefined;
|
|
105
125
|
}
|
|
@@ -122,10 +142,22 @@ const isExecFailure = compile(
|
|
|
122
142
|
)
|
|
123
143
|
).check;
|
|
124
144
|
|
|
145
|
+
const isLimits = compile(obj({ files: num(), minutes: num() })).check;
|
|
146
|
+
|
|
125
147
|
const isPolicy = compile(
|
|
126
148
|
obj(
|
|
127
149
|
{
|
|
128
|
-
briefs: opt(
|
|
150
|
+
briefs: opt(
|
|
151
|
+
obj(
|
|
152
|
+
{
|
|
153
|
+
noWrites: opt(arr(str())),
|
|
154
|
+
redBeforeGreen: opt(arr(str())),
|
|
155
|
+
sizes: opt(obj({}, { open: true })),
|
|
156
|
+
},
|
|
157
|
+
{ open: true }
|
|
158
|
+
)
|
|
159
|
+
),
|
|
160
|
+
jobs: opt(obj({}, { open: true })),
|
|
129
161
|
},
|
|
130
162
|
{ open: true }
|
|
131
163
|
)
|
|
@@ -139,10 +171,27 @@ const isPathInput = compile(obj({ path: str() }, { open: true })).check;
|
|
|
139
171
|
|
|
140
172
|
const isNumber = compile(num()).check;
|
|
141
173
|
|
|
142
|
-
const
|
|
174
|
+
const readPolicy = () => {
|
|
143
175
|
const policy: unknown = JSON.parse(readFileSync(policyPath(), "utf-8"));
|
|
144
176
|
|
|
145
|
-
return isPolicy(policy) ?
|
|
177
|
+
return isPolicy(policy) ? policy : {};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const readRedBeforeGreen = (): readonly string[] =>
|
|
181
|
+
readPolicy().briefs?.redBeforeGreen ?? [];
|
|
182
|
+
|
|
183
|
+
const readBriefRules = (): BriefRules => {
|
|
184
|
+
const { briefs, jobs } = readPolicy();
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
jobs: Object.keys(jobs ?? {}),
|
|
188
|
+
noWrites: briefs?.noWrites ?? [],
|
|
189
|
+
sizes: Object.fromEntries(
|
|
190
|
+
Object.entries(briefs?.sizes ?? {}).flatMap(([size, limits]) =>
|
|
191
|
+
isLimits(limits) ? [[size, limits] as const] : []
|
|
192
|
+
)
|
|
193
|
+
),
|
|
194
|
+
};
|
|
146
195
|
};
|
|
147
196
|
|
|
148
197
|
const changedFiles = async (cwd: string): Promise<string[]> => {
|
|
@@ -227,6 +276,7 @@ interface ChildState {
|
|
|
227
276
|
diskCheck: Promise<void>;
|
|
228
277
|
diskOver: boolean;
|
|
229
278
|
diskQueued: boolean;
|
|
279
|
+
delegated: boolean | undefined;
|
|
230
280
|
gitDir: string | undefined;
|
|
231
281
|
greenAtStart: boolean;
|
|
232
282
|
ready: boolean;
|
|
@@ -237,6 +287,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
237
287
|
const state: ChildState = {
|
|
238
288
|
baselineKb: undefined,
|
|
239
289
|
before: undefined,
|
|
290
|
+
delegated: undefined,
|
|
240
291
|
diskCheck: Promise.resolve(),
|
|
241
292
|
diskOver: false,
|
|
242
293
|
diskQueued: false,
|
|
@@ -331,7 +382,22 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
331
382
|
const started = sessionBrief(ctx);
|
|
332
383
|
|
|
333
384
|
if (started === undefined) {
|
|
334
|
-
|
|
385
|
+
// A delegated child whose brief is incomplete would otherwise run with
|
|
386
|
+
// no sandbox and no limits, as if it were the user's own session.
|
|
387
|
+
const problems = incompleteBrief(ctx, readBriefRules());
|
|
388
|
+
|
|
389
|
+
if (problems === undefined) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
state.delegated ??= await isDelegatedChild();
|
|
394
|
+
|
|
395
|
+
return state.delegated
|
|
396
|
+
? {
|
|
397
|
+
block: true,
|
|
398
|
+
reason: `This delegated task's brief is incomplete, so it can't run in the sandbox and every tool is blocked. Use no more tools. Report STATUS: BLOCKED (incomplete brief) with these problems, so the parent can resend a complete brief:\n- ${problems.join("\n- ")}`,
|
|
399
|
+
}
|
|
400
|
+
: undefined;
|
|
335
401
|
}
|
|
336
402
|
|
|
337
403
|
const { brief, startedAt } = started;
|
package/src/pstack/sandbox.ts
CHANGED
|
@@ -158,6 +158,11 @@ const bwrapArgs = (
|
|
|
158
158
|
"--dev-bind",
|
|
159
159
|
"/dev",
|
|
160
160
|
"/dev",
|
|
161
|
+
// A child cannot see or signal processes outside its own commands, so a
|
|
162
|
+
// stray pkill from a child cannot reach a daemon on the host. What a
|
|
163
|
+
// command starts in the background dies when that command ends.
|
|
164
|
+
"--die-with-parent",
|
|
165
|
+
"--unshare-pid",
|
|
161
166
|
"--proc",
|
|
162
167
|
"/proc",
|
|
163
168
|
...existing(writableRoots(workdir, gitDir, store)).flatMap((root) =>
|
|
@@ -269,7 +274,7 @@ export const chooseSandbox = async (
|
|
|
269
274
|
// and later read pnpm_config_*. npm also skips pre and post scripts of
|
|
270
275
|
// `npm run` then.
|
|
271
276
|
const exportEnv = (env: ChildEnv, command: string): string =>
|
|
272
|
-
`export XDG_RUNTIME_DIR=${quote(env.runtimeDir)}${env.pnpmStore === undefined ? "" : ` pnpm_config_store_dir=${quote(env.pnpmStore)}`} npm_config_ignore_scripts=true pnpm_config_ignore_scripts=true; ${command}`;
|
|
277
|
+
`export XDG_RUNTIME_DIR=${quote(env.runtimeDir)} TMPDIR=${quote(tmpdir())}${env.pnpmStore === undefined ? "" : ` pnpm_config_store_dir=${quote(env.pnpmStore)}`} npm_config_ignore_scripts=true pnpm_config_ignore_scripts=true; ${command}`;
|
|
273
278
|
|
|
274
279
|
// Undefined when no sandbox runs: the caller must not run the command.
|
|
275
280
|
export const wrap = (
|