pi-better-background-tasks 0.2.5 → 0.2.6
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 +35 -1
- package/package.json +7 -4
- package/src/index.ts +4 -0
- package/src/process.ts +68 -17
- package/src/registry.ts +11 -0
- package/src/remote-task-preset.ts +53 -571
- package/src/runtime.ts +35 -2
- package/src/sandbox.ts +285 -0
- package/src/shared-sandbox-core.ts +462 -0
- package/src/shared-ssh-core/index.ts +902 -0
- package/src/tools.ts +25 -9
- package/src/types.ts +13 -19
package/src/runtime.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { evaluateCondition } from "./conditions.js";
|
|
|
5
5
|
import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
|
|
6
6
|
import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./remote-task-preset.js";
|
|
7
7
|
import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
|
|
8
|
-
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
|
|
8
|
+
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
|
|
9
|
+
import { confineCommandSpec, resolveForegroundSandboxPlan } from "./sandbox.js";
|
|
9
10
|
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
10
11
|
import type {
|
|
11
12
|
BackgroundTaskCallbackOrigin,
|
|
@@ -32,6 +33,9 @@ const REMOTE_SESSION_POLL_MS = 100;
|
|
|
32
33
|
|
|
33
34
|
export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
34
35
|
|
|
36
|
+
/** Remote SSH launches never consult the local foreground sandbox policy. */
|
|
37
|
+
const UNCONFINED_LAUNCH = { confined: false } as const;
|
|
38
|
+
|
|
35
39
|
export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
|
|
36
40
|
|
|
37
41
|
export interface SpawnTaskParams extends CommandSpec {
|
|
@@ -69,6 +73,10 @@ export function spawnTask(
|
|
|
69
73
|
getActiveSession?: ActiveSessionProvider,
|
|
70
74
|
dependencies: TaskRuntimeDependencies = {},
|
|
71
75
|
): BackgroundTaskMeta {
|
|
76
|
+
// Resolved before any task directory, log, or metadata exists so a blocked
|
|
77
|
+
// launch leaves nothing behind. Remote SSH work is not a local execution path
|
|
78
|
+
// and keeps its existing remote semantics untouched.
|
|
79
|
+
const sandboxPlan = params.ssh ? UNCONFINED_LAUNCH : resolveForegroundSandboxPlan(pi);
|
|
72
80
|
const id = nextTaskId();
|
|
73
81
|
const cwd = params.cwd ?? defaultCwd;
|
|
74
82
|
const logPath = logPathFor(id);
|
|
@@ -85,12 +93,15 @@ export function spawnTask(
|
|
|
85
93
|
}, dependencies.remoteRunner)
|
|
86
94
|
: undefined;
|
|
87
95
|
const commandSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
|
|
96
|
+
const launchSpec = remoteTask
|
|
97
|
+
? commandSpec
|
|
98
|
+
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
|
|
88
99
|
const tmuxBacked = remoteTask?.metadata.remote.session === "tmux";
|
|
89
100
|
const spawned = tmuxBacked
|
|
90
101
|
? undefined
|
|
91
102
|
: remoteTask
|
|
92
103
|
? remoteTask.spawn(logPath, true)
|
|
93
|
-
: spawnCommand(
|
|
104
|
+
: spawnCommand(launchSpec, logPath, true);
|
|
94
105
|
const now = Date.now();
|
|
95
106
|
const meta: BackgroundTaskMeta = {
|
|
96
107
|
id,
|
|
@@ -108,6 +119,7 @@ export function spawnTask(
|
|
|
108
119
|
shell: commandSpec.shell,
|
|
109
120
|
cwd,
|
|
110
121
|
env: params.env,
|
|
122
|
+
launchArgv: launchArgvOf(commandSpec, launchSpec),
|
|
111
123
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
112
124
|
pid: spawned?.child.pid,
|
|
113
125
|
pgid: spawned?.pgid,
|
|
@@ -296,6 +308,7 @@ export function startWatchTask(
|
|
|
296
308
|
getActiveSession?: ActiveSessionProvider,
|
|
297
309
|
dependencies: TaskRuntimeDependencies = {},
|
|
298
310
|
): BackgroundTaskMeta {
|
|
311
|
+
const sandboxPlan = params.ssh ? UNCONFINED_LAUNCH : resolveForegroundSandboxPlan(pi);
|
|
299
312
|
const id = nextTaskId();
|
|
300
313
|
const cwd = params.cwd ?? defaultCwd;
|
|
301
314
|
const now = Date.now();
|
|
@@ -311,6 +324,9 @@ export function startWatchTask(
|
|
|
311
324
|
}, dependencies.remoteRunner)
|
|
312
325
|
: undefined;
|
|
313
326
|
const commandSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
|
|
327
|
+
const launchSpec = remoteTask
|
|
328
|
+
? commandSpec
|
|
329
|
+
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
|
|
314
330
|
const meta: BackgroundTaskMeta = {
|
|
315
331
|
id,
|
|
316
332
|
name: params.name,
|
|
@@ -328,6 +344,7 @@ export function startWatchTask(
|
|
|
328
344
|
shell: commandSpec.shell,
|
|
329
345
|
cwd,
|
|
330
346
|
env: params.env,
|
|
347
|
+
launchArgv: launchArgvOf(commandSpec, launchSpec),
|
|
331
348
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
332
349
|
spawnPid: process.pid,
|
|
333
350
|
successWhen: params.success_when,
|
|
@@ -796,6 +813,17 @@ function getCallbackSuppressionReason(
|
|
|
796
813
|
}
|
|
797
814
|
|
|
798
815
|
function commandSpecFromMeta(meta: BackgroundTaskMeta): CommandSpec {
|
|
816
|
+
// A task that launched under a sandbox re-runs the wrapper it captured then,
|
|
817
|
+
// not whatever the foreground policy says now — including after a resume in a
|
|
818
|
+
// later Pi session.
|
|
819
|
+
if (meta.launchArgv?.length) {
|
|
820
|
+
return {
|
|
821
|
+
argv: meta.launchArgv,
|
|
822
|
+
shell: false,
|
|
823
|
+
cwd: meta.cwd,
|
|
824
|
+
env: meta.env,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
799
827
|
return {
|
|
800
828
|
command: meta.command,
|
|
801
829
|
argv: meta.argv,
|
|
@@ -805,6 +833,11 @@ function commandSpecFromMeta(meta: BackgroundTaskMeta): CommandSpec {
|
|
|
805
833
|
};
|
|
806
834
|
}
|
|
807
835
|
|
|
836
|
+
/** Record a launch vector only when confinement actually rewrote the spec. */
|
|
837
|
+
function launchArgvOf(commandSpec: CommandSpec, launchSpec: CommandSpec): string[] | undefined {
|
|
838
|
+
return launchSpec === commandSpec ? undefined : launchSpec.argv;
|
|
839
|
+
}
|
|
840
|
+
|
|
808
841
|
function scheduleLogRetention(id: string): void {
|
|
809
842
|
stopLogRetention(id);
|
|
810
843
|
const timer = setInterval(() => {
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground write-sandbox policy for locally launched background tasks.
|
|
3
|
+
*
|
|
4
|
+
* `pi-better-sandbox` owns the foreground policy and publishes an immutable
|
|
5
|
+
* snapshot of it on `pi.events`. This module is the consumer side of that wire
|
|
6
|
+
* contract: it mirrors the latest snapshot, and turns it into a confined
|
|
7
|
+
* command at the moment a local task launches.
|
|
8
|
+
*
|
|
9
|
+
* Two rules make the behaviour predictable:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Snapshot at launch, never a live reference.** The wrapped executable and
|
|
12
|
+
* argv are resolved once, when the task starts, and are what the task keeps
|
|
13
|
+
* running. A later `/sandbox off` or deny-rule change therefore reaches only
|
|
14
|
+
* tasks launched after it.
|
|
15
|
+
* 2. **Fail closed.** Once the foreground state says a sandbox should apply, a
|
|
16
|
+
* missing or unusable backend blocks the launch. The local command is never
|
|
17
|
+
* retried unconfined behind the operator's back. The single exception is an
|
|
18
|
+
* explicitly `disabled` state, which is a human's deliberate decision.
|
|
19
|
+
*
|
|
20
|
+
* The contract is duplicated here rather than imported: `pi-better-sandbox` is
|
|
21
|
+
* an optional peer that this package must keep working without. Two channel
|
|
22
|
+
* names and a payload shape are the entire coupling, and both packages own
|
|
23
|
+
* tests that pin them.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdirSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { dirname } from "node:path";
|
|
29
|
+
|
|
30
|
+
import { commandExecution } from "./process.js";
|
|
31
|
+
import { baseDir } from "./registry.js";
|
|
32
|
+
import { maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
|
|
33
|
+
import type { CommandSpec } from "./types.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What an operator can do about a missing backend here.
|
|
37
|
+
*
|
|
38
|
+
* A background task inherits the session's foreground policy, so its remedy is
|
|
39
|
+
* the session's: the slash command. `sandbox:false` is the subagent tool's
|
|
40
|
+
* opt-out and means nothing on this surface. The text is duplicated rather than
|
|
41
|
+
* imported for the same reason the rest of this contract is — `pi-better-sandbox`
|
|
42
|
+
* is an optional peer.
|
|
43
|
+
*/
|
|
44
|
+
export const FOREGROUND_SANDBOX_REMEDY =
|
|
45
|
+
"Run unconfined on purpose with /sandbox off, or work in a session that has a backend.";
|
|
46
|
+
|
|
47
|
+
/** Channel `pi-better-sandbox` publishes every effective-policy change on. */
|
|
48
|
+
export const FOREGROUND_SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
|
|
49
|
+
|
|
50
|
+
/** Channel a consumer emits on to ask for the current policy. */
|
|
51
|
+
export const FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the foreground sandbox is doing right now.
|
|
55
|
+
*
|
|
56
|
+
* - `enabled` - confine locally launched tasks.
|
|
57
|
+
* - `disabled` - a human switched protection off; launch tasks as before.
|
|
58
|
+
* - `unavailable` - no backend on this platform; block protected launches.
|
|
59
|
+
* - `failed` - protection cannot be applied here; block protected launches.
|
|
60
|
+
*/
|
|
61
|
+
export type ForegroundSandboxState = "enabled" | "disabled" | "unavailable" | "failed";
|
|
62
|
+
|
|
63
|
+
/** The published snapshot, narrowed to the fields a task launch needs. */
|
|
64
|
+
export interface ForegroundSandboxPolicy {
|
|
65
|
+
readonly state: ForegroundSandboxState;
|
|
66
|
+
/** The only writable subtree while `state` is `enabled`. */
|
|
67
|
+
readonly writableRoot?: string | undefined;
|
|
68
|
+
/** Canonical paths that stay non-writable inside the writable root. */
|
|
69
|
+
readonly denyWrite: readonly string[];
|
|
70
|
+
/** Human-readable evidence for why `state` is what it is. */
|
|
71
|
+
readonly reason: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The minimum `pi.events` surface this module uses. */
|
|
75
|
+
export interface PolicyEventBus {
|
|
76
|
+
emit(channel: string, data: unknown): void;
|
|
77
|
+
on(channel: string, handler: (data: unknown) => void): unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Thrown instead of launching a local task the foreground policy forbids. */
|
|
81
|
+
export class ForegroundSandboxBlockedError extends Error {
|
|
82
|
+
readonly policy: ForegroundSandboxPolicy;
|
|
83
|
+
|
|
84
|
+
constructor(policy: ForegroundSandboxPolicy, detail?: string) {
|
|
85
|
+
super(
|
|
86
|
+
`Foreground sandbox is ${policy.state}; this local background task was blocked rather than launched unconfined. ${detail ?? policy.reason}`,
|
|
87
|
+
);
|
|
88
|
+
this.name = "ForegroundSandboxBlockedError";
|
|
89
|
+
this.policy = policy;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** How one local launch should be confined. Resolved before any task state exists. */
|
|
94
|
+
export type ForegroundSandboxPlan =
|
|
95
|
+
| { readonly confined: false }
|
|
96
|
+
| {
|
|
97
|
+
readonly confined: true;
|
|
98
|
+
readonly writableRoot: string;
|
|
99
|
+
readonly denyWrite: readonly string[];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const UNCONFINED: ForegroundSandboxPlan = { confined: false };
|
|
103
|
+
|
|
104
|
+
const VALID_STATES = new Set<string>(["enabled", "disabled", "unavailable", "failed"]);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The latest snapshot per event bus.
|
|
108
|
+
*
|
|
109
|
+
* Keyed by the bus rather than kept in one module variable so that separate Pi
|
|
110
|
+
* sessions inside one process (and separate tests) cannot read each other's
|
|
111
|
+
* policy.
|
|
112
|
+
*/
|
|
113
|
+
const mirrors = new WeakMap<PolicyEventBus, { policy: ForegroundSandboxPolicy | undefined }>();
|
|
114
|
+
|
|
115
|
+
function eventBusOf(pi: unknown): PolicyEventBus | undefined {
|
|
116
|
+
const events = (pi as { events?: unknown } | undefined)?.events;
|
|
117
|
+
if (!events || typeof events !== "object") return undefined;
|
|
118
|
+
const candidate = events as Partial<PolicyEventBus>;
|
|
119
|
+
if (typeof candidate.on !== "function" || typeof candidate.emit !== "function") return undefined;
|
|
120
|
+
return candidate as PolicyEventBus;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Accept a published payload only when it carries a state this module
|
|
125
|
+
* understands, so an unrelated extension emitting on the channel cannot clear a
|
|
126
|
+
* real policy or invent one.
|
|
127
|
+
*/
|
|
128
|
+
function readPolicy(data: unknown): ForegroundSandboxPolicy | undefined {
|
|
129
|
+
if (!data || typeof data !== "object") return undefined;
|
|
130
|
+
const value = data as Record<string, unknown>;
|
|
131
|
+
if (typeof value.state !== "string" || !VALID_STATES.has(value.state)) return undefined;
|
|
132
|
+
const writableRoot = typeof value.writableRoot === "string" ? value.writableRoot : undefined;
|
|
133
|
+
const denyWrite = Array.isArray(value.denyWrite)
|
|
134
|
+
? value.denyWrite.filter((entry): entry is string => typeof entry === "string")
|
|
135
|
+
: [];
|
|
136
|
+
return {
|
|
137
|
+
state: value.state as ForegroundSandboxState,
|
|
138
|
+
writableRoot,
|
|
139
|
+
denyWrite: Object.freeze([...denyWrite]),
|
|
140
|
+
reason: typeof value.reason === "string" ? value.reason : "No reason was published.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Start mirroring foreground policy on this session's event bus.
|
|
146
|
+
*
|
|
147
|
+
* Idempotent, and safe in either extension load order. If `pi-better-sandbox`
|
|
148
|
+
* loaded first, its last publication is already gone (the bus has no replay), so
|
|
149
|
+
* we ask for a fresh one on the request channel. If it loads later, its own
|
|
150
|
+
* session-start publication reaches the subscription registered here.
|
|
151
|
+
*/
|
|
152
|
+
export function observeForegroundSandboxPolicy(pi: unknown): void {
|
|
153
|
+
const events = eventBusOf(pi);
|
|
154
|
+
if (!events || mirrors.has(events)) return;
|
|
155
|
+
|
|
156
|
+
const mirror: { policy: ForegroundSandboxPolicy | undefined } = { policy: undefined };
|
|
157
|
+
mirrors.set(events, mirror);
|
|
158
|
+
events.on(FOREGROUND_SANDBOX_POLICY_CHANNEL, (data) => {
|
|
159
|
+
const policy = readPolicy(data);
|
|
160
|
+
if (policy) mirror.policy = policy;
|
|
161
|
+
});
|
|
162
|
+
events.emit(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The foreground policy as of right now, or `undefined` when no sandbox
|
|
167
|
+
* extension is publishing one.
|
|
168
|
+
*
|
|
169
|
+
* Asks for a re-publication first. Pi's event bus dispatches synchronously, so
|
|
170
|
+
* the answer to that request has already been mirrored by the time this
|
|
171
|
+
* returns; if a future bus were to defer, the last published snapshot is still
|
|
172
|
+
* returned rather than nothing.
|
|
173
|
+
*/
|
|
174
|
+
export function currentForegroundSandboxPolicy(pi: unknown): ForegroundSandboxPolicy | undefined {
|
|
175
|
+
observeForegroundSandboxPolicy(pi);
|
|
176
|
+
const events = eventBusOf(pi);
|
|
177
|
+
if (!events) return undefined;
|
|
178
|
+
events.emit(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
179
|
+
return mirrors.get(events)?.policy;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Decide how a local launch must be confined, before the task has an id, a
|
|
184
|
+
* directory, or a log.
|
|
185
|
+
*
|
|
186
|
+
* Throws for every state that is neither confinable nor a human's explicit
|
|
187
|
+
* opt-out, which is what keeps a blocked launch from leaving task state behind.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveForegroundSandboxPlan(pi: unknown): ForegroundSandboxPlan {
|
|
190
|
+
return planFor(currentForegroundSandboxPolicy(pi));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The plan for one already-read policy. Exposed for tests and reuse. */
|
|
194
|
+
export function planFor(policy: ForegroundSandboxPolicy | undefined): ForegroundSandboxPlan {
|
|
195
|
+
// No sandbox extension is publishing: this package is installed on its own and
|
|
196
|
+
// keeps its historical unsandboxed behaviour.
|
|
197
|
+
if (!policy) return UNCONFINED;
|
|
198
|
+
if (policy.state === "disabled") return UNCONFINED;
|
|
199
|
+
if (policy.state !== "enabled" || !policy.writableRoot) {
|
|
200
|
+
throw new ForegroundSandboxBlockedError(policy);
|
|
201
|
+
}
|
|
202
|
+
return { confined: true, writableRoot: policy.writableRoot, denyWrite: policy.denyWrite };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Wrap a local command in the platform's write sandbox.
|
|
207
|
+
*
|
|
208
|
+
* The result is an ordinary `CommandSpec` whose argv is the backend wrapper
|
|
209
|
+
* around the exact executable and arguments the unconfined spec would have run,
|
|
210
|
+
* so spawning, streaming, timeouts, process-group termination, and env handling
|
|
211
|
+
* all stay on their existing code paths. The generated macOS profile is written
|
|
212
|
+
* to `profilePath`, which callers put inside the task's own directory so a
|
|
213
|
+
* resumed watch re-reads the policy it launched with.
|
|
214
|
+
*
|
|
215
|
+
* `seams` defaults to the real platform and PATH. It exists so a caller — in
|
|
216
|
+
* practice a test — can prove the argv this produces for a backend other than
|
|
217
|
+
* the one the host happens to have.
|
|
218
|
+
*/
|
|
219
|
+
export function confineCommandSpec(
|
|
220
|
+
spec: CommandSpec,
|
|
221
|
+
plan: ForegroundSandboxPlan,
|
|
222
|
+
profilePath: string,
|
|
223
|
+
seams: SandboxSeams = {},
|
|
224
|
+
): CommandSpec {
|
|
225
|
+
if (!plan.confined) return spec;
|
|
226
|
+
|
|
227
|
+
const { execPath, execArgs } = commandExecution(spec);
|
|
228
|
+
// The generated profile is written here, so its directory must exist before
|
|
229
|
+
// the backend builds the command.
|
|
230
|
+
mkdirSync(dirname(profilePath), { recursive: true });
|
|
231
|
+
// Task state is this package's control plane: `meta.json` carries the launch
|
|
232
|
+
// vector a resumed watch re-runs verbatim, and `sandbox.sb` is the profile the
|
|
233
|
+
// launch is confined by. It lives under the system temp directory, which both
|
|
234
|
+
// backends leave writable by design, so a confined task could otherwise
|
|
235
|
+
// rewrite what its own next poll executes and choose its own confinement.
|
|
236
|
+
//
|
|
237
|
+
// Denying the whole registry closes that without moving any state, and without
|
|
238
|
+
// costing the task anything it actually needs: pi writes the registry from
|
|
239
|
+
// outside the sandbox, and the task's log reaches it through a descriptor
|
|
240
|
+
// opened before the launch, which no later mount or profile can revoke.
|
|
241
|
+
//
|
|
242
|
+
// Created here rather than assumed, because the Linux backend materializes an
|
|
243
|
+
// absent denied path as an empty *file* and this one has to be a directory.
|
|
244
|
+
const controlPlane = baseDir();
|
|
245
|
+
mkdirSync(controlPlane, { recursive: true });
|
|
246
|
+
const denyWrite = [...plan.denyWrite, controlPlane];
|
|
247
|
+
let command;
|
|
248
|
+
try {
|
|
249
|
+
command = maybeBuildSandboxCommand(
|
|
250
|
+
{
|
|
251
|
+
profilePath,
|
|
252
|
+
policy: {
|
|
253
|
+
writableRoot: plan.writableRoot,
|
|
254
|
+
denyWrite,
|
|
255
|
+
home: homedir(),
|
|
256
|
+
},
|
|
257
|
+
execPath,
|
|
258
|
+
execArgs,
|
|
259
|
+
},
|
|
260
|
+
// `explicitSandbox` because the foreground state already said a sandbox
|
|
261
|
+
// applies: an absent or unusable backend must throw here rather than hand
|
|
262
|
+
// back an unwrapped command. The remedy is the foreground one: this policy
|
|
263
|
+
// came from the session, and the session is where it is switched off.
|
|
264
|
+
{ sandboxEnabled: true, explicitSandbox: true, remedy: FOREGROUND_SANDBOX_REMEDY },
|
|
265
|
+
seams,
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw blocked(plan, error instanceof Error ? error.message : String(error));
|
|
269
|
+
}
|
|
270
|
+
if (!command) throw blocked(plan, "no sandbox backend was applied");
|
|
271
|
+
|
|
272
|
+
return { ...spec, argv: [command.file, ...command.fileArgs], shell: false };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function blocked(plan: ForegroundSandboxPlan & { confined: true }, detail: string): Error {
|
|
276
|
+
return new ForegroundSandboxBlockedError(
|
|
277
|
+
{
|
|
278
|
+
state: "failed",
|
|
279
|
+
writableRoot: plan.writableRoot,
|
|
280
|
+
denyWrite: plan.denyWrite,
|
|
281
|
+
reason: detail,
|
|
282
|
+
},
|
|
283
|
+
detail,
|
|
284
|
+
);
|
|
285
|
+
}
|