@spunto/build 0.15.0 → 0.17.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 +61 -4
- package/package.json +22 -2
- package/src/agent-connection/index.ts +347 -0
- package/src/agent-protocol/index.ts +190 -0
- package/src/commands/index.ts +205 -0
- package/src/index.ts +3 -0
- package/src/sandbox-agent/agent.mjs +1174 -0
- package/src/sandbox-agent/bootstrap.sh +64 -0
- package/src/sandbox-agent/index.ts +31 -0
- package/src/sandbox-agent/recipe.ts +65 -0
- package/src/sandbox-agent/runner.sh +38 -0
- package/src/script/setup-script.ts +15 -97
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background commands inside a box — the scripts, not the transport. Whoever can run a shell in
|
|
3
|
+
* the box (`docker exec`, the in-box agent's `exec`) runs these and parses what they print.
|
|
4
|
+
*
|
|
5
|
+
* The state of a command lives **in the box**, not in the control plane: it can restart (or be
|
|
6
|
+
* redeployed) mid-run, and the next poll picks the output back up from the files. **The layout is
|
|
7
|
+
* a format, not a preference** — a task started before a release must still be read after it:
|
|
8
|
+
*
|
|
9
|
+
* ~/.spunto/commands/<id>/cmd.sh the command, verbatim
|
|
10
|
+
* ~/.spunto/commands/<id>/run.sh the wrapper (cd + timeout + exit code)
|
|
11
|
+
* ~/.spunto/commands/<id>/stdin a FIFO, for an interactive harness only
|
|
12
|
+
* ~/.spunto/commands/<id>/stdout.log stdout of the wrapper
|
|
13
|
+
* ~/.spunto/commands/<id>/stderr.log stderr of the wrapper
|
|
14
|
+
* ~/.spunto/commands/<id>/pid pid of the wrapper, for cancellation
|
|
15
|
+
* ~/.spunto/commands/<id>/exit_code written last — its presence means "done"
|
|
16
|
+
*
|
|
17
|
+
* Every script prints `KEY:value` lines (`commandFields`), base64 for anything that is bytes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Where a box keeps the state of its background commands (inside $HOME). */
|
|
21
|
+
export const COMMANDS_DIR = "$HOME/.spunto/commands"
|
|
22
|
+
/** How long finished command directories are kept in the box before pruning. */
|
|
23
|
+
export const COMMAND_RETENTION_DAYS = 7
|
|
24
|
+
/** Default tail of each stream a poll returns. */
|
|
25
|
+
export const DEFAULT_POLL_OUTPUT_BYTES = 64 * 1024
|
|
26
|
+
/** Ceiling on one read — base64 inflates it by a third on the wire. */
|
|
27
|
+
export const MAX_READ_BYTES = 256 * 1024
|
|
28
|
+
|
|
29
|
+
/** POSIX single-quote quoting — the only safe way to inject a value into a shell string. */
|
|
30
|
+
const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
31
|
+
|
|
32
|
+
function base64(s: string): string {
|
|
33
|
+
const bytes = new TextEncoder().encode(s)
|
|
34
|
+
let bin = ""
|
|
35
|
+
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
|
36
|
+
return btoa(bin)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Command ids end up in a shell-built path inside the box, so they are checked rather than trusted,
|
|
41
|
+
* whatever the caller sends. A security boundary, like `sanitizeSessionName`.
|
|
42
|
+
*/
|
|
43
|
+
export const isCommandId = (id: string) => /^[a-z0-9]{1,64}$/.test(id)
|
|
44
|
+
|
|
45
|
+
export class InvalidCommandIdError extends Error {
|
|
46
|
+
constructor(id: string) {
|
|
47
|
+
super(`Invalid command id ${id}`)
|
|
48
|
+
this.name = "InvalidCommandIdError"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dirOf(commandId: string): string {
|
|
53
|
+
if (!isCommandId(commandId)) throw new InvalidCommandIdError(commandId)
|
|
54
|
+
return `${COMMANDS_DIR}/${commandId}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type StartCommandScript = {
|
|
58
|
+
commandId: string
|
|
59
|
+
command: string
|
|
60
|
+
cwd: string
|
|
61
|
+
/** Exported in the wrapper, so the command itself sees it — not just the exec that launched it. */
|
|
62
|
+
env?: Record<string, string>
|
|
63
|
+
timeoutMs: number
|
|
64
|
+
/**
|
|
65
|
+
* A writable stdin — a FIFO under the command's directory, appended to with `stdinScript`. For a
|
|
66
|
+
* harness that reads instructions as a stream and stays alive between turns.
|
|
67
|
+
*/
|
|
68
|
+
stdin?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write the command and its wrapper into the box, detach it, print its pid. `setsid` puts the
|
|
73
|
+
* wrapper in its own process group so a cancel takes the whole tree down; an image without it gets
|
|
74
|
+
* `nohup`.
|
|
75
|
+
*/
|
|
76
|
+
export function startScript(p: StartCommandScript): string {
|
|
77
|
+
const dir = dirOf(p.commandId)
|
|
78
|
+
const timeoutSec = Math.max(1, Math.ceil(p.timeoutMs / 1000))
|
|
79
|
+
const exports = Object.entries(p.env ?? {})
|
|
80
|
+
.map(([k, v]) => `export ${k}=${shq(v)}`)
|
|
81
|
+
.join("\n")
|
|
82
|
+
// A file rather than an inline `-c`: the detached process depends on nothing of the exec that
|
|
83
|
+
// launched it, and `cmd.sh` stays readable in the box for whoever debugs a run afterwards.
|
|
84
|
+
// `exec 9<>fifo` is the load-bearing line of interactive mode: opening a FIFO for writing blocks
|
|
85
|
+
// until a reader shows up (`<>` never does), and holding it open for the wrapper's whole life is
|
|
86
|
+
// what stops the harness from seeing EOF between two turns.
|
|
87
|
+
const runner = [
|
|
88
|
+
`#!/usr/bin/env bash`,
|
|
89
|
+
exports,
|
|
90
|
+
`cd ${shq(p.cwd)} 2>/dev/null || cd "$HOME"`,
|
|
91
|
+
...(p.stdin
|
|
92
|
+
? [`exec 9<>"$(dirname "$0")/stdin"`, `timeout -k 5 ${timeoutSec}s bash -l "$(dirname "$0")/cmd.sh" < "$(dirname "$0")/stdin"`]
|
|
93
|
+
: [`timeout -k 5 ${timeoutSec}s bash -l "$(dirname "$0")/cmd.sh"`]),
|
|
94
|
+
`echo $? > "$(dirname "$0")/exit_code"`,
|
|
95
|
+
].join("\n")
|
|
96
|
+
return [
|
|
97
|
+
`set -e`,
|
|
98
|
+
`mkdir -p ${dir}`,
|
|
99
|
+
...(p.stdin ? [`[ -p ${dir}/stdin ] || mkfifo ${dir}/stdin`] : []),
|
|
100
|
+
// A box is long-lived: its command log must not grow forever.
|
|
101
|
+
`find ${COMMANDS_DIR} -mindepth 1 -maxdepth 1 -type d -mtime +${COMMAND_RETENTION_DAYS} -exec rm -rf {} + 2>/dev/null || true`,
|
|
102
|
+
`printf %s ${shq(base64(p.command))} | base64 -d > ${dir}/cmd.sh`,
|
|
103
|
+
`printf %s ${shq(base64(runner))} | base64 -d > ${dir}/run.sh`,
|
|
104
|
+
`: > ${dir}/stdout.log`,
|
|
105
|
+
`: > ${dir}/stderr.log`,
|
|
106
|
+
`if command -v setsid >/dev/null 2>&1; then`,
|
|
107
|
+
` setsid bash ${dir}/run.sh > ${dir}/stdout.log 2> ${dir}/stderr.log < /dev/null &`,
|
|
108
|
+
`else`,
|
|
109
|
+
` nohup bash ${dir}/run.sh > ${dir}/stdout.log 2> ${dir}/stderr.log < /dev/null &`,
|
|
110
|
+
`fi`,
|
|
111
|
+
`echo $! > ${dir}/pid`,
|
|
112
|
+
`cat ${dir}/pid`,
|
|
113
|
+
].join("\n")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The state of a command and the tail of both streams. `exit_code` is written last by the wrapper,
|
|
118
|
+
* so its presence is the completion signal — the pid alone would call a command finished the
|
|
119
|
+
* instant its shell is replaced by an exec'd child.
|
|
120
|
+
* Prints `FOUND`, `EXIT` (`-` while running), `ALIVE`, `SIZEOUT`, `SIZEERR`, `OUT`, `ERR` (base64).
|
|
121
|
+
*/
|
|
122
|
+
export function pollScript(commandId: string, maxOutputBytes = DEFAULT_POLL_OUTPUT_BYTES): string {
|
|
123
|
+
const dir = dirOf(commandId)
|
|
124
|
+
return [
|
|
125
|
+
`if [ ! -d ${dir} ]; then echo "FOUND:0"; exit 0; fi`,
|
|
126
|
+
`echo "FOUND:1"`,
|
|
127
|
+
`if [ -f ${dir}/exit_code ]; then echo "EXIT:$(cat ${dir}/exit_code)"; else echo "EXIT:-"; fi`,
|
|
128
|
+
`pid=$(cat ${dir}/pid 2>/dev/null || echo "")`,
|
|
129
|
+
`if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "ALIVE:1"; else echo "ALIVE:0"; fi`,
|
|
130
|
+
`echo "SIZEOUT:$(wc -c < ${dir}/stdout.log 2>/dev/null || echo 0)"`,
|
|
131
|
+
`echo "SIZEERR:$(wc -c < ${dir}/stderr.log 2>/dev/null || echo 0)"`,
|
|
132
|
+
`echo "OUT:$(tail -c ${maxOutputBytes} ${dir}/stdout.log 2>/dev/null | base64 | tr -d '\\n')"`,
|
|
133
|
+
`echo "ERR:$(tail -c ${maxOutputBytes} ${dir}/stderr.log 2>/dev/null | base64 | tr -d '\\n')"`,
|
|
134
|
+
].join("\n")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A byte range of stdout, from `offset` on — for a reader that keeps a cursor (a harness streaming
|
|
139
|
+
* JSONL): every line, exactly once. Prints `FOUND`, `SIZE`, `EXIT`, `ALIVE`, `DATA` (base64).
|
|
140
|
+
*/
|
|
141
|
+
export function readScript(commandId: string, offset: number, maxBytes: number): string {
|
|
142
|
+
const dir = dirOf(commandId)
|
|
143
|
+
const from = Math.max(0, Math.floor(offset))
|
|
144
|
+
const bytes = Math.max(1, Math.floor(maxBytes))
|
|
145
|
+
return [
|
|
146
|
+
`if [ ! -d ${dir} ]; then echo "FOUND:0"; exit 0; fi`,
|
|
147
|
+
`echo "FOUND:1"`,
|
|
148
|
+
`echo "SIZE:$(wc -c < ${dir}/stdout.log 2>/dev/null || echo 0)"`,
|
|
149
|
+
`if [ -f ${dir}/exit_code ]; then echo "EXIT:$(cat ${dir}/exit_code)"; else echo "EXIT:-"; fi`,
|
|
150
|
+
`pid=$(cat ${dir}/pid 2>/dev/null || echo "")`,
|
|
151
|
+
`if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "ALIVE:1"; else echo "ALIVE:0"; fi`,
|
|
152
|
+
// `tail -c +N` counts bytes from 1, so an offset of 0 is `+1`.
|
|
153
|
+
`echo "DATA:$(tail -c +${from + 1} ${dir}/stdout.log 2>/dev/null | head -c ${bytes} | base64 | tr -d '\\n')"`,
|
|
154
|
+
].join("\n")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Append to a command's stdin FIFO (`dataB64`: the exact bytes, newline included). The wrapper holds
|
|
159
|
+
* the FIFO open, so there is always a reader; a write under PIPE_BUF (4 KiB) is atomic. Prints `WRITTEN`.
|
|
160
|
+
*/
|
|
161
|
+
export function stdinScript(commandId: string, dataB64: string): string {
|
|
162
|
+
const dir = dirOf(commandId)
|
|
163
|
+
return [`if [ ! -p ${dir}/stdin ]; then echo "WRITTEN:0"; exit 0; fi`, `printf %s ${shq(dataB64)} | base64 -d > ${dir}/stdin`, `echo "WRITTEN:1"`].join("\n")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* TERM to the whole process group, then a KILL sweep for anything that ignored it. The wrapper is
|
|
168
|
+
* gone after that, so the cancellation is recorded as exit 143 for a later poll. Prints `SIGNALLED`.
|
|
169
|
+
*/
|
|
170
|
+
export function cancelScript(commandId: string): string {
|
|
171
|
+
const dir = dirOf(commandId)
|
|
172
|
+
return [
|
|
173
|
+
`pid=$(cat ${dir}/pid 2>/dev/null || echo "")`,
|
|
174
|
+
`[ -z "$pid" ] && { echo "SIGNALLED:0"; exit 0; }`,
|
|
175
|
+
`pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')`,
|
|
176
|
+
`if [ -n "$pgid" ]; then kill -TERM -"$pgid" 2>/dev/null || true; fi`,
|
|
177
|
+
`kill -TERM "$pid" 2>/dev/null || true`,
|
|
178
|
+
`sleep 2`,
|
|
179
|
+
`if [ -n "$pgid" ]; then kill -KILL -"$pgid" 2>/dev/null || true; fi`,
|
|
180
|
+
`kill -KILL "$pid" 2>/dev/null || true`,
|
|
181
|
+
`[ -f ${dir}/exit_code ] || echo 143 > ${dir}/exit_code`,
|
|
182
|
+
`echo "SIGNALLED:1"`,
|
|
183
|
+
].join("\n")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The `KEY:value` lines the scripts above print. */
|
|
187
|
+
export function commandFields(stdout: string): Map<string, string> {
|
|
188
|
+
const fields = new Map<string, string>()
|
|
189
|
+
for (const line of stdout.split("\n")) {
|
|
190
|
+
const idx = line.indexOf(":")
|
|
191
|
+
if (idx > 0) fields.set(line.slice(0, idx), line.slice(idx + 1).trim())
|
|
192
|
+
}
|
|
193
|
+
return fields
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `EXIT` of a poll or a read: null while the command runs (or its wrapper died without one). */
|
|
197
|
+
export function commandExitCode(fields: Map<string, string>): number | null {
|
|
198
|
+
const raw = fields.get("EXIT")
|
|
199
|
+
if (!raw || raw === "-") return null
|
|
200
|
+
const n = parseInt(raw, 10)
|
|
201
|
+
return Number.isFinite(n) ? n : null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A wrapper still alive with an exit code already written is finishing up: done, not running. */
|
|
205
|
+
export const commandRunning = (fields: Map<string, string>) => commandExitCode(fields) === null && fields.get("ALIVE") === "1"
|
package/src/index.ts
CHANGED