pi-better-harness 0.1.25 → 0.1.26

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.
Files changed (34) hide show
  1. package/README.md +24 -5
  2. package/extensions/sandbox/index.ts +1 -0
  3. package/lib/cli.mjs +3 -0
  4. package/node_modules/pi-better-background-tasks/README.md +35 -1
  5. package/node_modules/pi-better-background-tasks/package.json +7 -4
  6. package/node_modules/pi-better-background-tasks/src/index.ts +4 -0
  7. package/node_modules/pi-better-background-tasks/src/process.ts +68 -17
  8. package/node_modules/pi-better-background-tasks/src/registry.ts +11 -0
  9. package/node_modules/pi-better-background-tasks/src/remote-task-preset.ts +53 -571
  10. package/node_modules/pi-better-background-tasks/src/runtime.ts +35 -2
  11. package/node_modules/pi-better-background-tasks/src/sandbox.ts +285 -0
  12. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +462 -0
  13. package/node_modules/pi-better-background-tasks/src/shared-ssh-core/index.ts +902 -0
  14. package/node_modules/pi-better-background-tasks/src/tools.ts +25 -9
  15. package/node_modules/pi-better-background-tasks/src/types.ts +13 -19
  16. package/node_modules/pi-better-sandbox/LICENSE +21 -0
  17. package/node_modules/pi-better-sandbox/README.md +212 -0
  18. package/node_modules/pi-better-sandbox/commands.ts +214 -0
  19. package/node_modules/pi-better-sandbox/deny-rules.ts +623 -0
  20. package/node_modules/pi-better-sandbox/events.ts +55 -0
  21. package/node_modules/pi-better-sandbox/files.ts +211 -0
  22. package/node_modules/pi-better-sandbox/index.ts +233 -0
  23. package/node_modules/pi-better-sandbox/package.json +62 -0
  24. package/node_modules/pi-better-sandbox/policy.ts +102 -0
  25. package/node_modules/pi-better-sandbox/rules-page.ts +176 -0
  26. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +462 -0
  27. package/node_modules/pi-better-sandbox/shell.ts +126 -0
  28. package/node_modules/pi-better-sandbox/state.ts +300 -0
  29. package/node_modules/pi-better-sandbox/status.ts +72 -0
  30. package/node_modules/pi-better-subagents/README.md +1 -1
  31. package/node_modules/pi-better-subagents/package.json +7 -3
  32. package/node_modules/pi-better-subagents/sandbox.ts +32 -125
  33. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +462 -0
  34. package/package.json +8 -4
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # pi-better-harness
2
2
 
3
- `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions for delegated subagents, durable background tasks, and goal tracking.
3
+ `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: a default-on write sandbox, delegated subagents, durable background tasks, and goal tracking.
4
4
 
5
5
  ## Quick Answer
6
6
 
7
- Use `pi-better-harness` when you want the full background-work set for Pi. It manages:
7
+ Use `pi-better-harness` when you want the full working set for Pi. It manages:
8
8
 
9
+ - `pi-better-sandbox` for a default-on write sandbox around Pi's foreground tools.
9
10
  - `pi-better-subagents` for detached, sandboxed subagent runs.
10
11
  - `pi-better-background-tasks` for durable shell tasks and watchers.
11
12
  - `pi-better-goal` for objective tracking that is aware of background work.
@@ -18,7 +19,7 @@ Use `pi-better-harness` when you want the full background-work set for Pi. It ma
18
19
 
19
20
  ## Install
20
21
 
21
- Install the three extensions as standalone Pi packages, so Pi displays and manages each by its own package name:
22
+ Install the extensions as standalone Pi packages, so Pi displays and manages each by its own package name:
22
23
 
23
24
  ```sh
24
25
  npx pi-better-harness install
@@ -36,9 +37,25 @@ The bundled installation remains available for compatibility:
36
37
  pi install npm:pi-better-harness
37
38
  ```
38
39
 
40
+ ## Ordinary Startup
41
+
42
+ You keep launching Pi the way you always have:
43
+
44
+ ```sh
45
+ pi
46
+ ```
47
+
48
+ The write sandbox arms itself at every session start — startup, new session, resume, fork, and reload. There is no launcher and no settings file to create.
49
+
50
+ While it is on, Pi's built-in `bash`, `write`, and `edit` tools, your own `!` / `!!` commands, local background tasks, and subagents can write only under the directory you launched Pi from, minus the packaged deny paths (`.git/hooks`, `.env`, `.env.local`).
51
+
52
+ **Reads and network access are unrestricted** — this sandbox limits writes only. Writes are confined for those integrated first-party execution paths; Pi's own process, arbitrary `pi.exec` calls, and unrelated third-party extension code are **not** confined. Confinement is also **per surface**: each integrated surface denies its own control plane, not every other surface's, so with several first-party surfaces installed a confined process on one can still write another's control plane.
53
+
54
+ Sandbox state is human-only: `/sandbox`, `/sandbox on`, `/sandbox off`, `/sandbox deny ...`, and `/sandbox rules` are slash commands with no tool equivalent, so the model cannot turn off its own confinement. `/sandbox off` needs an interactive confirmation and never persists past the session. Full policy: [pi-better-sandbox](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme).
55
+
39
56
  ## When To Use
