pi-better-background-tasks 0.2.5 → 0.2.7
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 +37 -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 +286 -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/README.md
CHANGED
|
@@ -17,9 +17,45 @@ 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. The foreground sandbox is
|
|
31
|
+
inactive by default, so local tasks ordinarily launch unconfined. A later
|
|
32
|
+
`/sandbox on`, `/sandbox off`, `/sandbox default on|off`, or a deny-rule change
|
|
33
|
+
reaches tasks launched after it; a task
|
|
34
|
+
already running — including a watcher resumed in a later Pi session — keeps the
|
|
35
|
+
policy it started with.
|
|
36
|
+
|
|
37
|
+
If the foreground sandbox reports `unavailable` or `failed`, a local launch is
|
|
38
|
+
refused with an explanation instead of running unconfined. `/sandbox off` is the
|
|
39
|
+
deliberate way to run local tasks unsandboxed.
|
|
40
|
+
|
|
41
|
+
Structured remote SSH tasks are unaffected: the foreground sandbox describes this
|
|
42
|
+
machine, and remote work keeps its existing remote semantics. Without
|
|
43
|
+
`pi-better-sandbox` installed, local tasks behave exactly as they always have.
|
|
44
|
+
|
|
45
|
+
Reads and network access are never restricted; only writes are. Pi's own
|
|
46
|
+
process, `pi.exec` calls, and unrelated third-party extension code stay outside
|
|
47
|
+
the guarantee, and confinement is per surface: a confined process on another
|
|
48
|
+
first-party surface can still write this one's task registry. Installing
|
|
49
|
+
[`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme)
|
|
50
|
+
installs the sandbox extension, but leaves foreground tools and local background
|
|
51
|
+
tasks inactive until a human opts in.
|
|
20
52
|
|
|
21
53
|
## Remote SSH
|
|
22
54
|
|
|
55
|
+
For short synchronous remote commands that should return output in the current
|
|
56
|
+
turn, install `pi-better-ssh` and use `remote_bash`. Use background tasks for
|
|
57
|
+
long-running or durable remote jobs and asynchronous health watches.
|
|
58
|
+
|
|
23
59
|
Prefer structured `ssh` fields over hand-written `ssh` command lines. A remote
|
|
24
60
|
spawn uses a durable tmux session by default, while a remote watch opens one
|
|
25
61
|
direct SSH poll per interval and does not require tmux. The package keeps the
|
|
@@ -59,7 +95,7 @@ pi -e npm:pi-better-background-tasks
|
|
|
59
95
|
|
|
60
96
|
Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
|
|
61
97
|
|
|
62
|
-
Do not use it for short commands where the foreground session should wait for the result directly.
|
|
98
|
+
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
99
|
|
|
64
100
|
## Compatibility
|
|
65
101
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-background-tasks",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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": {
|
package/src/index.ts
CHANGED
|
@@ -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);
|
package/src/process.ts
CHANGED
|
@@ -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,
|
package/src/registry.ts
CHANGED
|
@@ -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}`;
|