pi-glovebox 0.2.1 → 0.3.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 +2 -2
- package/bin/pi-glovebox-server.mjs +6 -5
- package/extensions/index.ts +12 -9
- package/package.json +1 -1
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
|
-
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
|
|
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, but nothing reads their output any more: if one later writes to the stdout or stderr it inherited, it gets `SIGPIPE` and usually dies, so redirect the output of anything meant to outlive the command (`server > log 2>&1 &`).
|
|
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
|
|
|
@@ -63,7 +63,7 @@ This sandbox sees the project read-write, `/nix` and the directories on your `PA
|
|
|
63
63
|
|
|
64
64
|
## Protocol
|
|
65
65
|
|
|
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
|
|
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 stdout and type 4 carries stderr, each as it arrives; 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. `read` and `edit` take file contents from stdout alone and the file tools report errors from stderr, so warnings a tool in the sandbox prints on stderr never end up in a file.
|
|
67
67
|
|
|
68
68
|
## License
|
|
69
69
|
|
|
@@ -3,9 +3,10 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const stdout = 1;
|
|
7
7
|
const exit = 2;
|
|
8
8
|
const failure = 3;
|
|
9
|
+
const stderr = 4;
|
|
9
10
|
// Background processes can hold stdout and stderr open after the command exits. Like Pi's own bash,
|
|
10
11
|
// stop waiting for their output once none has arrived for this long.
|
|
11
12
|
const quietMs = 100;
|
|
@@ -44,11 +45,11 @@ net
|
|
|
44
45
|
}, quietMs);
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
const forward = (stream) =>
|
|
48
|
+
const forward = (stream, type) =>
|
|
48
49
|
stream.on("data", (chunk) => {
|
|
49
50
|
if (finished) return;
|
|
50
51
|
if (exited) finishWhenQuiet();
|
|
51
|
-
if (socket.write(frame(
|
|
52
|
+
if (socket.write(frame(type, chunk))) return;
|
|
52
53
|
stream.pause();
|
|
53
54
|
socket.once("drain", () => stream.resume());
|
|
54
55
|
});
|
|
@@ -90,8 +91,8 @@ net
|
|
|
90
91
|
} catch (error) {
|
|
91
92
|
return finish(failure, error.message);
|
|
92
93
|
}
|
|
93
|
-
forward(child.stdout);
|
|
94
|
-
forward(child.stderr);
|
|
94
|
+
forward(child.stdout, stdout);
|
|
95
|
+
forward(child.stderr, stderr);
|
|
95
96
|
child.on("error", (error) => finish(failure, error.message));
|
|
96
97
|
child.on("exit", () => {
|
|
97
98
|
exited = true;
|
package/extensions/index.ts
CHANGED
|
@@ -23,18 +23,20 @@ const ownPaths = [ownFile, path.dirname(ownFile)];
|
|
|
23
23
|
const ownTools = ["bash", "read", "write", "edit"];
|
|
24
24
|
// The largest delay setTimeout accepts; Pi's own bash rejects longer timeouts the same way.
|
|
25
25
|
const maxTimeoutMs = 2147483647;
|
|
26
|
-
const
|
|
26
|
+
const frameStdout = 1;
|
|
27
27
|
const frameExit = 2;
|
|
28
28
|
const frameFailure = 3;
|
|
29
|
+
const frameStderr = 4;
|
|
29
30
|
|
|
30
31
|
type Request = { argv: string[]; cwd: string; stdin?: string };
|
|
31
32
|
type Options = { onData?: (data: Buffer) => void; signal?: AbortSignal; timeout?: number };
|
|
32
|
-
type Result = {
|
|
33
|
+
type Result = { stdout: Buffer; stderr: Buffer; exitCode: number | null };
|
|
33
34
|
|
|
34
35
|
const run = (request: Request, { onData, signal, timeout }: Options = {}) =>
|
|
35
36
|
new Promise<Result>((resolve, reject) => {
|
|
36
37
|
const socket = net.createConnection(socketPath);
|
|
37
|
-
const
|
|
38
|
+
const stdout: Buffer[] = [];
|
|
39
|
+
const stderr: Buffer[] = [];
|
|
38
40
|
let received = Buffer.alloc(0);
|
|
39
41
|
let exitCode: number | null | undefined;
|
|
40
42
|
let failure: string | undefined;
|
|
@@ -51,9 +53,9 @@ const run = (request: Request, { onData, signal, timeout }: Options = {}) =>
|
|
|
51
53
|
received = Buffer.concat([received, chunk]);
|
|
52
54
|
while (received.length >= 5 && received.length >= 5 + received.readUInt32BE(1)) {
|
|
53
55
|
const payload = received.subarray(5, 5 + received.readUInt32BE(1));
|
|
54
|
-
if (received[0] ===
|
|
56
|
+
if (received[0] === frameStdout || received[0] === frameStderr) {
|
|
55
57
|
if (onData) onData(payload);
|
|
56
|
-
else
|
|
58
|
+
else (received[0] === frameStdout ? stdout : stderr).push(payload);
|
|
57
59
|
} else if (received[0] === frameExit) {
|
|
58
60
|
exitCode = JSON.parse(payload.toString());
|
|
59
61
|
} else if (received[0] === frameFailure) {
|
|
@@ -68,17 +70,18 @@ const run = (request: Request, { onData, signal, timeout }: Options = {}) =>
|
|
|
68
70
|
signal?.removeEventListener("abort", abort);
|
|
69
71
|
if (failure !== undefined) reject(new Error(failure));
|
|
70
72
|
else if (exitCode === undefined) reject(new Error("pi-glovebox: connection closed before the command finished"));
|
|
71
|
-
else resolve({
|
|
73
|
+
else resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode });
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
if (signal?.aborted) abort();
|
|
75
77
|
else socket.write(`${JSON.stringify(request)}\n`);
|
|
76
78
|
});
|
|
77
79
|
|
|
80
|
+
// File contents come from stdout alone, so warnings a sandboxed tool prints on stderr never end up in them.
|
|
78
81
|
const succeed = async (argv: string[], stdin?: string) => {
|
|
79
|
-
const {
|
|
80
|
-
if (exitCode !== 0) throw new Error(
|
|
81
|
-
return
|
|
82
|
+
const { stdout, stderr, exitCode } = await run({ argv, cwd: "/", stdin });
|
|
83
|
+
if (exitCode !== 0) throw new Error(stderr.toString().trim() || `exit code ${exitCode}`);
|
|
84
|
+
return stdout;
|
|
82
85
|
};
|
|
83
86
|
|
|
84
87
|
const shell = (script: string, file: string, stdin?: string) =>
|