pi-t3-bridge 0.8.0 → 0.8.1
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 +6 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/pstack/skills/poteto-mode/brief.md +2 -1
- package/src/pstack/child.ts +1 -1
- package/src/pstack/sandbox.ts +97 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Change log
|
|
2
2
|
|
|
3
|
+
## 0.8.1, 6 November 2026
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
Children now skip install scripts, so a root `prepare` such as `lefthook install` no longer fails on the read-only git hooks. With this setting npm also skips the pre and post scripts of `npm run`, while pnpm still runs them. A brief that needs a postinstall step, such as `next typegen`, should name the build or typecheck that runs it.
|
|
8
|
+
|
|
3
9
|
## 0.8.0, 5 November 2026
|
|
4
10
|
|
|
5
11
|
Version 0.8.0 removes the pi-amplike adapter (`amplike.ts`). The package no longer loads pi-amplike or changes its `/mode`, `/btw`, `/handoff`, and `subagent` for T3. Use T3's own `/btw`, `/side`, model picker, and `delegate_task` instead. If you set `{"source":"npm:pi-amplike","extensions":[]}` for the adapter, remove that entry from `~/.pi/agent/settings.json`.
|
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ You need the following programs:
|
|
|
29
29
|
|
|
30
30
|
A delegated child runs its shell commands in a sandbox, which is `sandbox-exec` on macOS and `bwrap` (bubblewrap) on Linux and WSL2. On Linux you need the `bubblewrap` package and unprivileged user namespaces. The Ubuntu `bubblewrap` package ships an AppArmor profile that lets `bwrap` run (checked on 26.04). If AppArmor still blocks `bwrap`, run `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`.
|
|
31
31
|
|
|
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
|
+
|
|
32
34
|
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.
|
|
33
35
|
|
|
34
36
|
## Install
|
package/package.json
CHANGED
|
@@ -60,7 +60,8 @@ ISSUES or BLOCKED: one line each with file:line and the command that shows it
|
|
|
60
60
|
|
|
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
|
-
- Every shell command runs in a
|
|
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
|
+
- 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`.
|
|
64
65
|
- `write` and `edit` only touch the listed files and `.scratch/`.
|
|
65
66
|
- Shell timeouts stop at the time limit. After it, or past the disk limit, every tool is refused and the child can only report.
|
|
66
67
|
- When the child ends its turn, the extension reruns the acceptance command, `git status` and `du`, and appends a "Checked by t3-pstack" block with the real exit codes and STATUS. It also writes a ledger line to `~/.local/state/t3-pstack/ledger.jsonl`.
|
package/src/pstack/child.ts
CHANGED
|
@@ -297,7 +297,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
297
297
|
state.gitDir = undefined;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
state.sandbox = await chooseSandbox(state.gitDir);
|
|
300
|
+
state.sandbox = await chooseSandbox(ctx.cwd, state.gitDir);
|
|
301
301
|
|
|
302
302
|
state.baselineKb = await duKb(ctx.cwd);
|
|
303
303
|
|
package/src/pstack/sandbox.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
@@ -34,10 +34,17 @@ const isProbeFailure = compile(
|
|
|
34
34
|
)
|
|
35
35
|
).check;
|
|
36
36
|
|
|
37
|
+
// Settled once per child: its own XDG_RUNTIME_DIR, and the pnpm store it
|
|
38
|
+
// shares with the parent.
|
|
39
|
+
interface ChildEnv {
|
|
40
|
+
readonly pnpmStore: string | undefined;
|
|
41
|
+
readonly runtimeDir: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
export type Sandbox =
|
|
38
|
-
| { kind: "bubblewrap"; bwrap: string }
|
|
39
|
-
| { kind: "off" }
|
|
40
|
-
| { kind: "sandbox-exec" }
|
|
45
|
+
| { kind: "bubblewrap"; bwrap: string; env: ChildEnv }
|
|
46
|
+
| { kind: "off"; env: ChildEnv }
|
|
47
|
+
| { kind: "sandbox-exec"; env: ChildEnv }
|
|
41
48
|
| { kind: "unavailable"; reason: string };
|
|
42
49
|
|
|
43
50
|
const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
@@ -50,8 +57,30 @@ export const real = (target: string): string => {
|
|
|
50
57
|
}
|
|
51
58
|
};
|
|
52
59
|
|
|
53
|
-
//
|
|
54
|
-
|
|
60
|
+
// The store pnpm picks for this workdir outside the sandbox, from project and
|
|
61
|
+
// user config alike. Undefined when pnpm isn't installed. A store at or above
|
|
62
|
+
// home would open all of home, so it stays unbound.
|
|
63
|
+
const pnpmStore = async (workdir: string): Promise<string | undefined> => {
|
|
64
|
+
try {
|
|
65
|
+
const { stdout } = await run("pnpm", ["store", "path"], {
|
|
66
|
+
cwd: workdir,
|
|
67
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const store = path.dirname(stdout.trim());
|
|
71
|
+
const fromHome = path.relative(store, homedir());
|
|
72
|
+
|
|
73
|
+
return path.isAbsolute(store) && fromHome.startsWith("..")
|
|
74
|
+
? store
|
|
75
|
+
: undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Tool caches, installs and the pnpm store stay writable, so uv, npm, pnpm
|
|
82
|
+
// and mise work.
|
|
83
|
+
const cacheRoots = (store: string | undefined): string[] => {
|
|
55
84
|
const home = homedir();
|
|
56
85
|
|
|
57
86
|
return [
|
|
@@ -59,14 +88,19 @@ const cacheRoots = (): string[] => {
|
|
|
59
88
|
path.join(home, ".npm"),
|
|
60
89
|
path.join(home, ".local", "share", "uv"),
|
|
61
90
|
path.join(home, ".local", "share", "mise"),
|
|
91
|
+
...(store === undefined ? [] : [store]),
|
|
62
92
|
];
|
|
63
93
|
};
|
|
64
94
|
|
|
65
|
-
const writableRoots = (
|
|
95
|
+
const writableRoots = (
|
|
96
|
+
workdir: string,
|
|
97
|
+
gitDir: string | undefined,
|
|
98
|
+
store: string | undefined
|
|
99
|
+
) =>
|
|
66
100
|
[
|
|
67
101
|
workdir,
|
|
68
102
|
gitDir,
|
|
69
|
-
...cacheRoots(),
|
|
103
|
+
...cacheRoots(store),
|
|
70
104
|
path.join(homedir(), "Library", "Caches"),
|
|
71
105
|
"/private/var/folders",
|
|
72
106
|
tmpdir(),
|
|
@@ -114,7 +148,8 @@ const bwrapArgs = (
|
|
|
114
148
|
bwrap: string,
|
|
115
149
|
command: string,
|
|
116
150
|
workdir: string,
|
|
117
|
-
gitDir: string | undefined
|
|
151
|
+
gitDir: string | undefined,
|
|
152
|
+
store: string | undefined
|
|
118
153
|
): string[] => [
|
|
119
154
|
bwrap,
|
|
120
155
|
"--ro-bind",
|
|
@@ -125,7 +160,7 @@ const bwrapArgs = (
|
|
|
125
160
|
"/dev",
|
|
126
161
|
"--proc",
|
|
127
162
|
"/proc",
|
|
128
|
-
...existing(writableRoots(workdir, gitDir)).flatMap((root) =>
|
|
163
|
+
...existing(writableRoots(workdir, gitDir, store)).flatMap((root) =>
|
|
129
164
|
root === "/dev" ? [] : ["--bind", root, root]
|
|
130
165
|
),
|
|
131
166
|
...existing(
|
|
@@ -158,10 +193,18 @@ const probeBwrap = async (bwrap: string): Promise<string | undefined> => {
|
|
|
158
193
|
|
|
159
194
|
// A missing root can't be bound, and a missing hooks directory could be
|
|
160
195
|
// created by the child, so both are made on the host first. They are empty
|
|
161
|
-
// directories, so git and the tools see no change.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
// directories, so git and the tools see no change. A root the user can't
|
|
197
|
+
// create stays unbound.
|
|
198
|
+
const prepareBinds = (
|
|
199
|
+
gitDir: string | undefined,
|
|
200
|
+
store: string | undefined
|
|
201
|
+
): void => {
|
|
202
|
+
for (const root of cacheRoots(store)) {
|
|
203
|
+
try {
|
|
204
|
+
mkdirSync(root, { recursive: true });
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
165
208
|
}
|
|
166
209
|
|
|
167
210
|
if (gitDir !== undefined) {
|
|
@@ -169,17 +212,23 @@ const prepareBinds = (gitDir: string | undefined): void => {
|
|
|
169
212
|
}
|
|
170
213
|
};
|
|
171
214
|
|
|
215
|
+
const childEnv = async (workdir: string): Promise<ChildEnv> => ({
|
|
216
|
+
pnpmStore: await pnpmStore(workdir),
|
|
217
|
+
runtimeDir: mkdtempSync(path.join(tmpdir(), "pi-t3-child-")),
|
|
218
|
+
});
|
|
219
|
+
|
|
172
220
|
// Run once per child, before its first command. "childSandbox": "off" in the
|
|
173
221
|
// config wins on every platform.
|
|
174
222
|
export const chooseSandbox = async (
|
|
223
|
+
workdir: string,
|
|
175
224
|
gitDir: string | undefined
|
|
176
225
|
): Promise<Sandbox> => {
|
|
177
226
|
if (readConfig().childSandbox === "off") {
|
|
178
|
-
return { kind: "off" };
|
|
227
|
+
return { env: await childEnv(workdir), kind: "off" };
|
|
179
228
|
}
|
|
180
229
|
|
|
181
230
|
if (process.platform === "darwin" && existsSync(SANDBOX)) {
|
|
182
|
-
return { kind: "sandbox-exec" };
|
|
231
|
+
return { env: await childEnv(workdir), kind: "sandbox-exec" };
|
|
183
232
|
}
|
|
184
233
|
|
|
185
234
|
if (process.platform !== "linux") {
|
|
@@ -204,11 +253,24 @@ export const chooseSandbox = async (
|
|
|
204
253
|
return { kind: "unavailable", reason: problem };
|
|
205
254
|
}
|
|
206
255
|
|
|
207
|
-
|
|
256
|
+
const env = await childEnv(workdir);
|
|
208
257
|
|
|
209
|
-
|
|
258
|
+
prepareBinds(gitDir, env.pnpmStore);
|
|
259
|
+
|
|
260
|
+
return { bwrap, env, kind: "bubblewrap" };
|
|
210
261
|
};
|
|
211
262
|
|
|
263
|
+
// pnpm 12 takes its store lock in $XDG_RUNTIME_DIR, which the sandbox keeps
|
|
264
|
+
// read-only, so each child gets its own. Each bwrap bind is its own mount, and
|
|
265
|
+
// Linux refuses a hardlink between mounts, so pnpm would build a second store
|
|
266
|
+
// in the workdir. Naming the store keeps the one pnpm picked for the parent,
|
|
267
|
+
// and pnpm copies from it. Install scripts stay off, so a `prepare` such as `lefthook install`
|
|
268
|
+
// doesn't fail on the read-only hooks. npm and yarn read npm_config_*, pnpm 11
|
|
269
|
+
// and later read pnpm_config_*. npm also skips pre and post scripts of
|
|
270
|
+
// `npm run` then.
|
|
271
|
+
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}`;
|
|
273
|
+
|
|
212
274
|
// Undefined when no sandbox runs: the caller must not run the command.
|
|
213
275
|
export const wrap = (
|
|
214
276
|
sandbox: Sandbox | undefined,
|
|
@@ -218,17 +280,29 @@ export const wrap = (
|
|
|
218
280
|
): string | undefined => {
|
|
219
281
|
switch (sandbox?.kind) {
|
|
220
282
|
case "sandbox-exec": {
|
|
221
|
-
return
|
|
283
|
+
return exportEnv(
|
|
284
|
+
sandbox.env,
|
|
285
|
+
`${SANDBOX} -p ${quote(sandboxProfile(writableRoots(workdir, gitDir, sandbox.env.pnpmStore), gitDenies(gitDir)))} /bin/bash -c ${quote(command)}`
|
|
286
|
+
);
|
|
222
287
|
}
|
|
223
288
|
|
|
224
289
|
case "bubblewrap": {
|
|
225
|
-
return
|
|
226
|
-
.
|
|
227
|
-
|
|
290
|
+
return exportEnv(
|
|
291
|
+
sandbox.env,
|
|
292
|
+
bwrapArgs(
|
|
293
|
+
sandbox.bwrap,
|
|
294
|
+
command,
|
|
295
|
+
workdir,
|
|
296
|
+
gitDir,
|
|
297
|
+
sandbox.env.pnpmStore
|
|
298
|
+
)
|
|
299
|
+
.map(quote)
|
|
300
|
+
.join(" ")
|
|
301
|
+
);
|
|
228
302
|
}
|
|
229
303
|
|
|
230
304
|
case "off": {
|
|
231
|
-
return command;
|
|
305
|
+
return exportEnv(sandbox.env, command);
|
|
232
306
|
}
|
|
233
307
|
|
|
234
308
|
default: {
|