pi-glovebox 0.1.0 → 0.2.0
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 +4 -3
- package/bin/pi-glovebox-server.mjs +45 -16
- package/extensions/index.ts +26 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Every call becomes one command run by `pi-glovebox-server` inside your sandbox:
|
|
|
13
13
|
| `write` | `mkdir -p`, then `cat > file` with the content on stdin |
|
|
14
14
|
| `edit` | `: < file && : >> file` to check access, `cat`, `cat > file` |
|
|
15
15
|
|
|
16
|
-
Commands get the server's environment, not Pi's. Aborts and timeouts close the connection, and the server kills the command's process group.
|
|
16
|
+
Your `!` and `!!` commands run the same way as `bash`. Commands get the server's environment, not Pi's. Aborts and timeouts close the connection, and the server kills the command's process group. Like Pi's own `bash`, a command counts as finished once it has exited and any background processes it started have printed nothing for 100 ms; those processes keep running.
|
|
17
17
|
|
|
18
18
|
pi-glovebox also blocks calls to tools that would run in Pi's process instead, returning an error the model sees:
|
|
19
19
|
|
|
@@ -32,6 +32,7 @@ pi-glovebox separates where Pi runs from where the model's commands run, and lea
|
|
|
32
32
|
|
|
33
33
|
## Requirements
|
|
34
34
|
|
|
35
|
+
- Pi 0.85.0 or later.
|
|
35
36
|
- Node.js 22 or later for the server, and `bash`, `cat`, `head` and `mkdir` inside the sandbox.
|
|
36
37
|
- Pi's working directory must exist at the same path inside the sandbox.
|
|
37
38
|
|
|
@@ -55,14 +56,14 @@ This sandbox sees the project read-write, `/nix` and the directories on your `PA
|
|
|
55
56
|
- `env -i` keeps exported API keys out of the sandbox, since sbox passes its environment through.
|
|
56
57
|
- `--no-builtin-tools` leaves Pi's own tools inactive. If pi-glovebox fails to load, Pi has no tools unless another extension activates Pi's own.
|
|
57
58
|
- Other extensions, including their tools, run in Pi's process with Pi's environment. Load only extensions you trust, or pass `--no-extensions` and name each with `-e`.
|
|
58
|
-
- The bash environment Pi normally sets (`PI_SESSION_ID`, `PI_MODEL`, …) and the `shellPath` and `shellCommandPrefix` settings do not reach the sandbox.
|
|
59
|
+
- The bash environment Pi normally sets (`PI_SESSION_ID`, `PI_MODEL`, …) and the `shellPath` and `shellCommandPrefix` settings do not reach the sandbox; the `bash` tool does not mention them to the model.
|
|
59
60
|
- Anything that can reach the socket can run commands in the sandbox.
|
|
60
61
|
- Pi still reads `AGENTS.md`, skills, extensions and settings from the host. If the model can write them, it can influence later sessions; see Pi's `--no-context-files`, `--no-skills`, `--no-extensions` and `--no-approve`.
|
|
61
62
|
- Pi saves truncated bash output under its `TMPDIR`. Mount that directory into the sandbox if the model should read those files.
|
|
62
63
|
|
|
63
64
|
## Protocol
|
|
64
65
|
|
|
65
|
-
One connection runs one command. The client sends one line of JSON, `{"argv": [...], "cwd": "/abs/path", "stdin": "optional text"}`. The server answers with frames of a 1-byte type, a 4-byte big-endian length and a payload: type 1 carries output (stdout and stderr as they arrive)
|
|
66
|
+
One connection runs one command. The client sends one line of JSON, `{"argv": [...], "cwd": "/abs/path", "stdin": "optional text"}`. The server answers with frames of a 1-byte type, a 4-byte big-endian length and a payload: type 1 carries output (stdout and stderr as they arrive); type 2 carries the exit code as JSON (`null` if the command was killed by a signal); type 3 carries an error message if the command could not start, such as a missing `cwd`. Either type 2 or type 3 comes last, then the server closes the connection. Closing the connection early, including only its writing side, kills the command's process group, so keep it fully open until the last frame; a malformed request closes it without running anything.
|
|
66
67
|
|
|
67
68
|
## License
|
|
68
69
|
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import net from "node:net";
|
|
4
5
|
|
|
5
6
|
const output = 1;
|
|
6
7
|
const exit = 2;
|
|
8
|
+
const failure = 3;
|
|
9
|
+
// Background processes can hold stdout and stderr open after the command exits. Like Pi's own bash,
|
|
10
|
+
// stop waiting for their output once none has arrived for this long.
|
|
11
|
+
const quietMs = 100;
|
|
7
12
|
|
|
8
13
|
const frame = (type, payload) => {
|
|
9
14
|
const header = Buffer.alloc(5);
|
|
@@ -14,18 +19,35 @@ const frame = (type, payload) => {
|
|
|
14
19
|
|
|
15
20
|
net
|
|
16
21
|
.createServer((socket) => {
|
|
17
|
-
|
|
22
|
+
const received = [];
|
|
18
23
|
let child;
|
|
19
24
|
let exited = false;
|
|
25
|
+
let finished = false;
|
|
26
|
+
let quiet;
|
|
20
27
|
|
|
21
|
-
const finish = (
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
const finish = (type, payload) => {
|
|
29
|
+
if (finished) return;
|
|
30
|
+
finished = true;
|
|
31
|
+
clearTimeout(quiet);
|
|
32
|
+
socket.end(frame(type, Buffer.from(payload)));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const finishWhenQuiet = () => {
|
|
36
|
+
clearTimeout(quiet);
|
|
37
|
+
if (finished) return;
|
|
38
|
+
quiet = setTimeout(() => {
|
|
39
|
+
// A paused stream still holds output the client has not taken yet.
|
|
40
|
+
if (child.stdout.isPaused() || child.stderr.isPaused()) return finishWhenQuiet();
|
|
41
|
+
child.stdout.destroy();
|
|
42
|
+
child.stderr.destroy();
|
|
43
|
+
finish(exit, JSON.stringify(child.exitCode));
|
|
44
|
+
}, quietMs);
|
|
25
45
|
};
|
|
26
46
|
|
|
27
47
|
const forward = (stream) =>
|
|
28
48
|
stream.on("data", (chunk) => {
|
|
49
|
+
if (finished) return;
|
|
50
|
+
if (exited) finishWhenQuiet();
|
|
29
51
|
if (socket.write(frame(output, chunk))) return;
|
|
30
52
|
stream.pause();
|
|
31
53
|
socket.once("drain", () => stream.resume());
|
|
@@ -33,21 +55,27 @@ net
|
|
|
33
55
|
|
|
34
56
|
socket.on("error", () => {});
|
|
35
57
|
socket.on("close", () => {
|
|
36
|
-
if (
|
|
58
|
+
if (finished) return;
|
|
59
|
+
finished = true;
|
|
60
|
+
clearTimeout(quiet);
|
|
61
|
+
if (!child) return;
|
|
37
62
|
try {
|
|
38
63
|
process.kill(-child.pid, "SIGKILL");
|
|
39
64
|
} catch {}
|
|
65
|
+
// Drain what the killed processes left behind so the child can be reaped.
|
|
66
|
+
child.stdout.resume();
|
|
67
|
+
child.stderr.resume();
|
|
40
68
|
});
|
|
41
69
|
|
|
42
70
|
socket.on("data", function onRequest(chunk) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
const end = chunk.indexOf(10);
|
|
72
|
+
if (end < 0) return void received.push(chunk);
|
|
73
|
+
received.push(chunk.subarray(0, end));
|
|
46
74
|
socket.off("data", onRequest);
|
|
47
75
|
|
|
48
76
|
let request;
|
|
49
77
|
try {
|
|
50
|
-
request = JSON.parse(
|
|
78
|
+
request = JSON.parse(Buffer.concat(received).toString());
|
|
51
79
|
} catch {}
|
|
52
80
|
const valid =
|
|
53
81
|
Array.isArray(request?.argv) &&
|
|
@@ -56,19 +84,20 @@ net
|
|
|
56
84
|
typeof request.cwd === "string" &&
|
|
57
85
|
(request.stdin === undefined || typeof request.stdin === "string");
|
|
58
86
|
if (!valid) return socket.destroy();
|
|
87
|
+
if (!existsSync(request.cwd)) return finish(failure, `Working directory does not exist: ${request.cwd}`);
|
|
59
88
|
try {
|
|
60
89
|
child = spawn(request.argv[0], request.argv.slice(1), { cwd: request.cwd, detached: true });
|
|
61
90
|
} catch (error) {
|
|
62
|
-
|
|
63
|
-
return finish(null);
|
|
91
|
+
return finish(failure, error.message);
|
|
64
92
|
}
|
|
65
93
|
forward(child.stdout);
|
|
66
94
|
forward(child.stderr);
|
|
67
|
-
child.on("error", (error) =>
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
child.on("error", (error) => finish(failure, error.message));
|
|
96
|
+
child.on("exit", () => {
|
|
97
|
+
exited = true;
|
|
98
|
+
finishWhenQuiet();
|
|
70
99
|
});
|
|
71
|
-
child.on("close", (code) => finish(code));
|
|
100
|
+
child.on("close", (code) => finish(exit, JSON.stringify(code)));
|
|
72
101
|
child.stdin.on("error", () => {});
|
|
73
102
|
child.stdin.end(request.stdin ?? "");
|
|
74
103
|
});
|
package/extensions/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import {
|
|
8
|
+
type BashOperations,
|
|
8
9
|
createBashTool,
|
|
9
10
|
createEditTool,
|
|
10
11
|
createReadTool,
|
|
@@ -20,6 +21,11 @@ const imageSniffBytes = "4100";
|
|
|
20
21
|
const ownFile = realpathSync(fileURLToPath(import.meta.url));
|
|
21
22
|
const ownPaths = [ownFile, path.dirname(ownFile)];
|
|
22
23
|
const ownTools = ["bash", "read", "write", "edit"];
|
|
24
|
+
// The largest delay setTimeout accepts; Pi's own bash rejects longer timeouts the same way.
|
|
25
|
+
const maxTimeoutMs = 2147483647;
|
|
26
|
+
const frameOutput = 1;
|
|
27
|
+
const frameExit = 2;
|
|
28
|
+
const frameFailure = 3;
|
|
23
29
|
|
|
24
30
|
type Request = { argv: string[]; cwd: string; stdin?: string };
|
|
25
31
|
type Options = { onData?: (data: Buffer) => void; signal?: AbortSignal; timeout?: number };
|
|
@@ -31,6 +37,7 @@ const run = (request: Request, { onData, signal, timeout }: Options = {}) =>
|
|
|
31
37
|
const output: Buffer[] = [];
|
|
32
38
|
let received = Buffer.alloc(0);
|
|
33
39
|
let exitCode: number | null | undefined;
|
|
40
|
+
let failure: string | undefined;
|
|
34
41
|
|
|
35
42
|
const stop = (error: Error) => {
|
|
36
43
|
socket.destroy();
|
|
@@ -44,11 +51,13 @@ const run = (request: Request, { onData, signal, timeout }: Options = {}) =>
|
|
|
44
51
|
received = Buffer.concat([received, chunk]);
|
|
45
52
|
while (received.length >= 5 && received.length >= 5 + received.readUInt32BE(1)) {
|
|
46
53
|
const payload = received.subarray(5, 5 + received.readUInt32BE(1));
|
|
47
|
-
if (received[0] ===
|
|
54
|
+
if (received[0] === frameOutput) {
|
|
48
55
|
if (onData) onData(payload);
|
|
49
56
|
else output.push(payload);
|
|
50
|
-
} else {
|
|
57
|
+
} else if (received[0] === frameExit) {
|
|
51
58
|
exitCode = JSON.parse(payload.toString());
|
|
59
|
+
} else if (received[0] === frameFailure) {
|
|
60
|
+
failure = payload.toString();
|
|
52
61
|
}
|
|
53
62
|
received = received.subarray(5 + payload.length);
|
|
54
63
|
}
|
|
@@ -57,7 +66,8 @@ const run = (request: Request, { onData, signal, timeout }: Options = {}) =>
|
|
|
57
66
|
socket.on("close", () => {
|
|
58
67
|
clearTimeout(timer);
|
|
59
68
|
signal?.removeEventListener("abort", abort);
|
|
60
|
-
if (
|
|
69
|
+
if (failure !== undefined) reject(new Error(failure));
|
|
70
|
+
else if (exitCode === undefined) reject(new Error("pi-glovebox: connection closed before the command finished"));
|
|
61
71
|
else resolve({ output: Buffer.concat(output), exitCode });
|
|
62
72
|
});
|
|
63
73
|
|
|
@@ -74,6 +84,15 @@ const succeed = async (argv: string[], stdin?: string) => {
|
|
|
74
84
|
const shell = (script: string, file: string, stdin?: string) =>
|
|
75
85
|
succeed(["bash", "-c", script, "pi-glovebox", file], stdin).then(() => {});
|
|
76
86
|
|
|
87
|
+
const exec: BashOperations["exec"] = async (command, cwd, { onData, signal, timeout }) => {
|
|
88
|
+
if (timeout !== undefined) {
|
|
89
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new Error("Invalid timeout: must be a finite number of seconds");
|
|
90
|
+
if (timeout * 1000 > maxTimeoutMs) throw new Error(`Invalid timeout: maximum is ${maxTimeoutMs / 1000} seconds`);
|
|
91
|
+
}
|
|
92
|
+
const { exitCode } = await run({ argv: ["bash", "-c", command], cwd }, { onData, signal, timeout });
|
|
93
|
+
return { exitCode };
|
|
94
|
+
};
|
|
95
|
+
|
|
77
96
|
const detectImageMimeType = async (file: string) => {
|
|
78
97
|
const head = await succeed(["head", "-c", imageSniffBytes, "--", file]);
|
|
79
98
|
const directory = await mkdtemp(path.join(tmpdir(), "pi-glovebox-"));
|
|
@@ -93,17 +112,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
112
|
return { block: true, reason: `pi-glovebox: ${toolName} is built into Pi and would run outside the sandbox` };
|
|
94
113
|
}
|
|
95
114
|
if (ownTools.includes(toolName) && !(source && existsSync(source.path) && ownPaths.includes(realpathSync(source.path)))) {
|
|
96
|
-
return { block: true, reason: `pi-glovebox: ${toolName} comes from ${source?.path}, not pi-glovebox, and would run outside the sandbox` };
|
|
115
|
+
return { block: true, reason: `pi-glovebox: ${toolName} comes from ${source?.path}, not this pi-glovebox (${ownFile}), and would run outside the sandbox` };
|
|
97
116
|
}
|
|
98
117
|
});
|
|
99
|
-
pi.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
exec: (command, directory, options) =>
|
|
103
|
-
run({ argv: ["bash", "-c", command], cwd: directory }, options).then(({ exitCode }) => ({ exitCode })),
|
|
104
|
-
},
|
|
105
|
-
}),
|
|
106
|
-
);
|
|
118
|
+
pi.on("user_bash", () => ({ operations: { exec } }));
|
|
119
|
+
// The sandbox gets the server's environment, so the PI_* variables Pi would advertise never reach it.
|
|
120
|
+
pi.registerTool(createBashTool(cwd, { operations: { exec }, exposeSessionEnvironment: false }));
|
|
107
121
|
pi.registerTool(
|
|
108
122
|
createReadTool(cwd, {
|
|
109
123
|
operations: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-glovebox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Run Pi's bash, read, write and edit tools in a separate sandbox, reached over a Unix socket",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
]
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": "
|
|
33
|
+
"@earendil-works/pi-coding-agent": ">=0.85.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=22"
|