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,329 @@
1
+ // Working-directory sync for `mirab spawn --vm`. Stock Firecracker has no
2
+ // virtio-fs, so the guest works on a copy of the directory (an ext4 drive) and
3
+ // its changes come back to the host at exit and on demand (`mirab sync <id>`).
4
+ //
5
+ // The rule, per path, three-way against the baseline (the tree as it was copied
6
+ // into the guest, or as of the last sync):
7
+ // - the guest changed it and the host still matches the baseline: applied;
8
+ // - the host changed it too: the host copy is kept and the guest's version is
9
+ // saved under `conflictDir`, never merged and never silently dropped;
10
+ // - only the host changed it: untouched.
11
+ // The guest is untrusted: its paths are validated, nothing is written through a
12
+ // symlink or outside the root, and file bytes must match the hash it listed.
13
+ import { createHash } from 'node:crypto'
14
+ import { AnchoredDirectory } from './anchored-fs'
15
+ import { readdir } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+ import { mkdirSync } from 'node:fs'
18
+ import type { Entry, GuestFiles } from './session'
19
+
20
+ export type Manifest = Map<string, Entry>
21
+ export type RecoveryLocation = { directory: string; note: string }
22
+
23
+ export interface SyncResult {
24
+ written: string[]
25
+ deleted: string[]
26
+ conflicts: { path: string; reason: string; saved?: string }[]
27
+ /** What the agent changed about itself (its definition, `.env`): saved
28
+ * aside, never applied, for the person to review. */
29
+ held: { path: string; reason: string; saved?: string }[]
30
+ rejected: { path: string; reason: string }[]
31
+ /** Final VM sync only: private retained workspace and recovery note. */
32
+ recovery?: RecoveryLocation
33
+ }
34
+
35
+ /** The host side of the guest agent's `list` op: same walk, same fields. */
36
+ export async function hostManifest(root: string): Promise<Manifest> {
37
+ const out: Manifest = new Map(),
38
+ anchored = AnchoredDirectory.open(root)
39
+ const walk = async (
40
+ directory: AnchoredDirectory,
41
+ path: string,
42
+ prefix: string,
43
+ ) => {
44
+ for (const name of (await readdir(path)).sort()) {
45
+ const rel = prefix ? `${prefix}/${name}` : name
46
+ const e = directory.entry(name, rel, true)
47
+ if (!e) continue
48
+ out.set(rel, e)
49
+ if (e.k === 'd') {
50
+ const child = directory.directory(name)
51
+ try {
52
+ await walk(child, join(path, name), rel)
53
+ } finally {
54
+ child.close()
55
+ }
56
+ }
57
+ }
58
+ }
59
+ try {
60
+ await walk(anchored, root, '')
61
+ } finally {
62
+ anchored.close()
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** Equal for sync purposes: kind, content or link target, and permission bits. */
68
+ export function sameEntry(a?: Entry, b?: Entry) {
69
+ if (!a || !b) return a === b
70
+ if (a.k !== b.k) return false
71
+ if (a.k === 'l') return a.t === b.t
72
+ if ((a.m & 0o777) !== (b.m & 0o777)) return false
73
+ return a.k === 'd' || a.h === b.h
74
+ }
75
+
76
+ export function validPath(p: unknown): p is string {
77
+ return (
78
+ typeof p === 'string' &&
79
+ p.length > 0 &&
80
+ p.length < 4096 &&
81
+ !p.startsWith('/') &&
82
+ !p.includes('\0') &&
83
+ p.split('/').every((c) => c !== '' && c !== '.' && c !== '..')
84
+ )
85
+ }
86
+
87
+ function validEntry(e: Entry) {
88
+ if (!validPath(e.p) || !Number.isInteger(e.m) || e.m < 0 || e.m > 0o7777)
89
+ return false
90
+ if (e.k === 'f')
91
+ return (
92
+ Number.isSafeInteger(e.s) &&
93
+ e.s! >= 0 &&
94
+ typeof e.h === 'string' &&
95
+ /^[0-9a-f]{64}$/.test(e.h)
96
+ )
97
+ if (e.k === 'l')
98
+ return typeof e.t === 'string' && e.t.length > 0 && !e.t.includes('\0')
99
+ return e.k === 'd'
100
+ }
101
+
102
+ /** What an agent is, and what it may reach: its definition (`agent/`: the
103
+ * manifest's domains, packages and tools, its instructions) and the
104
+ * project's `.env` (which secrets it names). A VM's change to these never
105
+ * lands on its own: it would be the agent widening itself for its next
106
+ * session. Held aside for the person instead. */
107
+ export function selfDefinition(path: string) {
108
+ // Case-folded: on a Mac's disk Agent/ and .ENV are agent/ and .env. Every
109
+ // .env file, since tools read .env.local and the like too.
110
+ const first = path.split('/')[0].normalize('NFC').toLowerCase()
111
+ return first === 'agent' || first.startsWith('.env')
112
+ }
113
+
114
+ /** Guest Git state is never adopted as trusted host repository metadata. */
115
+ export function protectedMetadata(path: string) {
116
+ return path.split('/').some((part) => part.toLowerCase() === '.git')
117
+ }
118
+
119
+ export interface SyncOptions {
120
+ /** Host directory the guest's copy came from. */
121
+ root: string
122
+ /** Where the guest sees it. */
123
+ guestRoot: string
124
+ files: GuestFiles
125
+ /** Updated only for applied/resolved changes; conflicts stay pending. */
126
+ baseline: Manifest
127
+ /** Conflicting bytes and adjacent metadata are saved outside the root. */
128
+ conflictDir: string
129
+ /** Refuse a sync that would copy more than this many bytes. */
130
+ maxBytes?: number
131
+ }
132
+
133
+ export async function syncBack(o: SyncOptions): Promise<SyncResult> {
134
+ const result: SyncResult = {
135
+ written: [],
136
+ deleted: [],
137
+ conflicts: [],
138
+ held: [],
139
+ rejected: [],
140
+ }
141
+ const guest: Manifest = new Map()
142
+ for (const e of await o.files.list(o.guestRoot)) {
143
+ // mke2fs puts lost+found at the root of the copy; it was never the user's.
144
+ if (
145
+ !o.baseline.has('lost+found') &&
146
+ (e.p === 'lost+found' || e.p?.startsWith?.('lost+found/'))
147
+ )
148
+ continue
149
+ if (!validEntry(e)) {
150
+ result.rejected.push({
151
+ path: String(e.p),
152
+ reason: 'invalid entry from the guest',
153
+ })
154
+ continue
155
+ }
156
+ guest.set(e.p, e)
157
+ }
158
+ const changed = [...new Set([...o.baseline.keys(), ...guest.keys()])].filter(
159
+ (p) => {
160
+ if (sameEntry(o.baseline.get(p), guest.get(p))) return false
161
+ if (!validPath(p) || protectedMetadata(p)) {
162
+ result.rejected.push({
163
+ path: p,
164
+ reason:
165
+ 'host Git metadata is protected; retained in the guest workspace',
166
+ })
167
+ return false
168
+ }
169
+ return true
170
+ },
171
+ )
172
+ const bytes = changed.reduce((n, p) => {
173
+ const e = guest.get(p)
174
+ return n + (e?.k === 'f' ? (e.s ?? 0) : 0)
175
+ }, 0)
176
+ if (!Number.isSafeInteger(bytes) || bytes > (o.maxBytes ?? 2 * 1024 ** 3))
177
+ throw new Error(`sync would copy ${bytes} bytes; refusing`)
178
+ // Deletions deepest first, then creations and updates parents first.
179
+ const deletions = changed
180
+ .filter((p) => !guest.has(p))
181
+ .sort()
182
+ .reverse()
183
+ const updates = changed.filter((p) => guest.has(p)).sort()
184
+
185
+ const root = AnchoredDirectory.open(o.root)
186
+ const fetch = async (rel: string, after: Entry) => {
187
+ const data = await o.files.read(`${o.guestRoot}/${rel}`)
188
+ if (
189
+ data.length !== after.s ||
190
+ createHash('sha256').update(data).digest('hex') !== after.h
191
+ )
192
+ throw new Error('guest file changed while syncing')
193
+ return data
194
+ }
195
+ const conflict = (
196
+ rel: string,
197
+ reason: string,
198
+ after?: Entry,
199
+ data?: Buffer,
200
+ into: SyncResult['conflicts'] = result.conflicts,
201
+ ) => {
202
+ let saved: string | undefined
203
+ if (after?.k === 'f' && data) {
204
+ // The private recovery root is host-owned. Child paths use the same
205
+ // anchored primitives as normal sync, never a guest-provided pathname.
206
+ mkdirSync(o.conflictDir, { recursive: true, mode: 0o700 })
207
+ const recovery = AnchoredDirectory.open(o.conflictDir)
208
+ let parent: AnchoredDirectory | undefined
209
+ try {
210
+ parent = recovery.parent(rel, true)
211
+ parent.put(rel.split('/').at(-1)!, { ...after, m: 0o600 }, data)
212
+ saved = join(o.conflictDir, rel)
213
+ } finally {
214
+ if (parent && parent !== recovery) parent.close()
215
+ recovery.close()
216
+ }
217
+ }
218
+ // Never materialize a conflicting link/directory/deletion in recovery.
219
+ // Store its exact entry as private JSON, with a host-chosen hashed name.
220
+ const metadataDir = o.conflictDir + '.metadata'
221
+ mkdirSync(metadataDir, { recursive: true, mode: 0o700 })
222
+ const metadata = AnchoredDirectory.open(metadataDir)
223
+ const name = createHash('sha256').update(rel).digest('hex') + '.json'
224
+ try {
225
+ metadata.put(
226
+ name,
227
+ { p: name, k: 'f', m: 0o600 },
228
+ Buffer.from(
229
+ JSON.stringify({ path: rel, reason, entry: after ?? null }) + '\n',
230
+ ),
231
+ )
232
+ saved ??= join(metadataDir, name)
233
+ } finally {
234
+ metadata.close()
235
+ }
236
+ into.push({ path: rel, reason, ...(saved ? { saved } : {}) })
237
+ }
238
+ try {
239
+ for (const rel of [...deletions, ...updates]) {
240
+ const before = o.baseline.get(rel),
241
+ after = guest.get(rel)
242
+ let parent: AnchoredDirectory | undefined
243
+ try {
244
+ // Finish asynchronous guest retrieval before opening/checking the host
245
+ // destination. All host validation and mutation below are synchronous
246
+ // and relative to directory descriptors, including temp and rename.
247
+ const data = after?.k === 'f' ? await fetch(rel, after) : undefined
248
+ // The agent's change to itself: saved aside, never applied; the
249
+ // baseline stays, so it is still pending next time.
250
+ if (selfDefinition(rel)) {
251
+ conflict(
252
+ rel,
253
+ 'the agent changed its own definition; held for you to review',
254
+ after,
255
+ data,
256
+ result.held,
257
+ )
258
+ continue
259
+ }
260
+ try {
261
+ parent = root.parent(rel)
262
+ } catch {
263
+ conflict(rel, 'a parent is not a directory on the host', after, data)
264
+ continue
265
+ }
266
+ const name = rel.split('/').at(-1)!
267
+ const current = parent.entry(name, rel)
268
+ if (sameEntry(current, after)) {
269
+ setBaseline(o.baseline, rel, after)
270
+ continue
271
+ }
272
+ if (!sameEntry(current, before)) {
273
+ conflict(rel, 'changed on the host and in the guest', after, data)
274
+ continue
275
+ }
276
+ if (!after) {
277
+ parent.remove(name, current!.k === 'd')
278
+ result.deleted.push(rel)
279
+ } else if (after.k === 'd') {
280
+ if (current && current.k !== 'd') parent.remove(name, false)
281
+ const directory = parent.directory(name, true)
282
+ directory.close()
283
+ parent.mode(name, after.m & 0o777, true)
284
+ result.written.push(rel)
285
+ } else if (
286
+ after.k === 'f' &&
287
+ current?.k === 'f' &&
288
+ current.h === after.h
289
+ ) {
290
+ parent.mode(name, after.m & 0o777, false)
291
+ result.written.push(rel)
292
+ } else {
293
+ parent.put(name, after, data, current?.k === 'd')
294
+ result.written.push(rel)
295
+ }
296
+ setBaseline(o.baseline, rel, after)
297
+ } catch (e) {
298
+ result.conflicts.push({
299
+ path: rel,
300
+ reason: `not applied: ${(e as Error).message}`,
301
+ })
302
+ } finally {
303
+ if (parent && parent !== root) parent.close()
304
+ }
305
+ }
306
+ } finally {
307
+ root.close()
308
+ }
309
+ return result
310
+ }
311
+
312
+ function setBaseline(m: Manifest, rel: string, e?: Entry) {
313
+ if (e) m.set(rel, e)
314
+ else m.delete(rel)
315
+ }
316
+
317
+ export function describeSync(r: SyncResult) {
318
+ const parts = [
319
+ `${r.written.length} written`,
320
+ `${r.deleted.length} deleted`,
321
+ `${r.conflicts.length} conflicts`,
322
+ ]
323
+ if (r.held.length)
324
+ parts.push(
325
+ `${r.held.length} held (the agent changed its own definition: ${r.held.map((h) => h.saved ?? h.path).join(', ')})`,
326
+ )
327
+ if (r.rejected.length) parts.push(`${r.rejected.length} rejected`)
328
+ return parts.join(', ')
329
+ }
@@ -0,0 +1,247 @@
1
+ // `sudo mirab vm network setup`: the one-time owner step that gives macOS `vz`
2
+ // guests filtered egress, from the released binary instead of a loose script.
3
+ //
4
+ // VZ NAT (vmnet shared mode) puts guests on a host bridge (192.168.64.0/24 by
5
+ // default) and NATs them out of the Mac, which also reaches the Mac itself,
6
+ // its LAN, the tailnet and link-local. Only pf (root) can deny that. Setup:
7
+ // 1. writes the pf anchor: guests may use the vmnet DNS forwarder on the
8
+ // gateway and DHCP, and nothing else in 10/8, 172.16/12, 192.168/16,
9
+ // 100.64/10, 169.254/16, 127/8 or multicast; the internet passes;
10
+ // 2. checks it with `pfctl -n` before anything loads it;
11
+ // 3. loads it under com.apple/mirab-vz (stock pf.conf already evaluates
12
+ // anchor "com.apple/*", so pf.conf is not edited) from a LaunchDaemon
13
+ // that runs only /sbin/pfctl, so it survives reboots;
14
+ // 4. writes the root-owned marker vzNetworkState reads (never pfctl) to
15
+ // decide that a VM may get a NAT device.
16
+ import { execFileSync } from 'node:child_process'
17
+ import {
18
+ chmodSync,
19
+ chownSync,
20
+ mkdirSync,
21
+ renameSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from 'node:fs'
25
+ import { dirname } from 'node:path'
26
+ import { VZ_LAN_DENIED, VZ_NETWORK_MARKER } from './vz'
27
+
28
+ export const VZ_ANCHOR = 'com.apple/mirab-vz'
29
+ export const VZ_ANCHOR_FILE = '/etc/pf.anchors/mirab-vz'
30
+ export const VZ_DAEMON_LABEL = 'gg.mirab.vz-net'
31
+ export const VZ_DAEMON_PLIST = `/Library/LaunchDaemons/${VZ_DAEMON_LABEL}.plist`
32
+ const VMNET_PLIST =
33
+ '/Library/Preferences/SystemConfiguration/com.apple.vmnet.plist'
34
+
35
+ /** Everything a guest must never reach through VZ NAT. */
36
+ export const DENIED_RANGES = [
37
+ '10.0.0.0/8',
38
+ '172.16.0.0/12',
39
+ '192.168.0.0/16',
40
+ '100.64.0.0/10', // the tailnet
41
+ '169.254.0.0/16',
42
+ '127.0.0.0/8',
43
+ '224.0.0.0/4',
44
+ ]
45
+
46
+ export interface VmnetNetwork {
47
+ gateway: string
48
+ subnet: string
49
+ }
50
+
51
+ /** 255.255.255.0 → 24. Refuses a mask that isn't contiguous ones then zeros. */
52
+ export function maskPrefix(mask: string): number {
53
+ const octets = mask.split('.').map(Number)
54
+ if (
55
+ octets.length !== 4 ||
56
+ octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)
57
+ )
58
+ throw new Error(`not a netmask: ${mask}`)
59
+ const bits = octets.map((o) => o.toString(2).padStart(8, '0')).join('')
60
+ if (!/^1*0*$/.test(bits)) throw new Error(`not a contiguous netmask: ${mask}`)
61
+ return bits.indexOf('0') === -1 ? 32 : bits.indexOf('0')
62
+ }
63
+
64
+ export interface VzNetworkFile {
65
+ path: string
66
+ mode: number
67
+ content: string
68
+ }
69
+
70
+ /** The three files setup writes, in the order it writes them. */
71
+ export function vzNetworkFiles(net: VmnetNetwork): VzNetworkFile[] {
72
+ const { gateway: gw, subnet } = net
73
+ return [
74
+ {
75
+ path: VZ_ANCHOR_FILE,
76
+ mode: 0o644,
77
+ content: `# mirab vz guests (vmnet shared network ${subnet}): internet yes, LAN/host/metadata no.
78
+ pass in quick inet proto { tcp udp } from ${subnet} to ${gw} port 53
79
+ pass in quick inet proto udp from any port 68 to any port 67
80
+ block drop in quick inet from ${subnet} to { ${DENIED_RANGES.join(' ')} }
81
+ `,
82
+ },
83
+ {
84
+ path: VZ_DAEMON_PLIST,
85
+ mode: 0o644,
86
+ content: `<?xml version="1.0" encoding="UTF-8"?>
87
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
88
+ <plist version="1.0"><dict>
89
+ <key>Label</key><string>${VZ_DAEMON_LABEL}</string>
90
+ <key>ProgramArguments</key><array>
91
+ <string>/sbin/pfctl</string><string>-E</string>
92
+ <string>-a</string><string>${VZ_ANCHOR}</string>
93
+ <string>-f</string><string>${VZ_ANCHOR_FILE}</string>
94
+ </array>
95
+ <key>RunAtLoad</key><true/>
96
+ </dict></plist>
97
+ `,
98
+ },
99
+ {
100
+ path: VZ_NETWORK_MARKER,
101
+ mode: 0o644,
102
+ content:
103
+ JSON.stringify({
104
+ version: 1,
105
+ network: VZ_LAN_DENIED,
106
+ gateway: gw,
107
+ subnet,
108
+ anchor: VZ_ANCHOR,
109
+ }) + '\n',
110
+ },
111
+ ]
112
+ }
113
+
114
+ /** What setup and remove touch on the machine; tests pass their own. */
115
+ export interface VzSetupSystem {
116
+ isRoot(): boolean
117
+ platform: NodeJS.Platform
118
+ /** vmnet's configured shared network, if the owner changed it. */
119
+ readDefault(key: 'Shared_Net_Address' | 'Shared_Net_Mask'): string | undefined
120
+ /** Runs a command; throws when it fails. */
121
+ run(argv: string[]): void
122
+ /** Exit status of a command, for checks that may fail. */
123
+ succeeds(argv: string[]): boolean
124
+ /** Atomically installs a root:wheel file with this mode. */
125
+ install(file: VzNetworkFile): void
126
+ remove(path: string): void
127
+ }
128
+
129
+ export const realVzSystem: VzSetupSystem = {
130
+ isRoot: () => process.getuid?.() === 0,
131
+ platform: process.platform,
132
+ readDefault(key) {
133
+ try {
134
+ return execFileSync('/usr/bin/defaults', ['read', VMNET_PLIST, key], {
135
+ encoding: 'utf8',
136
+ stdio: ['ignore', 'pipe', 'ignore'],
137
+ }).trim()
138
+ } catch {
139
+ return undefined
140
+ }
141
+ },
142
+ run(argv) {
143
+ execFileSync(argv[0]!, argv.slice(1), { stdio: 'inherit' })
144
+ },
145
+ succeeds(argv) {
146
+ try {
147
+ execFileSync(argv[0]!, argv.slice(1), { stdio: 'ignore' })
148
+ return true
149
+ } catch {
150
+ return false
151
+ }
152
+ },
153
+ install({ path, mode, content }) {
154
+ mkdirSync(dirname(path), { recursive: true, mode: 0o755 })
155
+ const tmp = `${path}.new`
156
+ writeFileSync(tmp, content, { mode })
157
+ chmodSync(tmp, mode)
158
+ chownSync(tmp, 0, 0)
159
+ renameSync(tmp, path)
160
+ },
161
+ remove(path) {
162
+ rmSync(path, { force: true })
163
+ },
164
+ }
165
+
166
+ export function vmnetNetwork(sys: VzSetupSystem): VmnetNetwork {
167
+ const gateway = sys.readDefault('Shared_Net_Address') ?? '192.168.64.1'
168
+ const mask = sys.readDefault('Shared_Net_Mask') ?? '255.255.255.0'
169
+ return { gateway, subnet: `${gateway}/${maskPrefix(mask)}` }
170
+ }
171
+
172
+ function precheck(sys: VzSetupSystem, dryRun: boolean, verb: string) {
173
+ if (sys.platform !== 'darwin')
174
+ throw new Error(
175
+ 'mirab vm network is for macOS vz guests; Firecracker hosts are set up separately',
176
+ )
177
+ if (!dryRun && !sys.isRoot())
178
+ throw new Error(
179
+ `run with sudo: sudo ~/.mirab/bin/mirab vm network ${verb} (or add --dry-run to see what it does)`,
180
+ )
181
+ }
182
+
183
+ const loaded = (sys: VzSetupSystem) =>
184
+ sys.succeeds(['/bin/launchctl', 'print', `system/${VZ_DAEMON_LABEL}`])
185
+
186
+ /** Installs the filter, or prints what it would do. Safe to run again. */
187
+ export function setupVzNetwork(
188
+ opts: { dryRun?: boolean; log?: (line: string) => void },
189
+ sys: VzSetupSystem = realVzSystem,
190
+ ) {
191
+ const dryRun = !!opts.dryRun
192
+ const log = opts.log ?? console.log
193
+ precheck(sys, dryRun, 'setup')
194
+ const net = vmnetNetwork(sys)
195
+ const [anchor, daemon, marker] = vzNetworkFiles(net)
196
+ const step = (what: string, act: () => void) =>
197
+ dryRun ? log(`+ ${what}`) : act()
198
+ const write = (f: VzNetworkFile) => {
199
+ if (dryRun) log(`--- ${f.path} (${f.mode.toString(8)})\n${f.content}`)
200
+ else sys.install(f)
201
+ }
202
+
203
+ write(anchor!)
204
+ // A broken anchor must never reach the running firewall.
205
+ step(`pfctl -n -a ${VZ_ANCHOR} -f ${VZ_ANCHOR_FILE}`, () =>
206
+ sys.run(['/sbin/pfctl', '-n', '-a', VZ_ANCHOR, '-f', VZ_ANCHOR_FILE]),
207
+ )
208
+ write(daemon!)
209
+ if (!dryRun && loaded(sys))
210
+ // Already installed: rerun the daemon so the new anchor is loaded now.
211
+ sys.run(['/bin/launchctl', 'kickstart', '-k', `system/${VZ_DAEMON_LABEL}`])
212
+ else
213
+ step(`launchctl bootstrap system ${VZ_DAEMON_PLIST}`, () =>
214
+ sys.run(['/bin/launchctl', 'bootstrap', 'system', VZ_DAEMON_PLIST]),
215
+ )
216
+ // Last: mirab trusts the marker, so it appears only once the filter is live.
217
+ write(marker!)
218
+ log(
219
+ `${dryRun ? 'would install' : 'installed'}: mirab vz network filter (anchor ${VZ_ANCHOR}, guests on ${net.subnet}). Check with: mirab vm network status`,
220
+ )
221
+ }
222
+
223
+ /** Undoes setup: the marker first, so no VM is offered NAT without the filter. */
224
+ export function removeVzNetwork(
225
+ opts: { dryRun?: boolean; log?: (line: string) => void },
226
+ sys: VzSetupSystem = realVzSystem,
227
+ ) {
228
+ const dryRun = !!opts.dryRun
229
+ const log = opts.log ?? console.log
230
+ precheck(sys, dryRun, 'remove')
231
+ const step = (what: string, act: () => void) =>
232
+ dryRun ? log(`+ ${what}`) : act()
233
+ step(`rm ${VZ_NETWORK_MARKER}`, () => sys.remove(VZ_NETWORK_MARKER))
234
+ if (dryRun) log(`+ launchctl bootout system/${VZ_DAEMON_LABEL}`)
235
+ else if (loaded(sys))
236
+ sys.run(['/bin/launchctl', 'bootout', `system/${VZ_DAEMON_LABEL}`])
237
+ step(`pfctl -a ${VZ_ANCHOR} -F all`, () =>
238
+ sys.succeeds(['/sbin/pfctl', '-a', VZ_ANCHOR, '-F', 'all']),
239
+ )
240
+ step(`rm ${VZ_DAEMON_PLIST} ${VZ_ANCHOR_FILE}`, () => {
241
+ sys.remove(VZ_DAEMON_PLIST)
242
+ sys.remove(VZ_ANCHOR_FILE)
243
+ })
244
+ log(
245
+ `${dryRun ? 'would remove' : 'removed'}: mirab vz network filter; vz VMs get no network until setup runs again`,
246
+ )
247
+ }