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,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
|
+
}
|