pi-t3-bridge 0.8.0 → 0.8.2
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 +10 -0
- package/README.md +3 -1
- package/package.json +1 -1
- package/pstack/skills/poteto-mode/brief.md +2 -1
- package/src/pstack/brief.ts +33 -9
- package/src/pstack/child.ts +54 -8
- package/src/pstack/sandbox.ts +97 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Change log
|
|
2
2
|
|
|
3
|
+
## 0.8.2, 6 November 2026
|
|
4
|
+
|
|
5
|
+
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:`.
|
|
6
|
+
|
|
7
|
+
## 0.8.1, 6 November 2026
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
3
13
|
## 0.8.0, 5 November 2026
|
|
4
14
|
|
|
5
15
|
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
|
|
@@ -228,7 +230,7 @@ To choose your own models, run the `setup-pstack` skill, which writes a model sh
|
|
|
228
230
|
|
|
229
231
|
### Limits for delegated children
|
|
230
232
|
|
|
231
|
-
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.
|
|
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. 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.
|
|
232
234
|
|
|
233
235
|
```mermaid
|
|
234
236
|
sequenceDiagram
|
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/brief.ts
CHANGED
|
@@ -253,10 +253,9 @@ const dispatchable = (brief: Brief): boolean =>
|
|
|
253
253
|
brief.diskMb !== undefined &&
|
|
254
254
|
SECTIONS.every((section) => brief.sections.has(section));
|
|
255
255
|
|
|
256
|
-
|
|
257
|
-
export const sessionBrief = (
|
|
256
|
+
const firstMessage = (
|
|
258
257
|
ctx: ExtensionContext
|
|
259
|
-
): { readonly
|
|
258
|
+
): { readonly at: number; readonly text: string } | undefined => {
|
|
260
259
|
const first = ctx.sessionManager
|
|
261
260
|
.getBranch()
|
|
262
261
|
.find((entry) => entry.type === "message" && entry.message.role === "user");
|
|
@@ -267,13 +266,38 @@ export const sessionBrief = (
|
|
|
267
266
|
|
|
268
267
|
const { content } = first.message;
|
|
269
268
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
269
|
+
return {
|
|
270
|
+
at: Date.parse(first.timestamp),
|
|
271
|
+
text: isString(content)
|
|
272
|
+
? content
|
|
273
|
+
: content
|
|
274
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
275
|
+
.join("\n"),
|
|
276
|
+
};
|
|
277
|
+
};
|
|
273
278
|
|
|
274
|
-
|
|
279
|
+
// The brief that started this session, when it is a delegated child.
|
|
280
|
+
export const sessionBrief = (
|
|
281
|
+
ctx: ExtensionContext
|
|
282
|
+
): { readonly brief: Brief; readonly startedAt: number } | undefined => {
|
|
283
|
+
const first = firstMessage(ctx);
|
|
284
|
+
const brief = first === undefined ? undefined : parseBrief(first.text);
|
|
285
|
+
|
|
286
|
+
return first === undefined || brief === undefined || !dispatchable(brief)
|
|
287
|
+
? undefined
|
|
288
|
+
: { brief, startedAt: first.at };
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// What is wrong with a brief that has a `# Task:` heading but is not complete
|
|
292
|
+
// enough to hold a child to. Undefined when there is no such brief.
|
|
293
|
+
export const incompleteBrief = (
|
|
294
|
+
ctx: ExtensionContext,
|
|
295
|
+
rules: BriefRules
|
|
296
|
+
): string[] | undefined => {
|
|
297
|
+
const first = firstMessage(ctx);
|
|
298
|
+
const brief = first === undefined ? undefined : parseBrief(first.text);
|
|
275
299
|
|
|
276
|
-
return brief === undefined ||
|
|
300
|
+
return first === undefined || brief === undefined || dispatchable(brief)
|
|
277
301
|
? undefined
|
|
278
|
-
:
|
|
302
|
+
: briefProblems(first.text, rules);
|
|
279
303
|
};
|
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,
|
|
@@ -122,10 +122,22 @@ const isExecFailure = compile(
|
|
|
122
122
|
)
|
|
123
123
|
).check;
|
|
124
124
|
|
|
125
|
+
const isLimits = compile(obj({ files: num(), minutes: num() })).check;
|
|
126
|
+
|
|
125
127
|
const isPolicy = compile(
|
|
126
128
|
obj(
|
|
127
129
|
{
|
|
128
|
-
briefs: opt(
|
|
130
|
+
briefs: opt(
|
|
131
|
+
obj(
|
|
132
|
+
{
|
|
133
|
+
noWrites: opt(arr(str())),
|
|
134
|
+
redBeforeGreen: opt(arr(str())),
|
|
135
|
+
sizes: opt(obj({}, { open: true })),
|
|
136
|
+
},
|
|
137
|
+
{ open: true }
|
|
138
|
+
)
|
|
139
|
+
),
|
|
140
|
+
jobs: opt(obj({}, { open: true })),
|
|
129
141
|
},
|
|
130
142
|
{ open: true }
|
|
131
143
|
)
|
|
@@ -139,10 +151,27 @@ const isPathInput = compile(obj({ path: str() }, { open: true })).check;
|
|
|
139
151
|
|
|
140
152
|
const isNumber = compile(num()).check;
|
|
141
153
|
|
|
142
|
-
const
|
|
154
|
+
const readPolicy = () => {
|
|
143
155
|
const policy: unknown = JSON.parse(readFileSync(policyPath(), "utf-8"));
|
|
144
156
|
|
|
145
|
-
return isPolicy(policy) ?
|
|
157
|
+
return isPolicy(policy) ? policy : {};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const readRedBeforeGreen = (): readonly string[] =>
|
|
161
|
+
readPolicy().briefs?.redBeforeGreen ?? [];
|
|
162
|
+
|
|
163
|
+
const readBriefRules = (): BriefRules => {
|
|
164
|
+
const { briefs, jobs } = readPolicy();
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
jobs: Object.keys(jobs ?? {}),
|
|
168
|
+
noWrites: briefs?.noWrites ?? [],
|
|
169
|
+
sizes: Object.fromEntries(
|
|
170
|
+
Object.entries(briefs?.sizes ?? {}).flatMap(([size, limits]) =>
|
|
171
|
+
isLimits(limits) ? [[size, limits] as const] : []
|
|
172
|
+
)
|
|
173
|
+
),
|
|
174
|
+
};
|
|
146
175
|
};
|
|
147
176
|
|
|
148
177
|
const changedFiles = async (cwd: string): Promise<string[]> => {
|
|
@@ -227,6 +256,7 @@ interface ChildState {
|
|
|
227
256
|
diskCheck: Promise<void>;
|
|
228
257
|
diskOver: boolean;
|
|
229
258
|
diskQueued: boolean;
|
|
259
|
+
delegated: boolean | undefined;
|
|
230
260
|
gitDir: string | undefined;
|
|
231
261
|
greenAtStart: boolean;
|
|
232
262
|
ready: boolean;
|
|
@@ -237,6 +267,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
237
267
|
const state: ChildState = {
|
|
238
268
|
baselineKb: undefined,
|
|
239
269
|
before: undefined,
|
|
270
|
+
delegated: undefined,
|
|
240
271
|
diskCheck: Promise.resolve(),
|
|
241
272
|
diskOver: false,
|
|
242
273
|
diskQueued: false,
|
|
@@ -297,7 +328,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
297
328
|
state.gitDir = undefined;
|
|
298
329
|
}
|
|
299
330
|
|
|
300
|
-
state.sandbox = await chooseSandbox(state.gitDir);
|
|
331
|
+
state.sandbox = await chooseSandbox(ctx.cwd, state.gitDir);
|
|
301
332
|
|
|
302
333
|
state.baselineKb = await duKb(ctx.cwd);
|
|
303
334
|
|
|
@@ -331,7 +362,22 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
331
362
|
const started = sessionBrief(ctx);
|
|
332
363
|
|
|
333
364
|
if (started === undefined) {
|
|
334
|
-
|
|
365
|
+
// A delegated child whose brief is incomplete would otherwise run with
|
|
366
|
+
// no sandbox and no limits, as if it were the user's own session.
|
|
367
|
+
const problems = incompleteBrief(ctx, readBriefRules());
|
|
368
|
+
|
|
369
|
+
if (problems === undefined) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
state.delegated ??= await isDelegatedChild();
|
|
374
|
+
|
|
375
|
+
return state.delegated
|
|
376
|
+
? {
|
|
377
|
+
block: true,
|
|
378
|
+
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- ")}`,
|
|
379
|
+
}
|
|
380
|
+
: undefined;
|
|
335
381
|
}
|
|
336
382
|
|
|
337
383
|
const { brief, startedAt } = started;
|
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: {
|