@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 CHANGED
@@ -198,6 +198,59 @@ What dtach doesn't do is remember the screen, so the session is recorded inside
198
198
  scripts this module builds, so anything that doesn't come through it is a command injection into
199
199
  someone's container.
200
200
 
201
+ ### `@spunto/build/sandbox-agent` — the agent that lives in the box
202
+
203
+ A box that carries its own agent needs no Docker socket on the control plane's side: its main
204
+ process runs `bootstrap.sh`, which runs `agent.mjs`, which dials the control plane over a WebSocket
205
+ and serves exec, persistent terminals (dtach + `script(1)` + replay), TCP tunnels, listening ports
206
+ and the project's setup state. The three files ship here **as files**. This package does not read
207
+ them; it says where they are, and you serve them:
208
+
209
+ ```ts
210
+ import { readFileSync } from "node:fs"
211
+ import { SANDBOX_AGENT_FILES, SANDBOX_AGENT_CONTENT_TYPES } from "@spunto/build/sandbox-agent"
212
+
213
+ app.get("/api/sandboxes/agent.mjs", (c) =>
214
+ c.body(readFileSync(SANDBOX_AGENT_FILES.agent, "utf8"), 200, { "Content-Type": SANDBOX_AGENT_CONTENT_TYPES.agent }))
215
+ ```
216
+
217
+ `SPUNTO_AGENT_URL`, `SPUNTO_AGENT_CONNECT_URL` and `SPUNTO_NODE` (a Node >= 22 you mounted in the
218
+ box, so nothing is downloaded) point it at any control plane.
219
+
220
+ ### `@spunto/build/agent-protocol` — the wire, written down
221
+
222
+ The messages both ways (`PlatformMessage`, `AgentMessage`), the project plan, the close codes and
223
+ cadences, and the codec of tunnel frames (`[1][id length][id][payload]`). Additive only: a message
224
+ either side does not know is ignored by the other.
225
+
226
+ ### `@spunto/build/agent-connection` — the control plane's half
227
+
228
+ `AgentConnection` keeps what waits on one agent (execs, requests, terminals, tunnels) and routes
229
+ the wire's messages to it. It works on a minimal socket that its owner *feeds* — so no WebSocket
230
+ library comes with it:
231
+
232
+ ```ts
233
+ import { AgentConnection } from "@spunto/build/agent-connection"
234
+
235
+ wss.on("connection", (ws) => ws.once("message", (raw) => {
236
+ // …authenticate the hello yourself…
237
+ const conn = new AgentConnection(ws, { label: "Worker", onMessage: (m) => m.type === "project_state" && save(m.state) })
238
+ ws.on("message", (data, isBinary) => conn.receive(data, isBinary))
239
+ ws.on("pong", () => conn.heard())
240
+ ws.on("close", () => conn.closed())
241
+ }))
242
+
243
+ await conn.exec({ command: "git status", timeoutMs: 30_000 })
244
+ ```
245
+
246
+ ### `@spunto/build/commands` — background commands, the scripts
247
+
248
+ A background command is a directory in the box (`~/.spunto/commands/<id>/`), so it outlives the
249
+ control plane that started it. `startScript`, `pollScript`, `readScript`, `stdinScript` and
250
+ `cancelScript` build the shell; `commandFields` reads what it prints. Run them however you reach the
251
+ box — `docker exec`, or the agent's `exec`. `isCommandId` is a security boundary, like
252
+ `sanitizeSessionName`.
253
+
201
254
  ## The rule
202
255
 
203
256
  A module belongs in this package only if it imports **no** ORM schema, **no** HTTP framework, **no**
@@ -212,6 +265,10 @@ consumer that imports `./naming` or `./steps` and nothing else should not have a
212
265
  installed on its behalf. Import `./spec` without zod and resolution fails loudly, which is the
213
266
  right trade for not taxing every other entry point.
214
267
 
268
+ **The one place that reads its environment is `./sandbox-agent`** — and it is not module code: those
269
+ are files a control plane serves into a box, where they *are* the process. Nothing in this package
270
+ imports them.
271
+
215
272
  Concretely: **no platform I/O**. No database, no Docker socket, no WebSocket. This package produces
