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,312 @@
1
+ // macOS backend: one Virtualization.framework microVM per job or agent,
2
+ // launched by the `mirab-vz` helper (vz/mirab-vz.swift, ad-hoc signed with the
3
+ // virtualization entitlement by scripts/build-vz-helper.sh).
4
+ //
5
+ // Same guest contract as Firecracker: the helper speaks Firecracker's vsock
6
+ // unix-socket protocol, so exec, put, pty, file listing and the hook relay are
7
+ // the shared vsockMembers. What differs:
8
+ // - the root disk is an APFS clone of the base image (copy-on-write, per VM);
9
+ // - the work directory is an APFS clone too, shared into the guest with
10
+ // virtio-fs at /workspace, so the host checkout is never written and no
11
+ // mke2fs is needed on the Mac;
12
+ // - networking is VZ NAT or nothing (see planVzNetwork).
13
+ import { execFileSync, spawn } from 'node:child_process'
14
+ import {
15
+ accessSync,
16
+ chmodSync,
17
+ constants,
18
+ readFileSync,
19
+ statSync,
20
+ } from 'node:fs'
21
+ import { mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises'
22
+ import type { Server } from 'node:net'
23
+ import { tmpdir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import {
26
+ growDisk,
27
+ vsockChannel,
28
+ vsockMembers,
29
+ type BootTimings,
30
+ type VmStartOptions,
31
+ } from './firecracker'
32
+ import {
33
+ checkGuestProtocol,
34
+ guestPing,
35
+ GuestProtocolError,
36
+ type Isolation,
37
+ type Opener,
38
+ } from './session'
39
+
40
+ export interface VzConfig {
41
+ /** The signed mirab-vz helper. */
42
+ helper: string
43
+ /** Guest kernel with virtio-pci built in: the uncompressed arm64 `Image` on
44
+ * Apple silicon, a relocatable x86_64 `bzImage` on Intel Macs. */
45
+ kernel: string
46
+ /** Base ext4 image; each VM boots an APFS clone of it. */
47
+ rootfs: string
48
+ memoryMiB: number
49
+ vcpus: number
50
+ /** Guest vsock port the agent listens on. */
51
+ port: number
52
+ egress: 'auto' | 'block' | 'allow'
53
+ }
54
+
55
+ /** Guest -> host vsock ports the helper relays: the spawn --vm hook relay
56
+ * (9100) and the way out through the station (9101). */
57
+ export const GUEST_TO_HOST_PORTS = [9100, 9101]
58
+ /** The guest's loopback port for the way out, and the host vsock port it reaches. */
59
+ export const EGRESS_GUEST_PORT = 8790
60
+ export const EGRESS_HOST_PORT = 9101
61
+
62
+ export const VZ_NETWORK_MARKER = '/etc/mirab/vz-network.json'
63
+ export const VZ_SETUP_COMMAND = 'sudo ~/.mirab/bin/mirab vm network setup'
64
+ /** What the marker says once the pf anchor denies guests the LAN. */
65
+ export const VZ_LAN_DENIED = 'lan-denied'
66
+
67
+ /** This Mac can run Virtualization.framework guests (hosted CI Macs cannot). */
68
+ export function vzHostReady() {
69
+ try {
70
+ return (
71
+ execFileSync('/usr/sbin/sysctl', ['-n', 'kern.hv_support'], {
72
+ encoding: 'utf8',
73
+ }).trim() === '1'
74
+ )
75
+ } catch {
76
+ return false
77
+ }
78
+ }
79
+
80
+ export function vzAvailable(
81
+ c: Pick<VzConfig, 'helper' | 'kernel' | 'rootfs'>,
82
+ platform: NodeJS.Platform = process.platform,
83
+ ) {
84
+ if (platform !== 'darwin') return false
85
+ try {
86
+ accessSync(c.kernel, constants.R_OK)
87
+ accessSync(c.rootfs, constants.R_OK)
88
+ execFileSync(c.helper, ['check'], { stdio: 'ignore' })
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ export interface VzNetworkState {
96
+ /** The owner installed the pf anchor that denies VZ NAT guests the LAN. */
97
+ filtered: boolean
98
+ reason?: string
99
+ }
100
+
101
+ /** Reads the root-owned marker `mirab vm network setup` writes; never runs sudo or pfctl. */
102
+ export function vzNetworkState(marker = VZ_NETWORK_MARKER): VzNetworkState {
103
+ try {
104
+ const st = statSync(marker)
105
+ if (st.uid !== 0 || st.mode & 0o022)
106
+ return {
107
+ filtered: false,
108
+ reason: `${marker} is not root-owned and read-only`,
109
+ }
110
+ const m = JSON.parse(readFileSync(marker, 'utf8'))
111
+ return m?.network === VZ_LAN_DENIED
112
+ ? { filtered: true }
113
+ : { filtered: false, reason: `${marker} does not say ${VZ_LAN_DENIED}` }
114
+ } catch {
115
+ return { filtered: false, reason: 'the vz pf anchor is not installed' }
116
+ }
117
+ }
118
+
119
+ export interface VzNetworkPlan {
120
+ net: 'none' | 'nat'
121
+ note: string
122
+ }
123
+
124
+ /**
125
+ * VZ NAT (vmnet shared mode) gives the guest the internet AND the Mac's LAN,
126
+ * the Mac itself and the tailnet. Only a root pf anchor can deny those, so NAT
127
+ * is used only when that anchor is installed; otherwise the VM has no network.
128
+ */
129
+ export function planVzNetwork(
130
+ egress: VzConfig['egress'],
131
+ state: VzNetworkState,
132
+ ): VzNetworkPlan {
133
+ if (egress === 'block')
134
+ return { net: 'none', note: 'vm network: none (egress: block)' }
135
+ if (state.filtered)
136
+ return {
137
+ net: 'nat',
138
+ note: 'vm network: VZ NAT, egress allowed, LAN/host/metadata denied by the mirab-vz pf anchor',
139
+ }
140
+ if (egress === 'allow')
141
+ throw new Error(
142
+ `egress: allow needs the vz network filter (${state.reason}). Needs Sami: ${VZ_SETUP_COMMAND}`,
143
+ )
144
+ return {
145
+ net: 'none',
146
+ note: `vm network: none (${state.reason}; VZ NAT would reach the LAN; for filtered egress: ${VZ_SETUP_COMMAND})`,
147
+ }
148
+ }
149
+
150
+ export function vzNetworkStatusLine(
151
+ egress: VzConfig['egress'],
152
+ state: VzNetworkState,
153
+ ) {
154
+ try {
155
+ return planVzNetwork(egress, state).note
156
+ } catch (e) {
157
+ return `vm network: refused (${(e as Error).message})`
158
+ }
159
+ }
160
+
161
+ export function kernelCommandLine(net: VzNetworkPlan['net'], now: Date) {
162
+ return (
163
+ 'console=hvc0 root=/dev/vda rw rootfstype=ext4 init=/init-mirab.sh quiet loglevel=1' +
164
+ ` mirab.time=${Math.floor(now.getTime() / 1000)}` +
165
+ (net === 'nat' ? ' mirab.net=dhcp' : '')
166
+ )
167
+ }
168
+
169
+ /** APFS clonefile (copy-on-write); a plain copy when the clone is refused (another volume). */
170
+ function cloneTree(helper: string, src: string, dst: string) {
171
+ try {
172
+ execFileSync(helper, ['clone', src, dst], { stdio: 'pipe' })
173
+ } catch {
174
+ execFileSync('cp', ['-Rp', src, dst], { stdio: 'ignore' })
175
+ }
176
+ }
177
+
178
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
179
+
180
+ /** The helper's `run` arguments; `share` null = no virtio-fs device at all. */
181
+ export function vzRunArgs(o: {
182
+ cfg: Pick<VzConfig, 'kernel' | 'vcpus' | 'memoryMiB'>
183
+ root: string
184
+ uds: string
185
+ vmDir: string
186
+ share: string | null
187
+ net: VzNetworkPlan['net']
188
+ listen: number[]
189
+ now?: Date
190
+ }): string[] {
191
+ return [
192
+ 'run',
193
+ '--kernel',
194
+ o.cfg.kernel,
195
+ '--rootfs',
196
+ o.root,
197
+ '--cmdline',
198
+ kernelCommandLine(o.net, o.now ?? new Date()),
199
+ '--cpus',
200
+ String(o.cfg.vcpus),
201
+ '--memory',
202
+ String(o.cfg.memoryMiB),
203
+ '--vsock',
204
+ o.uds,
205
+ '--serial',
206
+ join(o.vmDir, 'serial.log'),
207
+ ...(o.share === null ? [] : ['--share', `workspace=${o.share}`]),
208
+ '--net',
209
+ o.net,
210
+ ...o.listen.flatMap((p) => ['--listen', String(p)]),
211
+ ]
212
+ }
213
+
214
+ export async function startVz(
215
+ cfg: VzConfig,
216
+ work: string | null,
217
+ vmDir: string,
218
+ log: (line: string) => void = () => {},
219
+ network: VzNetworkState = vzNetworkState(),
220
+ opts: VmStartOptions = {},
221
+ ): Promise<Isolation & { timings: BootTimings; pid: number }> {
222
+ const plan = planVzNetwork(cfg.egress, network)
223
+ log(plan.note)
224
+ await mkdir(vmDir, { recursive: true, mode: 0o700 })
225
+ // Unix socket paths are limited to 104 bytes on macOS, so they live in a short dir.
226
+ const sockDir = await mkdtemp(join(tmpdir(), 'gv-'))
227
+ const uds = join(sockDir, 'v'),
228
+ root = join(vmDir, 'rootfs.img'),
229
+ share = join(vmDir, 'work')
230
+ const t0 = Date.now()
231
+ let helper: ReturnType<typeof spawn> | undefined
232
+ const listeners: Server[] = []
233
+ const stop = async (options?: {
234
+ preserveWorkspace?: boolean
235
+ keepRoot?: string
236
+ }) => {
237
+ for (const l of listeners) l.close()
238
+ if (helper && helper.exitCode === null && helper.signalCode === null) {
239
+ const exited = new Promise((r) => helper!.once('exit', r))
240
+ helper.stdin?.end() // the helper stops the VM when its stdin closes
241
+ helper.kill('SIGTERM')
242
+ if (!(await Promise.race([exited.then(() => true), wait(4000)])))
243
+ helper.kill('SIGKILL')
244
+ }
245
+ await rm(sockDir, { recursive: true, force: true })
246
+ if (options?.keepRoot) await rename(root, options.keepRoot)
247
+ if (!options?.preserveWorkspace)
248
+ await rm(vmDir, { recursive: true, force: true })
249
+ }
250
+ try {
251
+ cloneTree(cfg.helper, cfg.rootfs, root)
252
+ chmodSync(root, 0o600) // the base is usually read-only; its clone is this VM's disk
253
+ if (opts.rootGiB) await growDisk(root, opts.rootGiB)
254
+ // No work directory (a CI runner): no share, so nothing of the host's is inside.
255
+ if (work !== null) cloneTree(cfg.helper, work, share)
256
+ const timings: BootTimings = { imageMs: Date.now() - t0, bootToAgentMs: 0 }
257
+ const args = vzRunArgs({
258
+ cfg,
259
+ root,
260
+ uds,
261
+ vmDir,
262
+ share: work === null ? null : share,
263
+ net: plan.net,
264
+ listen: opts.listen ?? GUEST_TO_HOST_PORTS,
265
+ })
266
+ const t1 = Date.now()
267
+ const h = spawn(cfg.helper, args, { stdio: ['pipe', 'pipe', 'pipe'] })
268
+ helper = h
269
+ let stderr = ''
270
+ h.stderr!.on('data', (c) => (stderr += c))
271
+ h.stdout!.on('data', () => {})
272
+ const open: Opener = () => vsockChannel(uds, cfg.port)
273
+ for (;;) {
274
+ try {
275
+ checkGuestProtocol(await guestPing(open), cfg.rootfs)
276
+ break
277
+ } catch (e) {
278
+ if (e instanceof GuestProtocolError) throw e
279
+ if (h.exitCode !== null || h.signalCode !== null) {
280
+ const serial = await readFile(
281
+ join(vmDir, 'serial.log'),
282
+ 'utf8',
283
+ ).catch(() => '')
284
+ throw new Error(
285
+ `mirab-vz exited: ${stderr.trim()} ${serial.slice(-400)}`.trim(),
286
+ )
287
+ }
288
+ if (Date.now() - t1 > 20_000)
289
+ throw new Error(
290
+ 'microVM agent did not come up: ' + (e as Error).message,
291
+ )
292
+ await wait(5)
293
+ }
294
+ }
295
+ timings.bootToAgentMs = Date.now() - t1
296
+ log(
297
+ `microVM ready in ${timings.bootToAgentMs} ms (clone ${timings.imageMs} ms)`,
298
+ )
299
+ return {
300
+ level: 'vm',
301
+ backend: 'vz',
302
+ label: 'vm (vz)',
303
+ timings,
304
+ pid: h.pid!,
305
+ ...vsockMembers(open, uds, work, listeners),
306
+ close: stop,
307
+ }
308
+ } catch (e) {
309
+ await stop()
310
+ throw e
311
+ }
312
+ }