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.
- package/README.md +24 -5
- package/extensions/sandbox/index.ts +1 -0
- package/lib/cli.mjs +3 -0
- package/node_modules/pi-better-background-tasks/README.md +35 -1
- package/node_modules/pi-better-background-tasks/package.json +7 -4
- package/node_modules/pi-better-background-tasks/src/index.ts +4 -0
- package/node_modules/pi-better-background-tasks/src/process.ts +68 -17
- package/node_modules/pi-better-background-tasks/src/registry.ts +11 -0
- package/node_modules/pi-better-background-tasks/src/remote-task-preset.ts +53 -571
- package/node_modules/pi-better-background-tasks/src/runtime.ts +35 -2
- package/node_modules/pi-better-background-tasks/src/sandbox.ts +285 -0
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +462 -0
- package/node_modules/pi-better-background-tasks/src/shared-ssh-core/index.ts +902 -0
- package/node_modules/pi-better-background-tasks/src/tools.ts +25 -9
- package/node_modules/pi-better-background-tasks/src/types.ts +13 -19
- package/node_modules/pi-better-sandbox/LICENSE +21 -0
- package/node_modules/pi-better-sandbox/README.md +212 -0
- package/node_modules/pi-better-sandbox/commands.ts +214 -0
- package/node_modules/pi-better-sandbox/deny-rules.ts +623 -0
- package/node_modules/pi-better-sandbox/events.ts +55 -0
- package/node_modules/pi-better-sandbox/files.ts +211 -0
- package/node_modules/pi-better-sandbox/index.ts +233 -0
- package/node_modules/pi-better-sandbox/package.json +62 -0
- package/node_modules/pi-better-sandbox/policy.ts +102 -0
- package/node_modules/pi-better-sandbox/rules-page.ts +176 -0
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +462 -0
- package/node_modules/pi-better-sandbox/shell.ts +126 -0
- package/node_modules/pi-better-sandbox/state.ts +300 -0
- package/node_modules/pi-better-sandbox/status.ts +72 -0
- package/node_modules/pi-better-subagents/README.md +1 -1
- package/node_modules/pi-better-subagents/package.json +7 -3
- package/node_modules/pi-better-subagents/sandbox.ts +32 -125
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +462 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
128
|
+
process.kill(-target, signal);
|
|
85
129
|
return;
|
|
86
130
|
} catch {
|
|
87
|
-
process.kill(pid,
|
|
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
|
-
|
|
107
|
-
|
|
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}`;
|