mirab 0.0.0-stage → 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/LICENSE +201 -0
- package/README.md +226 -2
- package/package.json +58 -4
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// Linux backend: one Firecracker microVM per job.
|
|
2
|
+
// Rootfs is shared and read-only; the job work directory is packed into a
|
|
3
|
+
// per-VM ext4 image attached as a second drive, so the base image is never
|
|
4
|
+
// written and nothing of the host is visible to the guest.
|
|
5
|
+
import { rmSync } from 'node:fs'
|
|
6
|
+
import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
|
|
7
|
+
import {
|
|
8
|
+
createConnection,
|
|
9
|
+
createServer,
|
|
10
|
+
type Server,
|
|
11
|
+
type Socket,
|
|
12
|
+
} from 'node:net'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import {
|
|
15
|
+
mkdir,
|
|
16
|
+
mkdtemp,
|
|
17
|
+
open as openFile,
|
|
18
|
+
rm,
|
|
19
|
+
truncate,
|
|
20
|
+
readFile,
|
|
21
|
+
rename,
|
|
22
|
+
stat,
|
|
23
|
+
} from 'node:fs/promises'
|
|
24
|
+
import { existsSync, accessSync, constants } from 'node:fs'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { bootNetArgs, networkState, planNetwork } from './network'
|
|
27
|
+
import {
|
|
28
|
+
checkGuestProtocol,
|
|
29
|
+
guestExec,
|
|
30
|
+
guestFiles,
|
|
31
|
+
guestPing,
|
|
32
|
+
guestPty,
|
|
33
|
+
readOneMessage,
|
|
34
|
+
guestPut,
|
|
35
|
+
GuestProtocolError,
|
|
36
|
+
type Channel,
|
|
37
|
+
type Isolation,
|
|
38
|
+
type Opener,
|
|
39
|
+
} from './session'
|
|
40
|
+
|
|
41
|
+
export interface FirecrackerConfig {
|
|
42
|
+
firecracker: string
|
|
43
|
+
kernel: string
|
|
44
|
+
/** Read-only base image containing mirab-guest-agent and /init-mirab.sh. */
|
|
45
|
+
rootfs: string
|
|
46
|
+
memoryMiB: number
|
|
47
|
+
vcpus: number
|
|
48
|
+
/** Guest vsock port the agent listens on. */
|
|
49
|
+
port: number
|
|
50
|
+
/** `block` = no network device; `allow` = tap or refuse; `auto` = tap when set up, else none. */
|
|
51
|
+
egress: 'auto' | 'block' | 'allow'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const GUEST_WORK = '/workspace'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The Firecracker to run when isolation.json names none: the one shipped next
|
|
58
|
+
* to the mirab executable (where mirab's packaging will put it), else
|
|
59
|
+
* `firecracker` on PATH. `vm.firecracker` in isolation.json wins over both.
|
|
60
|
+
*/
|
|
61
|
+
export function defaultFirecracker(execPath = process.execPath) {
|
|
62
|
+
const bundled = join(dirname(execPath), 'firecracker')
|
|
63
|
+
try {
|
|
64
|
+
accessSync(bundled, constants.X_OK)
|
|
65
|
+
return bundled
|
|
66
|
+
} catch {
|
|
67
|
+
return 'firecracker'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** /dev/kvm, Firecracker and mke2fs: what this host needs besides the guest image. */
|
|
72
|
+
export function firecrackerHostReady(firecracker: string) {
|
|
73
|
+
try {
|
|
74
|
+
accessSync('/dev/kvm', constants.R_OK | constants.W_OK)
|
|
75
|
+
execFileSync(firecracker, ['--version'], { stdio: 'ignore' })
|
|
76
|
+
execFileSync('mke2fs', ['-V'], { stdio: 'ignore' })
|
|
77
|
+
return true
|
|
78
|
+
} catch {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function firecrackerAvailable(
|
|
84
|
+
c: Pick<FirecrackerConfig, 'firecracker' | 'kernel' | 'rootfs'>,
|
|
85
|
+
) {
|
|
86
|
+
try {
|
|
87
|
+
accessSync(c.kernel, constants.R_OK)
|
|
88
|
+
accessSync(c.rootfs, constants.R_OK)
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
return firecrackerHostReady(c.firecracker)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Minimal HTTP/1.1 over the API unix socket (Bun's node:http lacks socketPath).
|
|
96
|
+
function api(socket: string, method: string, path: string, body: unknown) {
|
|
97
|
+
return new Promise<void>((resolve, reject) => {
|
|
98
|
+
const data = JSON.stringify(body)
|
|
99
|
+
const sock = createConnection(socket)
|
|
100
|
+
let text = ''
|
|
101
|
+
sock.on('error', reject)
|
|
102
|
+
sock.on('connect', () =>
|
|
103
|
+
sock.write(
|
|
104
|
+
`${method} ${path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(data)}\r\n\r\n${data}`,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
let done = false
|
|
108
|
+
const finish = () => {
|
|
109
|
+
if (done) return
|
|
110
|
+
done = true
|
|
111
|
+
sock.destroy()
|
|
112
|
+
const status = Number(/^HTTP\/1\.1 (\d+)/.exec(text)?.[1] ?? 500)
|
|
113
|
+
if (status < 300) resolve()
|
|
114
|
+
else
|
|
115
|
+
reject(
|
|
116
|
+
new Error(`firecracker ${method} ${path}: ${text.slice(0, 300)}`),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
// Firecracker answers 204 without closing: the header block is the reply.
|
|
120
|
+
sock.on('data', (c) => {
|
|
121
|
+
text += c
|
|
122
|
+
if (text.includes('\r\n\r\n')) finish()
|
|
123
|
+
})
|
|
124
|
+
sock.on('close', finish)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
129
|
+
|
|
130
|
+
export function vsockChannel(uds: string, port: number): Promise<Channel> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const sock: Socket = createConnection(uds)
|
|
133
|
+
let head = Buffer.alloc(0),
|
|
134
|
+
ready = false,
|
|
135
|
+
dataCb: (c: Buffer) => void = () => {},
|
|
136
|
+
closeCb: (e?: Error) => void = () => {}
|
|
137
|
+
const channel: Channel = {
|
|
138
|
+
write: (d) => void sock.write(d),
|
|
139
|
+
onData: (cb) => (dataCb = cb),
|
|
140
|
+
onClose: (cb) => (closeCb = cb),
|
|
141
|
+
close: () => sock.destroy(),
|
|
142
|
+
}
|
|
143
|
+
sock.on('error', (e) => (ready ? closeCb(e) : reject(e)))
|
|
144
|
+
sock.on('close', () =>
|
|
145
|
+
ready ? closeCb() : reject(new Error('vsock closed')),
|
|
146
|
+
)
|
|
147
|
+
sock.on('connect', () => sock.write(`CONNECT ${port}\n`))
|
|
148
|
+
sock.on('data', (chunk) => {
|
|
149
|
+
if (ready) return dataCb(chunk)
|
|
150
|
+
head = Buffer.concat([head, chunk])
|
|
151
|
+
const nl = head.indexOf('\n')
|
|
152
|
+
if (nl < 0) return
|
|
153
|
+
if (!head.subarray(0, nl).toString().startsWith('OK'))
|
|
154
|
+
return reject(new Error('vsock refused: ' + head.subarray(0, nl)))
|
|
155
|
+
ready = true
|
|
156
|
+
resolve(channel)
|
|
157
|
+
const rest = head.subarray(nl + 1)
|
|
158
|
+
if (rest.length) dataCb(rest)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The Isolation members every vsock backend shares (Firecracker, vz): the
|
|
165
|
+
* work directory (when there is one) appears at /workspace, and guest connections to host port N
|
|
166
|
+
* arrive on the unix socket `<uds>_N` (Firecracker's convention, which the vz
|
|
167
|
+
* helper follows too).
|
|
168
|
+
*/
|
|
169
|
+
export function vsockMembers(
|
|
170
|
+
open: Opener,
|
|
171
|
+
uds: string,
|
|
172
|
+
work: string | null,
|
|
173
|
+
listeners: Server[],
|
|
174
|
+
): Pick<
|
|
175
|
+
Isolation,
|
|
176
|
+
| 'guestPath'
|
|
177
|
+
| 'put'
|
|
178
|
+
| 'exec'
|
|
179
|
+
| 'pty'
|
|
180
|
+
| 'files'
|
|
181
|
+
| 'onGuestMessage'
|
|
182
|
+
| 'onGuestConnect'
|
|
183
|
+
> {
|
|
184
|
+
const toGuest = (p: string) =>
|
|
185
|
+
work === null
|
|
186
|
+
? p
|
|
187
|
+
: p === work
|
|
188
|
+
? GUEST_WORK
|
|
189
|
+
: p.startsWith(work + '/')
|
|
190
|
+
? GUEST_WORK + p.slice(work.length)
|
|
191
|
+
: p
|
|
192
|
+
return {
|
|
193
|
+
guestPath: toGuest,
|
|
194
|
+
put: (p, content, mode) => guestPut(open, toGuest(p), content, mode),
|
|
195
|
+
exec: (argv, o) => guestExec(open, argv, o),
|
|
196
|
+
pty: (request) => guestPty(open, request),
|
|
197
|
+
files: guestFiles(open),
|
|
198
|
+
onGuestMessage: (port, handler) => {
|
|
199
|
+
const server = createServer((c) => {
|
|
200
|
+
void readOneMessage(c)
|
|
201
|
+
.then(handler, () => {})
|
|
202
|
+
.finally(() => c.destroy())
|
|
203
|
+
})
|
|
204
|
+
server.listen(`${uds}_${port}`)
|
|
205
|
+
listeners.push(server)
|
|
206
|
+
},
|
|
207
|
+
// The path names this VM: the hypervisor delivers only its guest's
|
|
208
|
+
// connections there, so whoever serves it knows which VM is calling.
|
|
209
|
+
onGuestConnect: async (port, serve) => {
|
|
210
|
+
// A socket left by an earlier VM at this path would refuse the listen.
|
|
211
|
+
rmSync(`${uds}_${port}`, { force: true })
|
|
212
|
+
listeners.push(await serve(`${uds}_${port}`))
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** What a VM start may add to the backend's config. */
|
|
218
|
+
export interface VmStartOptions {
|
|
219
|
+
/** Grow this VM's root disk clone to this size before boot (sparse; never
|
|
220
|
+
* shrinks). The guest sees a bigger /dev/vda; growing the filesystem on it
|
|
221
|
+
* (resize2fs) is the guest's job. */
|
|
222
|
+
rootGiB?: number
|
|
223
|
+
/** vz: guest-to-host vsock ports to forward (default GUEST_TO_HOST_PORTS);
|
|
224
|
+
* [] for a VM that must not reach the host at all. Firecracker forwards a
|
|
225
|
+
* port only when someone serves it (onGuestConnect), so it needs no list. */
|
|
226
|
+
listen?: number[]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Sparse-grows a disk image file to `gib` GiB; refuses to shrink one. */
|
|
230
|
+
export async function growDisk(path: string, gib: number) {
|
|
231
|
+
const want = gib * 1024 ** 3
|
|
232
|
+
const have = (await stat(path)).size
|
|
233
|
+
if (want < have)
|
|
234
|
+
throw new Error(
|
|
235
|
+
`rootGiB ${gib} is smaller than the image (${(have / 1024 ** 3).toFixed(1)} GiB); a disk never shrinks`,
|
|
236
|
+
)
|
|
237
|
+
if (want > have) await truncate(path, want)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Checked bytes, not a signed 32-bit shift: large work images stay positive. */
|
|
241
|
+
export function workImageBytes(usedKiB: number) {
|
|
242
|
+
if (!Number.isSafeInteger(usedKiB) || usedKiB < 0)
|
|
243
|
+
throw new Error('invalid workspace disk usage')
|
|
244
|
+
const sizeMiB = Math.max(256, Math.ceil((usedKiB * 2) / 1024) + 128)
|
|
245
|
+
const bytes = sizeMiB * 1024 ** 2
|
|
246
|
+
if (!Number.isSafeInteger(bytes) || bytes > 8 * 1024 ** 3)
|
|
247
|
+
throw new Error('workspace VM image exceeds the 8 GiB limit')
|
|
248
|
+
return bytes
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface BootTimings {
|
|
252
|
+
imageMs: number
|
|
253
|
+
bootToAgentMs: number
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function startFirecracker(
|
|
257
|
+
cfg: FirecrackerConfig,
|
|
258
|
+
work: string | null,
|
|
259
|
+
vmDir: string,
|
|
260
|
+
log: (line: string) => void = () => {},
|
|
261
|
+
opts: VmStartOptions = {},
|
|
262
|
+
): Promise<Isolation & { timings: BootTimings; pid: number }> {
|
|
263
|
+
await mkdir(vmDir, { recursive: true, mode: 0o700 })
|
|
264
|
+
// Unix socket paths are limited to ~108 bytes, so they live in a short dir.
|
|
265
|
+
const sockDir = await mkdtemp(join(tmpdir(), 'gf-'))
|
|
266
|
+
const apiSock = join(sockDir, 'a'),
|
|
267
|
+
uds = join(sockDir, 'v'),
|
|
268
|
+
image = join(vmDir, 'work.ext4'),
|
|
269
|
+
root = join(vmDir, 'rootfs.ext4')
|
|
270
|
+
const t0 = Date.now()
|
|
271
|
+
// Pack the checkout into a fresh ext4 image (no root needed). No work
|
|
272
|
+
// directory (a CI runner): no second drive, nothing of the host's inside.
|
|
273
|
+
if (work !== null) {
|
|
274
|
+
const used = Number(
|
|
275
|
+
execFileSync('du', ['-sk', work], { encoding: 'utf8' }).split(/\s+/)[0],
|
|
276
|
+
)
|
|
277
|
+
const sizeBytes = workImageBytes(used)
|
|
278
|
+
await (await openFile(image, 'w', 0o600)).close()
|
|
279
|
+
await truncate(image, sizeBytes)
|
|
280
|
+
execFileSync('mke2fs', ['-q', '-t', 'ext4', '-d', work, '-F', image], {
|
|
281
|
+
stdio: 'ignore',
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
// This VM's own writable copy of the root disk, as on vz: copy-on-write
|
|
285
|
+
// where the filesystem can (reflink), a sparse copy where it can't.
|
|
286
|
+
execFileSync('cp', ['--reflink=auto', '--sparse=always', cfg.rootfs, root], {
|
|
287
|
+
stdio: 'ignore',
|
|
288
|
+
})
|
|
289
|
+
if (opts.rootGiB) await growDisk(root, opts.rootGiB)
|
|
290
|
+
const timings: BootTimings = { imageMs: Date.now() - t0, bootToAgentMs: 0 }
|
|
291
|
+
|
|
292
|
+
const plan = planNetwork(
|
|
293
|
+
cfg.egress,
|
|
294
|
+
networkState(),
|
|
295
|
+
join(tmpdir(), `mirab-vm-slots-${process.getuid?.() ?? 0}`),
|
|
296
|
+
)
|
|
297
|
+
log(plan.note)
|
|
298
|
+
const serial = await openFile(join(vmDir, 'serial.log'), 'w', 0o600)
|
|
299
|
+
const fc: ChildProcess = spawn(cfg.firecracker, ['--api-sock', apiSock], {
|
|
300
|
+
stdio: ['ignore', serial.fd, serial.fd],
|
|
301
|
+
})
|
|
302
|
+
// A VM never outlives mirab when it exits (as vz's helper stops when its
|
|
303
|
+
// input closes). `mirab dev` and the spawn worker turn SIGINT, SIGTERM and
|
|
304
|
+
// SIGHUP into an exit, so this runs for them; a signal that kills mirab
|
|
305
|
+
// outright (SIGKILL, or one nothing handles) skips it.
|
|
306
|
+
const onExit = () => {
|
|
307
|
+
try {
|
|
308
|
+
fc.kill('SIGKILL')
|
|
309
|
+
} catch {}
|
|
310
|
+
}
|
|
311
|
+
process.once('exit', onExit)
|
|
312
|
+
const listeners: Server[] = []
|
|
313
|
+
const kill = () => {
|
|
314
|
+
process.off('exit', onExit)
|
|
315
|
+
try {
|
|
316
|
+
fc.kill('SIGKILL')
|
|
317
|
+
} catch {}
|
|
318
|
+
plan.release()
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
for (let i = 0; i < 2500 && !existsSync(apiSock); i++) await wait(2)
|
|
322
|
+
await api(apiSock, 'PUT', '/machine-config', {
|
|
323
|
+
vcpu_count: cfg.vcpus,
|
|
324
|
+
mem_size_mib: cfg.memoryMiB,
|
|
325
|
+
smt: false,
|
|
326
|
+
})
|
|
327
|
+
await api(apiSock, 'PUT', '/boot-source', {
|
|
328
|
+
kernel_image_path: cfg.kernel,
|
|
329
|
+
// i8042 flags skip a 500 ms keyboard probe; measured 1.2 s -> 0.3 s boot.
|
|
330
|
+
boot_args:
|
|
331
|
+
'console=ttyS0 reboot=k panic=1 pci=off init=/init-mirab.sh i8042.noaux i8042.nokbd i8042.nopnp i8042.dumbkbd quiet loglevel=1' +
|
|
332
|
+
// The host's time, as vz passes it: TLS needs a right clock from the start.
|
|
333
|
+
` mirab.time=${Math.floor(Date.now() / 1000)}` +
|
|
334
|
+
(plan.net ? ' ' + bootNetArgs(plan.net) : ''),
|
|
335
|
+
})
|
|
336
|
+
await api(apiSock, 'PUT', '/drives/rootfs', {
|
|
337
|
+
drive_id: 'rootfs',
|
|
338
|
+
path_on_host: root,
|
|
339
|
+
is_root_device: true,
|
|
340
|
+
is_read_only: false,
|
|
341
|
+
})
|
|
342
|
+
if (work !== null)
|
|
343
|
+
await api(apiSock, 'PUT', '/drives/work', {
|
|
344
|
+
drive_id: 'work',
|
|
345
|
+
path_on_host: image,
|
|
346
|
+
is_root_device: false,
|
|
347
|
+
is_read_only: false,
|
|
348
|
+
})
|
|
349
|
+
await api(apiSock, 'PUT', '/vsock', {
|
|
350
|
+
vsock_id: 'vsock0',
|
|
351
|
+
guest_cid: 3,
|
|
352
|
+
uds_path: uds,
|
|
353
|
+
})
|
|
354
|
+
if (plan.net)
|
|
355
|
+
await api(apiSock, 'PUT', '/network-interfaces/eth0', {
|
|
356
|
+
iface_id: 'eth0',
|
|
357
|
+
guest_mac: plan.net.mac,
|
|
358
|
+
host_dev_name: plan.net.tap,
|
|
359
|
+
})
|
|
360
|
+
const t1 = Date.now()
|
|
361
|
+
await api(apiSock, 'PUT', '/actions', { action_type: 'InstanceStart' })
|
|
362
|
+
const open: Opener = () => vsockChannel(uds, cfg.port)
|
|
363
|
+
for (;;) {
|
|
364
|
+
try {
|
|
365
|
+
checkGuestProtocol(await guestPing(open), cfg.rootfs)
|
|
366
|
+
break
|
|
367
|
+
} catch (e) {
|
|
368
|
+
if (e instanceof GuestProtocolError) throw e
|
|
369
|
+
if (Date.now() - t1 > 20_000)
|
|
370
|
+
throw new Error(
|
|
371
|
+
'microVM agent did not come up: ' + (e as Error).message,
|
|
372
|
+
)
|
|
373
|
+
if (fc.exitCode !== null)
|
|
374
|
+
throw new Error(
|
|
375
|
+
'firecracker exited: ' +
|
|
376
|
+
(await readFile(join(vmDir, 'serial.log'), 'utf8')).slice(-400),
|
|
377
|
+
)
|
|
378
|
+
await wait(5)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
timings.bootToAgentMs = Date.now() - t1
|
|
382
|
+
log(
|
|
383
|
+
`microVM ready in ${timings.bootToAgentMs} ms (image ${timings.imageMs} ms)`,
|
|
384
|
+
)
|
|
385
|
+
return {
|
|
386
|
+
level: 'vm',
|
|
387
|
+
backend: 'firecracker',
|
|
388
|
+
label: 'vm (firecracker)',
|
|
389
|
+
timings,
|
|
390
|
+
pid: fc.pid!,
|
|
391
|
+
...vsockMembers(open, uds, work, listeners),
|
|
392
|
+
close: async (options) => {
|
|
393
|
+
for (const l of listeners) l.close()
|
|
394
|
+
const exited =
|
|
395
|
+
fc.exitCode !== null || fc.signalCode !== null
|
|
396
|
+
? Promise.resolve()
|
|
397
|
+
: new Promise<void>((r) => fc.once('exit', () => r()))
|
|
398
|
+
kill()
|
|
399
|
+
// Its disk is only taken once Firecracker is gone.
|
|
400
|
+
await Promise.race([exited, wait(5000)])
|
|
401
|
+
await serial.close()
|
|
402
|
+
await rm(sockDir, { recursive: true, force: true })
|
|
403
|
+
if (options?.keepRoot) await rename(root, options.keepRoot)
|
|
404
|
+
if (!options?.preserveWorkspace)
|
|
405
|
+
await rm(vmDir, { recursive: true, force: true })
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
} catch (e) {
|
|
409
|
+
kill()
|
|
410
|
+
await serial.close().catch(() => {})
|
|
411
|
+
await rm(sockDir, { recursive: true, force: true })
|
|
412
|
+
throw e
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "protocol": 2 }
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// `host` level: steps run on this machine as a dedicated, unprivileged OS user
|
|
2
|
+
// (the old mirab's `sandbox = "host"`, Layer 1). The owner creates the user once
|
|
3
|
+
// with scripts/host-user-setup.sh (needs root); mirab never runs sudo for setup,
|
|
4
|
+
// it only uses the one sudo rule that script installs: run the guest agent as
|
|
5
|
+
// that user. Same exec contract as a VM or container (`--stdio`), so runner
|
|
6
|
+
// code does not change. What it isolates: the owner's files (home, other
|
|
7
|
+
// checkouts) and, through the per-user firewall rule, the LAN. What it does
|
|
8
|
+
// not: the kernel, other world-readable files, CPU and memory.
|
|
9
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
10
|
+
import { randomBytes } from 'node:crypto'
|
|
11
|
+
import {
|
|
12
|
+
chmod,
|
|
13
|
+
cp,
|
|
14
|
+
lchown,
|
|
15
|
+
lstat,
|
|
16
|
+
readdir,
|
|
17
|
+
rename,
|
|
18
|
+
rm,
|
|
19
|
+
symlink,
|
|
20
|
+
unlink,
|
|
21
|
+
writeFile,
|
|
22
|
+
} from 'node:fs/promises'
|
|
23
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
24
|
+
import { userInfo } from 'node:os'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import {
|
|
27
|
+
guestExec,
|
|
28
|
+
guestPing,
|
|
29
|
+
guestPty,
|
|
30
|
+
type Channel,
|
|
31
|
+
type Isolation,
|
|
32
|
+
type Opener,
|
|
33
|
+
} from './session'
|
|
34
|
+
|
|
35
|
+
export const MARKER_DIR = '/etc/mirab/host-users'
|
|
36
|
+
|
|
37
|
+
/** What scripts/host-user-setup.sh wrote to /etc/mirab/host-users/<user>.json. */
|
|
38
|
+
export interface Marker {
|
|
39
|
+
version: 1
|
|
40
|
+
user: string
|
|
41
|
+
uid: number
|
|
42
|
+
gid: number
|
|
43
|
+
owner: string
|
|
44
|
+
agent: string
|
|
45
|
+
workRoot: string
|
|
46
|
+
network?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Everything detection looks at, so tests can describe any machine. */
|
|
50
|
+
export interface HostProbe {
|
|
51
|
+
marker(user: string): string | undefined
|
|
52
|
+
uidOf(user: string): number | undefined
|
|
53
|
+
me: { uid: number; name: string; groups: number[] }
|
|
54
|
+
stat(
|
|
55
|
+
path: string,
|
|
56
|
+
): { uid: number; gid: number; mode: number; dir: boolean } | undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const systemProbe = (markerDir = MARKER_DIR): HostProbe => ({
|
|
60
|
+
marker: (user) => {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(join(markerDir, `${user}.json`), 'utf8')
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
uidOf: (user) => {
|
|
68
|
+
try {
|
|
69
|
+
return Number(
|
|
70
|
+
execFileSync('id', ['-u', user], {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
73
|
+
}).trim(),
|
|
74
|
+
)
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
me: {
|
|
80
|
+
uid: process.getuid?.() ?? -1,
|
|
81
|
+
name: userInfo().username,
|
|
82
|
+
groups: process.getgroups?.() ?? [],
|
|
83
|
+
},
|
|
84
|
+
stat: (path) => {
|
|
85
|
+
try {
|
|
86
|
+
const s = statSync(path)
|
|
87
|
+
return { uid: s.uid, gid: s.gid, mode: s.mode, dir: s.isDirectory() }
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export type HostUserState =
|
|
95
|
+
{ ok: true; marker: Marker } | { ok: false; reason: string; fix: string }
|
|
96
|
+
|
|
97
|
+
export const setupCommand = (user: string, owner: string) =>
|
|
98
|
+
`sudo scripts/host-user-setup.sh ${user} ${owner} --agent <mirab-guest-agent for this OS>`
|
|
99
|
+
|
|
100
|
+
/** Is the host tier for `user` set up, and set up safely? Never runs sudo. */
|
|
101
|
+
export function hostUserState(user: string, probe: HostProbe): HostUserState {
|
|
102
|
+
const fix = `Needs Sami: ${setupCommand(user, probe.me.name)}`
|
|
103
|
+
const no = (reason: string): HostUserState => ({ ok: false, reason, fix })
|
|
104
|
+
const raw = probe.marker(user)
|
|
105
|
+
if (!raw) return no(`${user} is not set up (no ${MARKER_DIR}/${user}.json)`)
|
|
106
|
+
let m: Marker
|
|
107
|
+
try {
|
|
108
|
+
m = JSON.parse(raw)
|
|
109
|
+
} catch {
|
|
110
|
+
return no(`${MARKER_DIR}/${user}.json is not valid JSON`)
|
|
111
|
+
}
|
|
112
|
+
if (m.version !== 1 || m.user !== user)
|
|
113
|
+
return no(`${MARKER_DIR}/${user}.json does not describe ${user}`)
|
|
114
|
+
const uid = probe.uidOf(user)
|
|
115
|
+
if (uid === undefined) return no(`OS user ${user} does not exist`)
|
|
116
|
+
if (uid !== m.uid)
|
|
117
|
+
return no(`${user} has uid ${uid}, setup recorded ${m.uid}`)
|
|
118
|
+
if (uid === 0) return no(`${user} is root`)
|
|
119
|
+
if (uid === probe.me.uid)
|
|
120
|
+
return no(`${user} is the runner's own user; that is not isolation`)
|
|
121
|
+
if (m.owner !== probe.me.name)
|
|
122
|
+
return no(`${user} was set up for ${m.owner}, not ${probe.me.name}`)
|
|
123
|
+
if (!probe.me.groups.includes(m.gid))
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: `${probe.me.name} is not in group ${user} yet`,
|
|
127
|
+
fix: 'log out and in again (group membership is read at login)',
|
|
128
|
+
}
|
|
129
|
+
const agent = probe.stat(m.agent)
|
|
130
|
+
if (!agent || agent.dir) return no(`agent ${m.agent} is missing`)
|
|
131
|
+
if (agent.uid !== 0 || agent.mode & 0o022)
|
|
132
|
+
return no(
|
|
133
|
+
`agent ${m.agent} must be root-owned and not group/other-writable`,
|
|
134
|
+
)
|
|
135
|
+
const work = probe.stat(m.workRoot)
|
|
136
|
+
if (!work?.dir) return no(`work root ${m.workRoot} is missing`)
|
|
137
|
+
if (work.gid !== m.gid || (work.mode & 0o070) !== 0o070 || work.mode & 0o007)
|
|
138
|
+
return no(`work root ${m.workRoot} must be group ${user}, mode 2770`)
|
|
139
|
+
return { ok: true, marker: m }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Give the shared group read/write on everything (the dedicated user is in it, not the owner's other files). */
|
|
143
|
+
async function shareWithGroup(path: string, gid: number) {
|
|
144
|
+
const st = await lstat(path)
|
|
145
|
+
// Files the dedicated user made are already shared (setgid dirs, umask 007).
|
|
146
|
+
if (st.isSymbolicLink() || st.uid !== process.getuid?.()) return
|
|
147
|
+
if (st.gid !== gid) await lchown(path, -1, gid)
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
await chmod(path, (st.mode & 0o7777) | 0o2070)
|
|
150
|
+
for (const name of await readdir(path))
|
|
151
|
+
await shareWithGroup(join(path, name), gid)
|
|
152
|
+
} else await chmod(path, (st.mode & 0o777) | 0o060 | ((st.mode & 0o100) >> 3))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function move(from: string, to: string) {
|
|
156
|
+
try {
|
|
157
|
+
await rename(from, to)
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if ((e as NodeJS.ErrnoException).code !== 'EXDEV') throw e
|
|
160
|
+
await cp(from, to, { recursive: true, verbatimSymlinks: true })
|
|
161
|
+
await rm(from, { recursive: true, force: true })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function stdioChannel(argv: string[]): Channel {
|
|
166
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
167
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
168
|
+
})
|
|
169
|
+
let closeCb: (e?: Error) => void = () => {},
|
|
170
|
+
stderr = ''
|
|
171
|
+
child.stderr!.on('data', (d) => (stderr = (stderr + d).slice(-2000)))
|
|
172
|
+
child.on('close', (code) =>
|
|
173
|
+
closeCb(
|
|
174
|
+
code && stderr
|
|
175
|
+
? new Error(`${argv[0]} exited ${code}: ${stderr.trim()}`)
|
|
176
|
+
: undefined,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
child.on('error', (e) => closeCb(e))
|
|
180
|
+
return {
|
|
181
|
+
write: (d) => void child.stdin!.write(d),
|
|
182
|
+
onData: (cb) => child.stdout!.on('data', cb),
|
|
183
|
+
onClose: (cb) => (closeCb = cb),
|
|
184
|
+
close: () => {
|
|
185
|
+
child.stdin!.end()
|
|
186
|
+
setTimeout(() => child.kill('SIGKILL'), 2000).unref()
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Stage the job directory under the shared work root, then run the agent as
|
|
193
|
+
* the dedicated user. `runAs` is the privilege switch: the sudo rule from the
|
|
194
|
+
* setup script; tests pass [] to exercise the same path as themselves.
|
|
195
|
+
*/
|
|
196
|
+
export async function startHostUser(
|
|
197
|
+
marker: Marker,
|
|
198
|
+
work: string,
|
|
199
|
+
log: (line: string) => void = () => {},
|
|
200
|
+
runAs: string[] = ['sudo', '-n', '-u', marker.user, '--'],
|
|
201
|
+
): Promise<Isolation> {
|
|
202
|
+
const staged = join(marker.workRoot, `job-${randomBytes(6).toString('hex')}`)
|
|
203
|
+
await move(work, staged)
|
|
204
|
+
// The runner keeps using its own path; the dedicated user sees the staged one.
|
|
205
|
+
await symlink(staged, work)
|
|
206
|
+
const restore = async () => {
|
|
207
|
+
await unlink(work).catch(() => {})
|
|
208
|
+
await move(staged, work)
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await shareWithGroup(staged, marker.gid)
|
|
212
|
+
const open: Opener = async () =>
|
|
213
|
+
stdioChannel([...runAs, marker.agent, '--stdio', '--shared'])
|
|
214
|
+
try {
|
|
215
|
+
await guestPing(open)
|
|
216
|
+
} catch (e) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`cannot run the agent as ${marker.user}: ${(e as Error).message}. Needs Sami: ${setupCommand(marker.user, marker.owner)}`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
log(`steps run as OS user ${marker.user} in ${staged}`)
|
|
222
|
+
const guest = (p: string) =>
|
|
223
|
+
p === work
|
|
224
|
+
? staged
|
|
225
|
+
: p.startsWith(work + '/')
|
|
226
|
+
? staged + p.slice(work.length)
|
|
227
|
+
: p
|
|
228
|
+
return {
|
|
229
|
+
level: 'host',
|
|
230
|
+
backend: 'os-user',
|
|
231
|
+
label: `host (OS user ${marker.user})`,
|
|
232
|
+
guestPath: guest,
|
|
233
|
+
// The runner's own files (step scripts) are written by the runner and
|
|
234
|
+
// shared with the group, not by the dedicated user into a runner-owned file.
|
|
235
|
+
put: async (p, content, mode = 0o600) => {
|
|
236
|
+
await writeFile(guest(p), content, { mode })
|
|
237
|
+
await shareWithGroup(guest(p), marker.gid)
|
|
238
|
+
},
|
|
239
|
+
exec: (argv, o) => guestExec(open, argv, o),
|
|
240
|
+
pty: (request) => guestPty(open, request),
|
|
241
|
+
close: restore,
|
|
242
|
+
}
|
|
243
|
+
} catch (e) {
|
|
244
|
+
await restore()
|
|
245
|
+
throw e
|
|
246
|
+
}
|
|
247
|
+
}
|