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.
Files changed (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -2
  3. package/package.json +58 -4
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. 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
+ }