216
273
  strings and parses strings. Its only network calls are outbound HTTP on the caller's behalf — a
217
274
  public extension registry, and a model vendor's catalogue endpoint when you hand
@@ -226,10 +283,10 @@ degrades instead of throwing.
226
283
 
227
284
  ## What's deliberately *not* in it
228
285
 
229
- Image script generation (`buildImageScript` & co.), the devcontainer catalogs, the Docker client and
230
- the terminal bridge — all still duplicated between the two products, all staged for later. The
231
- reasoning, the measurements and the plan are in
232
- [RFC 0021](https://github.com/coderhammer/spunto/blob/main/rfc/0021-paquet-partage-build.md).
286
+ The Docker client, the registry of connections and the authentication of an agent — each control
287
+ plane keys and trusts its boxes its own way. The reasoning, the measurements and the plan are in
288
+ [RFC 0021](https://github.com/coderhammer/spunto/blob/main/rfc/0021-paquet-partage-build.md) and
289
+ [RFC 0047](https://github.com/coderhammer/spunto/blob/main/rfc/0047-l-agent-embarque-dans-spunto-build.md).
233
290
 
234
291
  ## Development
235
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,7 +62,26 @@
62
62
  "./agent-stream": {
63
63
  "types": "./src/agent-stream/index.ts",
64
64
  "import": "./src/agent-stream/index.ts"
65
- }
65
+ },
66
+ "./agent-protocol": {
67
+ "types": "./src/agent-protocol/index.ts",
68
+ "import": "./src/agent-protocol/index.ts"
69
+ },
70
+ "./agent-connection": {
71
+ "types": "./src/agent-connection/index.ts",
72
+ "import": "./src/agent-connection/index.ts"
73
+ },
74
+ "./commands": {
75
+ "types": "./src/commands/index.ts",
76
+ "import": "./src/commands/index.ts"
77
+ },
78
+ "./sandbox-agent": {
79
+ "types": "./src/sandbox-agent/index.ts",
80
+ "import": "./src/sandbox-agent/index.ts"
81
+ },
82
+ "./sandbox-agent/agent.mjs": "./src/sandbox-agent/agent.mjs",
83
+ "./sandbox-agent/bootstrap.sh": "./src/sandbox-agent/bootstrap.sh",
84
+ "./sandbox-agent/runner.sh": "./src/sandbox-agent/runner.sh"
66
85
  },
67
86
  "files": [
68
87
  "src",
@@ -78,6 +97,7 @@
78
97
  "devDependencies": {
79
98
  "typescript": "^5",
80
99
  "vitest": "^4.1.10",
100
+ "ws": "^8.22.0",
81
101
  "zod": "^4.6.5"
82
102
  },
83
103
  "peerDependencies": {
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The control plane's half of one in-box agent's connection (RFC 0047 § 3.3): what is waiting on
3
+ * the agent — execs, requests, terminals, tunnels — and how the wire's messages reach it.
4
+ *
5
+ * No WebSocket library in here: the connection works on `AgentSocket`, which the `WebSocket` of
6
+ * `ws` satisfies as is, and is *fed* by its owner — `receive` for each frame, `heard` for each
7
+ * pong, `closed` once. Who the agent is (its `hello`, its token) and where connections are kept
8
+ * (by sandbox, by worker) stay with the app; so does anything about freezing a box, which comes in
9
+ * through `deadline` and the liveness options.
10
+ */
11
+
12
+ import {
13
+ AGENT_PING_MS,
14
+ AGENT_SILENCE_MS,
15
+ decodeTunnelFrame,
16
+ encodeTunnelFrame,
17
+ type AgentMessage,
18
+ type ExecResult,
19
+ type PlatformMessage,
20
+ } from "../agent-protocol/index"
21
+
22
+ /** What the connection needs of a socket. `ws`'s `WebSocket` is one. */
23
+ export interface AgentSocket {
24
+ /** 1 = open, as in the WebSocket standard. */
25
+ readonly readyState: number
26
+ readonly bufferedAmount: number
27
+ send(data: string | Uint8Array): void
28
+ close(code?: number, reason?: string): void
29
+ /** Drops the socket without a closing handshake — a peer that is gone never completes one. */
30
+ terminate?(): void
31
+ /** A protocol-level ping, answered by a pong the owner reports with `heard()`. */
32
+ ping?(): void
33
+ }
34
+
35
+ const OPEN = 1
36
+
37
+ /** A timer that may be cancelled. The default is a plain `setTimeout`. */
38
+ export type Deadline = { cancel(): void }
39
+ export type DeadlineFactory = (budgetMs: number, onTimeout: () => void) => Deadline
40
+
41
+ const plainDeadline: DeadlineFactory = (budgetMs, onTimeout) => {
42
+ const t = setTimeout(onTimeout, budgetMs)
43
+ return { cancel: () => clearTimeout(t) }
44
+ }
45
+
46
+ /** `agentGone`: the agent's connection dropped (a resume, a redial) — the session itself lives on. */
47
+ export type TerminalHandlers = { onData: (data: Uint8Array) => void; onExit: (code: number | null, agentGone?: boolean) => void }
48
+ export type TunnelHandlers = { onData: (data: Uint8Array) => void; onClose: (error?: string) => void }
49
+
50
+ export type AgentConnectionOptions = {
51
+ /** Timers for execs and requests (a freeze-aware one, for a control plane that freezes boxes). */
52
+ deadline?: DeadlineFactory
53
+ /** Every message from the agent, after the connection has handled it — `project_state`, `heartbeat`… */
54
+ onMessage?: (msg: AgentMessage) => void
55
+ /** Anything at all came from the agent (a frame, a pong). */
56
+ onHeard?: () => void
57
+ liveness?: {
58
+ /** A connection silent this long is terminated and `onSilent` told. */
59
+ silenceMs?: number
60
+ /** Not now: the silence is not counted (a box frozen on purpose). */
61
+ paused?: () => boolean
62
+ /** Count the silence from no earlier than this (a box that just came out of a freeze). */
63
+ since?: () => number
64
+ onSilent?: () => void
65
+ }
66
+ /** The application-level `ping`. 0 turns it off. */
67
+ pingMs?: number
68
+ /** Ids of execs and requests. */
69
+ newId?: () => string
70
+ /**
71
+ * What the box is called in the errors this connection rejects with: "Sandbox" gives "Sandbox
72
+ * agent is not connected", "Sandbox agent disconnected", "Sandbox exec timed out"… — callers
73
+ * match on those to tell an unreachable box from a failed command.
74
+ */
75
+ label?: string
76
+ }
77
+
78
+ type Pending<T> = { resolve: (r: T) => void; reject: (e: Error) => void; timer: Deadline }
79
+ type Tunnel = TunnelHandlers & { ready?: Pending<void> }
80
+
81
+ const NodeBuffer = (globalThis as { Buffer?: { from(data: string | Uint8Array, enc?: string): Uint8Array & { toString(enc: string): string } } }).Buffer
82
+
83
+ /** Base64 of bytes — Node's Buffer where there is one, the platform's `btoa` elsewhere. */
84
+ export function bytesToBase64(bytes: Uint8Array): string {
85
+ if (NodeBuffer) return NodeBuffer.from(bytes).toString("base64")
86
+ let bin = ""
87
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
88
+ return btoa(bin)
89
+ }
90
+
91
+ export function base64ToBytes(b64: string): Uint8Array {
92
+ if (NodeBuffer) return NodeBuffer.from(b64, "base64")
93
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
94
+ }
95
+
96
+ export class AgentConnection {
97
+ readonly connectedAt = Date.now()
98
+ lastHeartbeat: Record<string, unknown> | null = null
99
+ private lastHeardAt = Date.now()
100
+ private readonly execs = new Map<string, Pending<ExecResult>>()
101
+ private readonly requests = new Map<string, Pending<unknown>>()
102
+ private readonly terminals = new Map<string, TerminalHandlers>()
103
+ private readonly tunnels = new Map<string, Tunnel>()
104
+ private readonly timers: ReturnType<typeof setInterval>[] = []
105
+ private readonly deadline: DeadlineFactory
106
+ private readonly newId: () => string
107
+ private readonly label: string
108
+ private ended = false
109
+
110
+ constructor(
111
+ readonly socket: AgentSocket,
112
+ private readonly options: AgentConnectionOptions = {},
113
+ ) {
114
+ this.deadline = options.deadline ?? plainDeadline
115
+ this.newId = options.newId ?? (() => crypto.randomUUID())
116
+ this.label = options.label ?? "Box"
117
+ const pingMs = options.pingMs ?? AGENT_PING_MS
118
+ if (pingMs > 0) this.timers.push(setInterval(() => this.send({ type: "ping", at: Date.now() }), pingMs))
119
+ const silenceMs = options.liveness?.silenceMs ?? AGENT_SILENCE_MS
120
+ const every = Math.min(15_000, Math.max(1_000, Math.floor(silenceMs / 3)))
121
+ this.timers.push(setInterval(() => this.checkLiveness(silenceMs), every))
122
+ }
123
+
124
+ get online(): boolean {
125
+ return !this.ended && this.socket.readyState === OPEN
126
+ }
127
+
128
+ /** What the socket still has to write — for the caller's backpressure. */
129
+ get bufferedAmount(): number {
130
+ return this.socket.bufferedAmount
131
+ }
132
+
133
+ // ── fed by the owner ─────────────────────────────────────────────────────────────────────
134
+
135
+ /** One frame from the agent: text (JSON), or binary (tunnel bytes). */
136
+ receive(data: Uint8Array | string, isBinary: boolean): void {
137
+ this.heard()
138
+ if (isBinary && typeof data !== "string") {
139
+ const frame = decodeTunnelFrame(data)
140
+ if (frame) this.tunnels.get(frame.tunnelId)?.onData(frame.payload)
141
+ return
142
+ }
143
+ let msg: AgentMessage
144
+ try {
145
+ msg = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data))
146
+ } catch {
147
+ return
148
+ }
149
+ this.handle(msg)
150
+ this.options.onMessage?.(msg)
151
+ }
152
+
153
+ /** A pong, or anything else that says the agent is there. */
154
+ heard(): void {
155
+ this.lastHeardAt = Date.now()
156
+ this.options.onHeard?.()
157
+ }
158
+
159
+ /** The socket closed: everything waiting on it fails now. Idempotent. */
160
+ closed(): void {
161
+ if (this.ended) return
162
+ this.ended = true
163
+ for (const t of this.timers) clearInterval(t)
164
+ const gone = () => new Error(`${this.label} agent disconnected`)
165
+ for (const p of [...this.requests.values(), ...this.execs.values()]) {
166
+ p.timer.cancel()
167
+ p.reject(gone())
168
+ }
169
+ this.requests.clear()
170
+ this.execs.clear()
171
+ for (const t of this.terminals.values()) t.onExit(null, true)
172
+ this.terminals.clear()
173
+ for (const t of this.tunnels.values()) {
174
+ if (t.ready) {
175
+ t.ready.timer.cancel()
176
+ t.ready.reject(gone())
177
+ } else t.onClose(`${this.label.toLowerCase()} agent disconnected`)
178
+ }
179
+ this.tunnels.clear()
180
+ }
181
+
182
+ // ── asked by the app ─────────────────────────────────────────────────────────────────────
183
+
184
+ send(msg: PlatformMessage): void {
185
+ if (this.online) this.socket.send(JSON.stringify(msg))
186
+ }
187
+
188
+ /** Drop the connection now (the box is known dead): pending calls fail at once. */
189
+ drop(): void {
190
+ if (this.socket.terminate) this.socket.terminate()
191
+ else this.socket.close()
192
+ this.closed()
193
+ }
194
+
195
+ /** Run a command in the box, with a login shell, as the project's user. */
196
+ exec(params: { command: string; cwd?: string; env?: Record<string, string>; timeoutMs?: number }): Promise<ExecResult> {
197
+ if (!this.online) return Promise.reject(new Error(`${this.label} agent is not connected`))
198
+ const id = this.newId()
199
+ const timeoutMs = params.timeoutMs ?? 60_000
200
+ return new Promise<ExecResult>((resolve, reject) => {
201
+ // A little longer than the agent's own kill timer, so the normal path is the agent's
202
+ // `timedOut: true` result rather than this rejection.
203
+ const timer = this.deadline(timeoutMs + 10_000, () => {
204
+ this.execs.delete(id)
205
+ reject(new Error(`${this.label} exec timed out`))
206
+ })
207
+ this.execs.set(id, { resolve, reject, timer })
208
+ this.send({ type: "exec", id, ...params, timeoutMs })
209
+ })
210
+ }
211
+
212
+ /** A request the agent answers with a `reply` (`sessions`, `ports`). */
213
+ request<T>(msg: { type: "sessions"; op?: "list" | "create" | "kill"; name?: string } | { type: "ports" }, timeoutMs = 15_000): Promise<T> {
214
+ if (!this.online) return Promise.reject(new Error(`${this.label} agent is not connected`))
215
+ const id = this.newId()
216
+ return new Promise<T>((resolve, reject) => {
217
+ const timer = this.deadline(timeoutMs, () => {
218
+ this.requests.delete(id)
219
+ reject(new Error(`${this.label} agent did not answer`))
220
+ })
221
+ this.requests.set(id, { resolve: resolve as (r: unknown) => void, reject, timer })
222
+ this.send({ ...msg, id } as PlatformMessage)
223
+ })
224
+ }
225
+
226
+ /**
227
+ * `session`: the dtach session to attach to (created if absent) — it outlives this connection.
228
+ * `logs`: the project runner's output, followed — a worker's "container logs".
229
+ */
230
+ openTerminal(termId: string, opts: { cols: number; rows: number; session?: string; mode?: "terminal" | "logs" }, handlers: TerminalHandlers): void {
231
+ if (!this.online) throw new Error(`${this.label} agent is not connected`)
232
+ this.terminals.set(termId, handlers)
233
+ this.send({ type: "term_open", termId, cols: opts.cols, rows: opts.rows, session: opts.session ?? "main", mode: opts.mode ?? "terminal" })
234
+ }
235
+
236
+ terminalInput(termId: string, data: Uint8Array): void {
237
+ this.send({ type: "term_input", termId, data: bytesToBase64(data) })
238
+ }
239
+
240
+ terminalResize(termId: string, cols: number, rows: number): void {
241
+ this.send({ type: "term_resize", termId, cols, rows })
242
+ }
243
+
244
+ closeTerminal(termId: string): void {
245
+ this.terminals.delete(termId)
246
+ this.send({ type: "term_close", termId })
247
+ }
248
+
249
+ /** Resolves once the agent is connected to the port; rejects when nothing listens there. */
250
+ openTunnel(tunnelId: string, port: number, handlers: TunnelHandlers, timeoutMs = 10_000): Promise<void> {
251
+ if (!this.online) return Promise.reject(new Error(`${this.label} agent is not connected`))
252
+ return new Promise<void>((resolve, reject) => {
253
+ // Not the freeze-aware deadline: a tunnel is a browser waiting, now.
254
+ const timer = plainDeadline(timeoutMs, () => {
255
+ this.tunnels.delete(tunnelId)
256
+ reject(new Error(`${this.label} agent did not open the tunnel`))
257
+ })
258
+ this.tunnels.set(tunnelId, { ...handlers, ready: { resolve, reject, timer } })
259
+ this.send({ type: "tunnel_open", tunnelId, port })
260
+ })
261
+ }
262
+
263
+ /** Returns the socket's buffered amount, for the caller's backpressure. */
264
+ sendTunnelData(tunnelId: string, data: Uint8Array): number {
265
+ if (!this.online) return 0
266
+ this.socket.send(encodeTunnelFrame(tunnelId, data))
267
+ return this.socket.bufferedAmount
268
+ }
269
+
270
+ closeTunnel(tunnelId: string): void {
271
+ if (!this.tunnels.delete(tunnelId)) return
272
+ this.send({ type: "tunnel_close", tunnelId })
273
+ }
274
+
275
+ // ── internals ────────────────────────────────────────────────────────────────────────────
276
+
277
+ private handle(msg: AgentMessage): void {
278
+ switch (msg.type) {
279
+ case "exec_result": {
280
+ const pending = this.execs.get(msg.id)
281
+ if (!pending) return
282
+ pending.timer.cancel()
283
+ this.execs.delete(msg.id)
284
+ const { type: _t, id: _id, ...result } = msg
285
+ pending.resolve(result)
286
+ return
287
+ }
288
+ case "reply": {
289
+ const pending = this.requests.get(msg.id)
290
+ if (!pending) return
291
+ pending.timer.cancel()
292
+ this.requests.delete(msg.id)
293
+ pending.resolve(msg.result)
294
+ return
295
+ }
296
+ case "term_data":
297
+ this.terminals.get(msg.termId)?.onData(base64ToBytes(msg.data ?? ""))
298
+ return
299
+ case "term_exit": {
300
+ const t = this.terminals.get(msg.termId)
301
+ this.terminals.delete(msg.termId)
302
+ t?.onExit(msg.code ?? null)
303
+ return
304
+ }
305
+ case "tunnel_ready": {
306
+ const t = this.tunnels.get(msg.tunnelId)
307
+ if (t?.ready) {
308
+ t.ready.timer.cancel()
309
+ t.ready.resolve()
310
+ t.ready = undefined
311
+ }
312
+ return
313
+ }
314
+ case "tunnel_close": {
315
+ const t = this.tunnels.get(msg.tunnelId)
316
+ this.tunnels.delete(msg.tunnelId)
317
+ if (t?.ready) {
318
+ t.ready.timer.cancel()
319
+ t.ready.reject(new Error(msg.error ?? "tunnel refused"))
320
+ } else t?.onClose(msg.error)
321
+ return
322
+ }
323
+ case "heartbeat": {
324
+ const { type: _t, ...rest } = msg
325
+ this.lastHeartbeat = { ...rest, at: new Date().toISOString() }
326
+ return
327
+ }
328
+ case "probe":
329
+ // The agent was frozen and asks whether this socket still reaches us.
330
+ if (this.online) this.socket.send(JSON.stringify({ type: "probe_ack", id: msg.id }))
331
+ return
332
+ }
333
+ }
334
+
335
+ private checkLiveness(silenceMs: number): void {
336
+ if (!this.online || this.options.liveness?.paused?.()) return
337
+ const silentFor = Date.now() - Math.max(this.lastHeardAt, this.options.liveness?.since?.() ?? 0)
338
+ if (silentFor > silenceMs) {
339
+ this.drop()
340
+ this.options.liveness?.onSilent?.()
341
+ return
342
+ }
343
+ try {
344
+ this.socket.ping?.()
345
+ } catch {}
346
+ }
347
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The wire between a control plane and the agent inside a box (RFC 0035, RFC 0047) — written down
3
+ * once. `agent.mjs` (`./sandbox-agent`) imports nothing, so these types do not constrain it: the
4
+ * agent's own tests, run against a fake platform, are what keep the two in step.
5
+ *
6
+ * One WebSocket per box, dialed by the agent. JSON text frames both ways, plus binary frames for
7
+ * tunnel bytes (`encodeTunnelFrame`). The agent authenticates in its first message (`hello`):
8
+ * Node's built-in WebSocket client cannot set headers.
9
+ *
10
+ * **Additive only.** A message either side does not know is ignored by the other — that is what
11
+ * lets an agent started before a deploy keep talking to the control plane after it. A field is
12
+ * never renamed nor given a new meaning; a new one is optional.
13
+ */
14
+
15
+ // ── Addresses, codes, cadences ───────────────────────────────────────────────────────────────
16
+
17
+ /** Where the agent dials, under the control plane's URL (`SPUNTO_AGENT_CONNECT_URL` overrides it). */
18
+ export const AGENT_CONNECT_PATH = "/api/sandbox-agent/connect"
19
+ /** Where `bootstrap.sh` fetches the agent, under the control plane's URL (`SPUNTO_AGENT_URL` overrides it). */
20
+ export const AGENT_SCRIPT_PATH = "/api/sandboxes/agent.mjs"
21
+ /** Where a box's main process fetches the bootstrap: `curl -fsSL $SPUNTO_API_URL<path> | sh`. */
22
+ export const AGENT_BOOTSTRAP_PATH = "/api/sandboxes/bootstrap.sh"
23
+
24
+ /** Close codes the control plane uses, and what the agent does on each. */
25
+ export const AGENT_CLOSE = {
26
+ /** A newer connection of the same box took over. */
27
+ replaced: 4000,
28
+ /** The box was stopped by the platform. */
29
+ stopped: 4001,
30
+ /** The first message was not a parseable `hello`. */
31
+ badHello: 4400,
32
+ /** The token was refused (or no `hello` came in time): the agent says so and backs off. */
33
+ tokenRefused: 4401,
34
+ /** The box is stopped or stopping: the agent retries every few seconds, a start lets it in. */
35
+ notRunning: 4409,
36
+ } as const
37
+
38
+ /** The agent's heartbeat. */
39
+ export const AGENT_HEARTBEAT_MS = 15_000
40
+ /** A connection silent this long (no message, no pong) is dead: a box that vanished leaves no FIN. */
41
+ export const AGENT_SILENCE_MS = 60_000
42
+ /** The control plane's application-level `ping`. */
43
+ export const AGENT_PING_MS = 20_000
44
+ /** What a `hello` has to arrive within, once the socket is open. */
45
+ export const AGENT_HELLO_TIMEOUT_MS = 10_000
46
+
47
+ // ── The project plan ─────────────────────────────────────────────────────────────────────────
48
+
49
+ /** What the agent is handed at every `hello` when its box belongs to a project. */
50
+ export type ProjectPlan = {
51
+ type: "project"
52
+ projectId: string
53
+ version: number
54
+ /** Changes whenever the recipe would bake something different: re-bake on the next start. */
55
+ bakeKey: string
56
+ /** The image recipe (`buildImageScript`). */
57
+ bake: string
58
+ /** The worker script (`buildSetupScript` + `buildStartScript`). */
59
+ worker: string
60
+ /** Environment of every process the agent starts (lifecycle, exec, terminals) — secrets included. */
61
+ env: Record<string, string>
62
+ /** The user terminals and commands run as once the recipe has created it. */
63
+ user: string
64
+ /** The runner script (`./sandbox-agent` runner.sh): recipe once per bake key, then the worker script. */
65
+ runner: string
66
+ /** A builder: run the recipe, report `baked`, and nothing else — no user, no clone. */
67
+ bakeOnly?: boolean
68
+ /**
69
+ * The image's own `ENV`, under the box's: a provider that boots the image's files (a microVM)
70
+ * does not apply it — `PATH` merged, the image's entries first, the rest only if unset.
71
+ */
72
+ imageEnv?: Record<string, string>
73
+ }
74
+
75
+ export const PROJECT_PHASES = ["pending", "baking", "baked", "setting-up", "running", "exited", "failed"] as const
76
+ /**
77
+ * Where the box is with its project. `running` means the worker script said `ready` for *this*
78
+ * start, postStart included; `baked` is a builder's end (`bakeOnly`).
79
+ */
80
+ export type ProjectPhase = (typeof PROJECT_PHASES)[number]
81
+
82
+ export type ProjectState = {
83
+ phase: ProjectPhase
84
+ version: number
85
+ bakeKey: string
86
+ /** The runner's exit code, null while it runs or when unknown. */
87
+ exitCode: number | null
88
+ /** The worker script's status file (`SetupStatus` of `./types`), as the box wrote it. */
89
+ worker: unknown
90
+ logTail: string
91
+ at: string
92
+ }
93
+
94
+ // ── Messages ─────────────────────────────────────────────────────────────────────────────────
95
+
96
+ export type AgentInfo = {
97
+ agentVersion: string
98
+ hostname: string
99
+ platform: string
100
+ arch: string
101
+ node: string
102
+ cpus: number
103
+ memTotal: number
104
+ shell: string
105
+ pty: "python3" | "script" | "none"
106
+ workspace: string
107
+ }
108
+
109
+ export type ExecResult = {
110
+ exitCode: number | null
111
+ timedOut: boolean
112
+ error?: string
113
+ stdout: string
114
+ stderr: string
115
+ /** Either stream hit the agent's ceiling (1 MB each): only its head is there. */
116
+ truncated: boolean
117
+ durationMs: number
118
+ }
119
+
120
+ /** A terminal session of the box (a dtach socket), as `sessions` lists them. */
121
+ export type AgentSession = { name: string; windows: number; attached: boolean; createdAt: number }
122
+ /** A TCP port something listens on inside the box. */
123
+ export type AgentPort = { port: number; process?: string }
124
+
125
+ /** Control plane → agent. */
126
+ export type PlatformMessage =
127
+ | { type: "welcome"; sandboxId: string }
128
+ | { type: "ping"; at: number }
129
+ | { type: "probe_ack"; id: number }
130
+ | ProjectPlan
131
+ | { type: "exec"; id: string; command: string; cwd?: string; env?: Record<string, string>; timeoutMs: number }
132
+ | { type: "exec_cancel"; id: string }
133
+ /** `session`: the dtach session to attach to, created if absent. `logs`: the runner's output, followed. */
134
+ | { type: "term_open"; termId: string; cols: number; rows: number; session?: string; mode?: "terminal" | "logs" }
135
+ /** `data`: base64. */
136
+ | { type: "term_input"; termId: string; data: string }
137
+ | { type: "term_resize"; termId: string; cols: number; rows: number }
138
+ | { type: "term_close"; termId: string }
139
+ /** Answered by a `reply` whose `result` is `AgentSession[]`. */
140
+ | { type: "sessions"; id: string; op?: "list" | "create" | "kill"; name?: string }
141
+ /** Answered by a `reply` whose `result` is `AgentPort[]`. */
142
+ | { type: "ports"; id: string }
143
+ | { type: "tunnel_open"; tunnelId: string; port: number }
144
+ | { type: "tunnel_close"; tunnelId: string }
145
+
146
+ /** Agent → control plane. */
147
+ export type AgentMessage =
148
+ | { type: "hello"; token: string; info: AgentInfo }
149
+ | { type: "heartbeat" }
150
+ | { type: "pong"; at: number }
151
+ /** After a resume (SIGUSR1): does this socket still reach the platform? */
152
+ | { type: "probe"; id: number }
153
+ | { type: "project_state"; state: ProjectState }
154
+ | ({ type: "exec_result"; id: string } & ExecResult)
155
+ | { type: "reply"; id: string; result: unknown }
156
+ /** `data`: base64. */
157
+ | { type: "term_data"; termId: string; data: string }
158
+ | { type: "term_exit"; termId: string; code: number | null }
159
+ | { type: "tunnel_ready"; tunnelId: string }
160
+ | { type: "tunnel_close"; tunnelId: string; error?: string }
161
+
162
+ // ── Tunnel frames ────────────────────────────────────────────────────────────────────────────
163
+
164
+ /** Tunnel bytes travel as binary frames, both ways: `[1][id length][id][payload]`. */
165
+ export const TUNNEL_FRAME_DATA = 1
166
+
167
+ const encoder = new TextEncoder()
168
+ const decoder = new TextDecoder()
169
+
170
+ export function encodeTunnelFrame(tunnelId: string, payload: Uint8Array): Uint8Array {
171
+ const id = encoder.encode(tunnelId)
172
+ if (id.length > 255) throw new RangeError(`tunnel id too long (${id.length} bytes)`)
173
+ const frame = new Uint8Array(2 + id.length + payload.length)
174
+ frame[0] = TUNNEL_FRAME_DATA
175
+ frame[1] = id.length
176
+ frame.set(id, 2)
177
+ frame.set(payload, 2 + id.length)
178
+ return frame
179
+ }
180
+
181
+ /**
182
+ * The tunnel and payload of a frame, null for anything else. The payload is a view of the frame,
183
+ * of the frame's own type: a Node `Buffer` in, a `Buffer` out.
184
+ */
185
+ export function decodeTunnelFrame<T extends Uint8Array>(frame: T): { tunnelId: string; payload: T } | null {
186
+ if (frame.length < 2 || frame[0] !== TUNNEL_FRAME_DATA) return null
187
+ const idLen = frame[1]!
188
+ if (frame.length < 2 + idLen) return null
189
+ return { tunnelId: decoder.decode(frame.subarray(2, 2 + idLen)), payload: frame.subarray(2 + idLen) as T }
190
+ }