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/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(commandSpec, logPath, true);
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
+ }