@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
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
[RFC
|
|
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.
|
|
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
|
+
}
|