40
57
 
41
- Use the installer when you want all three core extensions with standalone package identities. Install an individual package instead when you only need subagents, shell task supervision, or goal tracking.
58
+ Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, or goal tracking.
42
59
 
43
60
  ## Compatibility
44
61
 
@@ -46,11 +63,13 @@ Use the installer when you want all three core extensions with standalone packag
46
63
  |-------------|---------|
47
64
  | Pi | Required |
48
65
  | Recommended install | `npx pi-better-harness install` |
66
+ | Write sandbox on macOS | Seatbelt (`sandbox-exec`), ships with the OS |
67
+ | Write sandbox on Linux | Bubblewrap — install `bubblewrap` |
49
68
  | Development runtime | Node.js 22+ |
50
69
 
51
70
  ## Update Or Remove
52
71
 
53
- Remove all three standalone packages:
72
+ Remove every standalone package:
54
73
 
55
74
  ```sh
56
75
  npx pi-better-harness uninstall
@@ -0,0 +1 @@
1
+ export { default } from "../../node_modules/pi-better-sandbox/index.ts";
package/lib/cli.mjs CHANGED
@@ -7,6 +7,9 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
7
  const { version } = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
8
8
 
9
9
  export const componentPackages = [
10
+ // The sandbox is configured first so a fresh install has write protection in
11
+ // place before the extensions whose work it confines.
12
+ "pi-better-sandbox",
10
13
  "pi-better-subagents",
11
14
  "pi-better-background-tasks",
12
15
  "pi-better-goal",
@@ -17,9 +17,43 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
17
17
  - Keep task metadata and logs available across reloads.
18
18
  - Show active work in Pi's background-work navigator.
19
19
  - Flag running tasks with no observable output or completed poll as stalled.
20
+ - Confine local task writes to the project directory when `pi-better-sandbox` is enabled.
21
+
22
+ ## Write Sandbox
23
+
24
+ When [`pi-better-sandbox`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme)
25
+ is installed and enabled, every **local** task captures
26
+ the effective foreground policy at launch and runs under the platform's write
27
+ sandbox: reads and network stay unrestricted, writes are confined to the
28
+ canonical project directory, and denied paths stay denied.
29
+
30
+ The policy is captured once, when the task starts. A later `/sandbox on`,
31
+ `/sandbox off`, or deny-rule change reaches tasks launched after it; a task
32
+ already running — including a watcher resumed in a later Pi session — keeps the
33
+ policy it started with.
34
+
35
+ If the foreground sandbox reports `unavailable` or `failed`, a local launch is
36
+ refused with an explanation instead of running unconfined. `/sandbox off` is the
37
+ deliberate way to run local tasks unsandboxed.
38
+
39
+ Structured remote SSH tasks are unaffected: the foreground sandbox describes this
40
+ machine, and remote work keeps its existing remote semantics. Without
41
+ `pi-better-sandbox` installed, local tasks behave exactly as they always have.
42
+
43
+ Reads and network access are never restricted; only writes are. Pi's own
44
+ process, `pi.exec` calls, and unrelated third-party extension code stay outside
45
+ the guarantee, and confinement is per surface: a confined process on another
46
+ first-party surface can still write this one's task registry. Installing
47
+ [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme)
48
+ brings the sandbox in by default, so an ordinary `pi` session confines its
49
+ foreground tools and its local background tasks under one project policy.
20
50
 
21
51
  ## Remote SSH
22
52
 
53
+ For short synchronous remote commands that should return output in the current
54
+ turn, install `pi-better-ssh` and use `remote_bash`. Use background tasks for
55
+ long-running or durable remote jobs and asynchronous health watches.
56
+
23
57
  Prefer structured `ssh` fields over hand-written `ssh` command lines. A remote
24
58
  spawn uses a durable tmux session by default, while a remote watch opens one
25
59
  direct SSH poll per interval and does not require tmux. The package keeps the
@@ -59,7 +93,7 @@ pi -e npm:pi-better-background-tasks
59
93
 
60
94
  Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
61
95
 
62
- Do not use it for short commands where the foreground session should wait for the result directly.
96
+ Do not use it for short commands where the foreground session should wait for the result directly; use `remote_bash` from `pi-better-ssh` instead.
63
97
 
64
98
  ## Compatibility
65
99
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,20 +30,23 @@
30
30
  "access": "public"
31
31
  },
32
32
  "scripts": {
33
- "pretest": "node ../../scripts/sync-shared-log-utils.mjs",
34
- "prepack": "node ../../scripts/sync-shared-log-utils.mjs",
33
+ "pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-ssh-core.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
34
+ "prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-ssh-core.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
35
+ "pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs",
35
36
  "typecheck": "tsc --noEmit",
36
37
  "test": "vitest run",
37
38
  "test:unit": "vitest run src/conditions.test.ts src/runtime.test.ts",
38
39
  "test:e2e": "vitest run src/e2e.test.ts",
39
40
  "test:cross-session": "vitest run src/e2e.test.ts -t \"different session\"",
40
41
  "test:golden": "vitest run src/golden-path.test.ts",
41
- "test:integration": "npm run test:e2e && npm run test:golden"
42
+ "test:integration": "npm run test:e2e && npm run test:golden",
43
+ "test:sandbox": "vitest run src/sandbox.test.ts src/sandbox-kernel.test.ts"
42
44
  },
43
45
  "files": [
44
46
  "src/**/*.ts",
45
47
  "!src/**/*.test.ts",
46
48
  "!src/test-support/**",
49
+ "!src/shared-ssh-core/test-support/**",
47
50
  "README.md"
48
51
  ],
49
52
  "peerDependencies": {
@@ -2,11 +2,15 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { disposeBackgroundWorkNavigator } from "./shared-navigator.ts";
3
3
  import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
4
4
  import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
5
+ import { observeForegroundSandboxPolicy } from "./sandbox.js";
5
6
  import { registerTools } from "./tools.js";
6
7
 
7
8
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
8
9
  registerBackgroundTasksGoalProvider(pi);
9
10
  ensureBackgroundTasksNavigatorProvider(pi);
11
+ // Subscribed at load so a sandbox extension that publishes later is heard, and
12
+ // asked for a snapshot in case one published before this extension existed.
13
+ observeForegroundSandboxPolicy(pi);
10
14
  pi.on("session_start", async (_event, ctx) => {
11
15
  registerBackgroundTasksGoalProvider(pi);
12
16
  ensureBackgroundTasksNavigator(ctx);
@@ -6,6 +6,9 @@ import type { CommandResult, CommandSpec } from "./types.js";
6
6
 
7
7
  const DEFAULT_SHELL = process.env.PI_BETTER_BACKGROUND_TASKS_SHELL || "/bin/bash";
8
8
 
9
+ /** How long a timed-out process group has to exit on SIGTERM before SIGKILL. */
10
+ const TERMINATION_GRACE_MS = 2_000;
11
+
9
12
  export interface SpawnedProcess {
10
13
  child: ChildProcess;
11
14
  pgid?: number;
@@ -36,6 +39,22 @@ export function spawnCommand(spec: CommandSpec, logPath: string, detached: boole
36
39
  return { child, pgid: detached && child.pid ? child.pid : undefined };
37
40
  }
38
41
 
42
+ /**
43
+ * Run one command to completion and collect its output.
44
+ *
45
+ * A timeout has to reach the whole process tree, not just the process spawned
46
+ * here. Two things make that necessary rather than tidy:
47
+ *
48
+ * - The command may not be what runs. Under the Linux write sandbox the spawned
49
+ * process is `bwrap`, which forks the real command instead of exec'ing it, so
50
+ * a signal to the spawned pid never reaches the command at all. (macOS
51
+ * `sandbox-exec` execs its target, which is why the same signal works there.)
52
+ * - `close` fires when the output pipes close, not when the child exits. A
53
+ * surviving grandchild keeps holding them, so signalling only the direct child
54
+ * leaves this promise pending indefinitely and the caller's deadline unenforced.
55
+ *
56
+ * So the child leads its own process group, and a timeout signals that group.
57
+ */
39
58
  export function runCommandOnce(
40
59
  spec: CommandSpec,
41
60
  maxBufferBytes = 1024 * 1024,
@@ -43,7 +62,7 @@ export function runCommandOnce(
43
62
  ): Promise<CommandResult> {
44
63
  validateCommandSpec(spec);
45
64
  const startedAt = Date.now();
46
- const child = spawnArgs(spec, false, ["ignore", "pipe", "pipe"]);
65
+ const child = spawnArgs(spec, true, ["ignore", "pipe", "pipe"]);
47
66
  let stdout = "";
48
67
  let stderr = "";
49
68
  let timedOut = false;
@@ -54,17 +73,38 @@ export function runCommandOnce(
54
73
  if (Buffer.byteLength(stderr) < maxBufferBytes) stderr += chunk.toString("utf8");
55
74
  });
56
75
  return new Promise((resolve, reject) => {
76
+ // The group outlives the leader as long as any member is alive, so the
77
+ // kernel keeps the id reserved and this stays addressable after the spawned
78
+ // process itself has been reaped.
79
+ const signalGroup = (signal: NodeJS.Signals): void => {
80
+ if (child.pid === undefined) return;
81
+ try {
82
+ stopProcessGroup(child.pid, undefined, signal);
83
+ } catch {
84
+ // Nothing left in the group: the tree is already gone.
85
+ }
86
+ };
87
+ let escalation: NodeJS.Timeout | undefined;
57
88
  const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
58
89
  timedOut = true;
59
- child.kill("SIGTERM");
90
+ signalGroup("SIGTERM");
91
+ // SIGTERM is a request. Whatever still holds the output pipes after the
92
+ // grace period is exactly what would keep this promise pending, so the
93
+ // group is killed outright rather than waited on.
94
+ escalation = setTimeout(() => signalGroup("SIGKILL"), TERMINATION_GRACE_MS);
95
+ escalation.unref();
60
96
  }, Math.max(1, timeoutMs));
61
97
  timeout?.unref();
62
- child.on("error", (error) => {
98
+ const settle = (): void => {
63
99
  if (timeout) clearTimeout(timeout);
100
+ if (escalation) clearTimeout(escalation);
101
+ };
102
+ child.on("error", (error) => {
103
+ settle();
64
104
  reject(error);
65
105
  });
66
106
  child.on("close", (exitCode, signal) => {
67
- if (timeout) clearTimeout(timeout);
107
+ settle();
68
108
  resolve({
69
109
  exitCode,
70
110
  signal,
@@ -78,13 +118,17 @@ export function runCommandOnce(
78
118
  });
79
119
  }
80
120
 
81
- export function stopProcessGroup(pid: number, pgid?: number): void {
121
+ export function stopProcessGroup(
122
+ pid: number,
123
+ pgid?: number,
124
+ signal: NodeJS.Signals = "SIGTERM",
125
+ ): void {
82
126
  const target = pgid ?? pid;
83
127
  try {
84
- process.kill(-target, "SIGTERM");
128
+ process.kill(-target, signal);
85
129
  return;
86
130
  } catch {
87
- process.kill(pid, "SIGTERM");
131
+ process.kill(pid, signal);
88
132
  }
89
133
  }
90
134
 
@@ -97,22 +141,29 @@ export function processExists(pid: number): boolean {
97
141
  }
98
142
  }
99
143
 
144
+ /**
145
+ * The executable and argument vector a spec runs as.
146
+ *
147
+ * Shell specs become an explicit `bash -lc` invocation rather than a shell
148
+ * string, so every caller — spawning directly, or wrapping the same launch in an
149
+ * OS sandbox — starts from one definition of what actually executes.
150
+ */
151
+ export function commandExecution(spec: CommandSpec): { execPath: string; execArgs: string[] } {
152
+ if (spec.shell === false) {
153
+ const [command, ...args] = spec.argv!;
154
+ return { execPath: command!, execArgs: args };
155
+ }
156
+ return { execPath: DEFAULT_SHELL, execArgs: ["-lc", spec.command!] };
157
+ }
158
+
100
159
  function spawnArgs(
101
160
  spec: CommandSpec,
102
161
  detached: boolean,
103
162
  stdio: ["ignore", "pipe" | number, "pipe" | number],
104
163
  ): ChildProcess {
105
164
  const env = { ...process.env, ...spec.env };
106
- if (spec.shell === false) {
107
- const [command, ...args] = spec.argv!;
108
- return spawn(command!, args, {
109
- cwd: spec.cwd,
110
- env,
111
- detached,
112
- stdio,
113
- });
114
- }
115
- return spawn(DEFAULT_SHELL, ["-lc", spec.command!], {
165
+ const { execPath, execArgs } = commandExecution(spec);
166
+ return spawn(execPath, execArgs, {
116
167
  cwd: spec.cwd,
117
168
  env,
118
169
  detached,
@@ -32,6 +32,17 @@ export function logPathFor(id: string): string {
32
32
  return join(taskDir(id), "output.log");
33
33
  }
34
34
 
35
+ /**
36
+ * Where a task keeps the generated sandbox profile it launched under.
37
+ *
38
+ * It lives beside the task's own log and metadata so it survives exactly as long
39
+ * as the task does: a watch resumed in a later Pi session re-reads the profile it
40
+ * started with instead of a profile some other session has since rewritten.
41
+ */
42
+ export function sandboxProfilePathFor(id: string): string {
43
+ return join(taskDir(id), "sandbox.sb");
44
+ }
45
+
35
46
  export function nextTaskId(): string {
36
47
  seq += 1;
37
48
  return `bg_${process.pid.toString(36)}_${Date.now().toString(36)}_${seq}`;