mirab 0.0.0 → 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 (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +223 -3
  3. package/package.json +56 -7
  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
  106. package/index.js +0 -1
@@ -0,0 +1,541 @@
1
+ // `mirab spawn --vm` on Windows: the agent runs in a Multipass VM
2
+ // (Ubuntu), driven only through the `multipass` command and its JSON output.
3
+ //
4
+ // One VM, `mirab`, is made on first use and kept. Each session copies the
5
+ // working directory in (no mounts), plus the agent's own mirab key and its
6
+ // repo-scoped token, so the agent clones, pushes and opens pull requests from
7
+ // inside the VM. Your own mirab key never enters it. Work leaves the VM through
8
+ // git; the session's copy stays in the VM until you remove it.
9
+ import pkg from '../../package.json'
10
+ import { spawn, spawnSync } from 'node:child_process'
11
+ import { readFileSync } from 'node:fs'
12
+ import { readFile } from 'node:fs/promises'
13
+ import { basename, isAbsolute, join, relative, sep } from 'node:path'
14
+ import type { VmChild } from '../spawn-vm'
15
+
16
+ export const MULTIPASS_VM = 'mirab'
17
+ const HOME = '/home/ubuntu'
18
+
19
+ /** Windows runs `--vm` in Multipass (it has no vz or Firecracker), unless
20
+ * isolation.json in the config directory sets up its own `vm`. A Mac uses vz,
21
+ * with the published guest image fetched on first use. */
22
+ export function usesMultipass(
23
+ configDir?: string,
24
+ platform: NodeJS.Platform = process.platform,
25
+ ) {
26
+ if (platform !== 'win32') return false
27
+ if (!configDir) return true
28
+ try {
29
+ return !JSON.parse(readFileSync(join(configDir, 'isolation.json'), 'utf8'))
30
+ .vm
31
+ } catch {
32
+ return true
33
+ }
34
+ }
35
+
36
+ /** Where a Claude Code token for a Multipass VM would be kept (0600). mirab
37
+ * never copies one into a VM: the station adds the key on the way out. */
38
+ export const claudeTokenPath = (configDir: string) =>
39
+ join(configDir, 'vm', 'claude.token')
40
+
41
+ // One-time setup of the VM: what an agent needs to work from inside it. Run
42
+ // as plain commands after launch (a cloud-init file left the VM without a
43
+ // network address on macOS); a marker file says it finished.
44
+ const READY = `${HOME}/.mirab-ready`
45
+ const SETUP = `set -e
46
+ export DEBIAN_FRONTEND=noninteractive
47
+ sudo apt-get update -qq
48
+ sudo apt-get install -y -qq git nodejs npm python3 build-essential ca-certificates >/dev/null
49
+ sudo npm install -g --silent @anthropic-ai/claude-code gildforge@${pkg.version}
50
+ touch ${READY}`
51
+
52
+ /** The multipass binary's full path: the pty helper runs a path, not a name. */
53
+ function multipassPath() {
54
+ const r = spawnSync(
55
+ process.platform === 'win32' ? 'where' : 'which',
56
+ ['multipass'],
57
+ { encoding: 'utf8' },
58
+ )
59
+ return r.status === 0 ? r.stdout.split(/\r?\n/)[0].trim() : 'multipass'
60
+ }
61
+
62
+ /** Multipass could not open its SSH connection into the VM. On macOS that is
63
+ * Local Network privacy refusing the connection, typically once the terminal
64
+ * or SSH session that started mirab has gone; on any host, briefly, a VM whose
65
+ * network is still coming up. */
66
+ export const noRoute = (output: string) => /no route to host/i.test(output)
67
+
68
+ export function noRouteError(platform: NodeJS.Platform = process.platform) {
69
+ return new Error(
70
+ platform === 'darwin'
71
+ ? 'Multipass cannot reach the mirab VM (No route to host). macOS Local Network privacy is blocking it: allow Multipass in System Settings → Privacy & Security → Local Network, and keep the terminal or SSH session that starts mirab open until the agent has started'
72
+ : 'Multipass cannot reach the mirab VM (No route to host). Check `multipass list` and the host firewall, then run this again',
73
+ )
74
+ }
75
+
76
+ /** Runs `attempt` again while it fails with "No route to host", up to
77
+ * `tries` times, waiting `delayMs` between tries. */
78
+ export function retryNoRoute<T extends { status: number | null }>(
79
+ attempt: () => T & { stdout?: string; stderr?: string },
80
+ { tries = 5, delayMs = 1000, sleep = sleepSync } = {},
81
+ ): T {
82
+ for (let i = 1; ; i++) {
83
+ const r = attempt()
84
+ if (r.status === 0 || !noRoute(`${r.stderr ?? ''}${r.stdout ?? ''}`))
85
+ return r
86
+ if (i >= tries) return r
87
+ sleep(delayMs)
88
+ }
89
+ }
90
+
91
+ function sleepSync(ms: number) {
92
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
93
+ }
94
+
95
+ function mp(args: string[], input?: string) {
96
+ return retryNoRoute(() => {
97
+ const r = spawnSync('multipass', args, {
98
+ encoding: 'utf8',
99
+ input,
100
+ maxBuffer: 64 << 20,
101
+ })
102
+ if (r.error) throw r.error
103
+ return r
104
+ })
105
+ }
106
+
107
+ function must(args: string[], what: string, input?: string) {
108
+ const r = mp(args, input)
109
+ if (r.status !== 0) {
110
+ const output = `${r.stderr}${r.stdout}`
111
+ if (noRoute(output)) throw noRouteError()
112
+ throw new Error(
113
+ `${what}: ${(r.stderr || r.stdout).trim() || `multipass exited ${r.status}`}`,
114
+ )
115
+ }
116
+ return r.stdout
117
+ }
118
+
119
+ /** Whether the VM finished its one-time setup. Only a missing marker file
120
+ * means "not set up"; a VM that cannot be reached is an error. */
121
+ export function readiness(r: {
122
+ status: number | null
123
+ stdout: string
124
+ stderr: string
125
+ }): boolean {
126
+ const answer = r.stdout.trim()
127
+ if (r.status === 0 && answer === 'ready') return true
128
+ if (r.status === 0 && answer === 'missing') return false
129
+ if (noRoute(`${r.stderr}${r.stdout}`)) throw noRouteError()
130
+ throw new Error(
131
+ `checking the mirab VM: ${(r.stderr || r.stdout).trim() || `multipass exited ${r.status}`}`,
132
+ )
133
+ }
134
+
135
+ /** The program as the VM runs it: a program inside the working directory by
136
+ * its path relative to the copy (`./agent.sh`), anything else by name, from
137
+ * the VM's own PATH. */
138
+ export function guestProgram(binary: string, cwd: string) {
139
+ const inside = relative(cwd, binary)
140
+ if (inside && !inside.startsWith('..') && !isAbsolute(inside))
141
+ return `./${inside.split(sep).join('/')}`
142
+ return basename(binary)
143
+ }
144
+
145
+ /** Why Multipass can't be used here, or null. */
146
+ export function multipassMissing(): string | null {
147
+ const r = spawnSync('multipass', ['version'], { encoding: 'utf8' })
148
+ if (r.error || r.status !== 0)
149
+ return process.platform === 'win32'
150
+ ? 'Multipass is not installed. Install it from https://multipass.run, then run this again'
151
+ : 'Multipass is not installed. Install it with `brew install --cask multipass` (or from https://multipass.run), then run this again'
152
+ return null
153
+ }
154
+
155
+ /** Make or start the `mirab` VM. */
156
+ /** One step of making the VM: a spinner, its elapsed time and, while Ubuntu
157
+ * downloads, Multipass's own percentage; a plain line when not on a terminal. */
158
+ function step(label: string, args: string[], log: (line: string) => void) {
159
+ const tty = !!process.stderr.isTTY
160
+ const started = Date.now()
161
+ const seconds = () => `${Math.round((Date.now() - started) / 1000)}s`
162
+ const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
163
+ let frame = 0,
164
+ detail = '',
165
+ out = ''
166
+ if (!tty) log(`${label}…`)
167
+ const draw = () =>
168
+ process.stderr.write(
169
+ `\r\x1b[2K ${frames[frame++ % frames.length]} ${label}${detail ? ` · ${detail}` : ''} ${seconds()}`,
170
+ )
171
+ const timer = tty ? setInterval(draw, 120) : undefined
172
+ return new Promise<void>((resolve, reject) => {
173
+ const child = spawn(multipassPath(), args, {
174
+ stdio: ['ignore', 'pipe', 'pipe'],
175
+ })
176
+ const read = (d: Buffer) => {
177
+ out += d.toString()
178
+ const percent = [...d.toString().matchAll(/(\d{1,3})%/g)].at(-1)?.[1]
179
+ if (percent) detail = `${percent}%`
180
+ }
181
+ child.stdout.on('data', read)
182
+ child.stderr.on('data', read)
183
+ child.on('error', reject)
184
+ child.on('exit', (code) => {
185
+ if (timer) clearInterval(timer)
186
+ if (code === 0) {
187
+ if (tty) process.stderr.write(`\r\x1b[2K ✓ ${label} ${seconds()}\n`)
188
+ else log(`${label}: done in ${seconds()}`)
189
+ return resolve()
190
+ }
191
+ if (tty) process.stderr.write(`\r\x1b[2K ✗ ${label} ${seconds()}\n`)
192
+ if (noRoute(out)) return reject(noRouteError())
193
+ const why = out
194
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
195
+ .trim()
196
+ .split('\n')
197
+ .at(-1)
198
+ reject(new Error(`${label}: ${why || `multipass exited ${code}`}`))
199
+ })
200
+ })
201
+ }
202
+
203
+ /** Why mirab can't use Multipass yet: the VM installs the CLI from npm, and
204
+ * mirab isn't published there. On macOS the `vz` backend works instead. */
205
+ export const MULTIPASS_UNAVAILABLE =
206
+ "mirab can't set up a Multipass VM yet: the VM installs mirab from npm, where it isn't published. " +
207
+ 'On macOS, use the vz backend: build the guest with scripts/build-vm-guest.sh and the helper with scripts/build-vz-helper.sh, then set "vm" in isolation.json (docs/VM.md).'
208
+
209
+ /** Make or start the `mirab` VM, showing each step. */
210
+ export async function ensureVm(log: (line: string) => void = () => {}) {
211
+ const missing = multipassMissing()
212
+ if (missing) throw new Error(missing)
213
+ const info = mp(['info', MULTIPASS_VM, '--format', 'json'])
214
+ const fresh = info.status !== 0
215
+ const state = fresh
216
+ ? null
217
+ : JSON.parse(info.stdout).info?.[MULTIPASS_VM]?.state
218
+ const isReady = () =>
219
+ readiness(
220
+ mp([
221
+ 'exec',
222
+ MULTIPASS_VM,
223
+ '--',
224
+ 'sh',
225
+ '-c',
226
+ `test -f ${READY} && echo ready || echo missing`,
227
+ ]),
228
+ )
229
+ const setup = () =>
230
+ log('setting up the mirab VM (first time only, a few minutes)')
231
+ if (fresh) {
232
+ setup()
233
+ await step(
234
+ 'Download Ubuntu 24.04 and create the VM',
235
+ [
236
+ 'launch',
237
+ '24.04',
238
+ '--name',
239
+ MULTIPASS_VM,
240
+ '--cpus',
241
+ '2',
242
+ '--memory',
243
+ '4G',
244
+ '--disk',
245
+ '20G',
246
+ '--timeout',
247
+ '600',
248
+ ],
249
+ log,
250
+ )
251
+ } else if (state !== 'Running')
252
+ await step('Start the mirab VM', ['start', MULTIPASS_VM], log)
253
+ if (isReady()) {
254
+ // The VM's mirab follows this one: an agent in the VM signs, clones and
255
+ // pushes with it, so an upgrade here upgrades it there.
256
+ const inside = mp([
257
+ 'exec',
258
+ MULTIPASS_VM,
259
+ '--',
260
+ 'mirab',
261
+ '--version',
262
+ ]).stdout.trim()
263
+ if (inside !== pkg.version)
264
+ await step(
265
+ `Update mirab in the VM to ${pkg.version}`,
266
+ [
267
+ 'exec',
268
+ MULTIPASS_VM,
269
+ '--',
270
+ 'sudo',
271
+ 'npm',
272
+ 'install',
273
+ '-g',
274
+ '--silent',
275
+ '--prefer-online',
276
+ `gildforge@${pkg.version}`,
277
+ ],
278
+ log,
279
+ )
280
+ return
281
+ }
282
+ if (!fresh) setup()
283
+ await step(
284
+ 'Install Node, git, Claude Code and mirab',
285
+ ['exec', MULTIPASS_VM, '--', 'sh', '-c', SETUP],
286
+ log,
287
+ )
288
+ }
289
+
290
+ /** Resolves when the guest wrote the session's pid file (the program is
291
+ * starting), when the multipass process exits, or after `timeoutMs`. */
292
+ function started(
293
+ child: import('node:child_process').ChildProcess,
294
+ pidFile: string,
295
+ timeoutMs = 20000,
296
+ ) {
297
+ return new Promise<void>((resolve) => {
298
+ let done = false
299
+ const finish = () => {
300
+ if (done) return
301
+ done = true
302
+ clearTimeout(timer)
303
+ child.off('exit', finish)
304
+ resolve()
305
+ }
306
+ const timer = setTimeout(finish, timeoutMs)
307
+ child.once('exit', finish)
308
+ const poll = () => {
309
+ if (done) return
310
+ const check = spawn(
311
+ multipassPath(),
312
+ ['exec', MULTIPASS_VM, '--', 'test', '-s', pidFile],
313
+ { stdio: 'ignore' },
314
+ )
315
+ check.on('error', finish)
316
+ check.on('exit', (code) =>
317
+ code === 0 ? finish() : setTimeout(poll, 250),
318
+ )
319
+ }
320
+ poll()
321
+ })
322
+ }
323
+
324
+ const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
325
+
326
+ /** Write a file in the VM from host content, never through argv. */
327
+ function put(path: string, content: string, mode: string) {
328
+ must(
329
+ [
330
+ 'exec',
331
+ MULTIPASS_VM,
332
+ '--',
333
+ 'sh',
334
+ '-c',
335
+ `umask 077 && mkdir -p "$(dirname ${quote(path)})" && cat > ${quote(path)} && chmod ${mode} ${quote(path)}`,
336
+ ],
337
+ `copying ${basename(path)} into the VM`,
338
+ content,
339
+ )
340
+ }
341
+
342
+ export async function startMultipassChild(opts: {
343
+ pty: typeof import('node-pty')
344
+ configDir: string
345
+ sessionId: string
346
+ cwd: string
347
+ binary: string
348
+ args: string[]
349
+ env: Record<string, string>
350
+ /** The approved local agent this session runs as (its label), if any. */
351
+ agent?: string
352
+ cols: number
353
+ rows: number
354
+ log?: (line: string) => void
355
+ }): Promise<VmChild> {
356
+ await ensureVm(opts.log)
357
+ const dir = `${HOME}/mirab/sessions/${opts.sessionId}`
358
+ const ws = `${dir}/${basename(opts.cwd)}`
359
+ must(
360
+ [
361
+ 'exec',
362
+ MULTIPASS_VM,
363
+ '--',
364
+ 'sh',
365
+ '-c',
366
+ `rm -rf ${quote(dir)} && mkdir -p ${quote(dir)}`,
367
+ ],
368
+ 'preparing the session in the VM',
369
+ )
370
+ opts.log?.(`copying ${opts.cwd} into the VM`)
371
+ // A tar stream: sockets and other special files are skipped, not fatal.
372
+ const copy = retryNoRoute(() =>
373
+ spawnSync(
374
+ 'sh',
375
+ [
376
+ '-c',
377
+ `COPYFILE_DISABLE=1 tar -C ${quote(opts.cwd)} -cf - . 2>/dev/null | ${quote(multipassPath())} exec ${MULTIPASS_VM} -- sh -c ${quote(`mkdir -p ${quote(ws)} && tar -C ${quote(ws)} -xf - 2>/dev/null`)}`,
378
+ ],
379
+ { encoding: 'utf8', maxBuffer: 16 << 20 },
380
+ ),
381
+ )
382
+ if (noRoute(copy.stderr ?? '')) throw noRouteError()
383
+ if (
384
+ copy.status !== 0 ||
385
+ mp(['exec', MULTIPASS_VM, '--', 'test', '-d', ws]).status !== 0
386
+ )
387
+ throw new Error(
388
+ `copying the working directory into the VM: ${copy.stderr.trim() || 'tar failed'}`,
389
+ )
390
+
391
+ const program = guestProgram(opts.binary, opts.cwd)
392
+ if (basename(opts.binary) === 'claude') {
393
+ // gild copied a Claude Code token into the VM; mirab won't (the station
394
+ // adds the key on the way out), so this path refuses until then.
395
+ const token =
396
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ??
397
+ (
398
+ await readFile(claudeTokenPath(opts.configDir), 'utf8').catch(() => '')
399
+ ).trim()
400
+ if (!token)
401
+ throw new Error(
402
+ 'Claude Code in a Multipass VM needs its token copied into the VM, which mirab does not do; use the vz backend',
403
+ )
404
+ put(`${HOME}/.config/mirab/vm/claude.token`, token, '600')
405
+ // A fresh Claude Code asks first-run questions and whether to trust the folder; nobody is there to answer.
406
+ must(
407
+ [
408
+ 'exec',
409
+ MULTIPASS_VM,
410
+ '--',
411
+ 'python3',
412
+ '-c',
413
+ `import json,os;p=os.path.expanduser('~/.claude.json')
414
+ try: c=json.load(open(p))
415
+ except Exception: c={}
416
+ c['hasCompletedOnboarding']=True
417
+ c.setdefault('projects',{}).setdefault(${JSON.stringify(ws)},{})['hasTrustDialogAccepted']=True
418
+ json.dump(c,open(p,'w'))`,
419
+ ],
420
+ 'preparing Claude Code in the VM',
421
+ )
422
+ }
423
+ if (opts.agent) {
424
+ // The agent's own key and repo-scoped token: it pushes and opens pull requests from the VM.
425
+ const file = join(opts.configDir, 'agents', `${opts.agent}.json`)
426
+ put(
427
+ `${HOME}/.config/mirab/agents/${opts.agent}.json`,
428
+ await readFile(file, 'utf8'),
429
+ '600',
430
+ )
431
+ must(
432
+ [
433
+ 'exec',
434
+ MULTIPASS_VM,
435
+ '--working-directory',
436
+ ws,
437
+ '--',
438
+ 'sh',
439
+ '-c',
440
+ `git rev-parse --git-dir >/dev/null 2>&1 || exit 0
441
+ for k in $(git config --name-only --get-regexp '^credential\\..*helper$' | sort -u); do git config --unset-all "$k"; done
442
+ o=$(git remote get-url origin 2>/dev/null | sed -E 's#^(https?://[^/]+).*#\\1#')
443
+ [ -n "$o" ] && git config --add "credential.$o.helper" "!mirab credential --agent ${opts.agent}"; exit 0`,
444
+ ],
445
+ 'wiring git in the VM',
446
+ )
447
+ }
448
+
449
+ const env = {
450
+ ...opts.env,
451
+ HOME,
452
+ PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
453
+ }
454
+ // The agent's terminal is made inside the VM by `script`; the host only
455
+ // pipes bytes through `multipass exec` (a host pty helper never got as far
456
+ // as running multipass from a detached session). Its pid is kept so the
457
+ // session can be hung up from the host.
458
+ const command = [
459
+ `echo $$ > ${quote(dir + '/pid')}`,
460
+ `stty cols ${opts.cols} rows ${opts.rows}`,
461
+ `t=${quote(HOME + '/.config/mirab/vm/claude.token')}; [ -f "$t" ] && export CLAUDE_CODE_OAUTH_TOKEN="$(cat "$t")"`,
462
+ `exec env ${Object.entries(env)
463
+ .map(([k, v]) => quote(`${k}=${v}`))
464
+ .join(' ')} ${[program, ...opts.args].map(quote).join(' ')}`,
465
+ ].join('; ')
466
+ const p = spawn(
467
+ multipassPath(),
468
+ [
469
+ 'exec',
470
+ MULTIPASS_VM,
471
+ '--working-directory',
472
+ ws,
473
+ '--',
474
+ 'script',
475
+ '-qfec',
476
+ command,
477
+ '/dev/null',
478
+ ],
479
+ {
480
+ cwd: opts.cwd,
481
+ stdio: ['pipe', 'pipe', 'pipe'],
482
+ },
483
+ )
484
+ // Return once the program runs in the VM: `mirab spawn --detach` reports the
485
+ // session ready only then, so the session that started it is still there
486
+ // while Multipass connects (macOS Local Network privacy refuses it after).
487
+ await started(p, `${dir}/pid`)
488
+ const hangUp = () =>
489
+ mp([
490
+ 'exec',
491
+ MULTIPASS_VM,
492
+ '--',
493
+ 'sh',
494
+ '-c',
495
+ `kill -HUP "$(cat ${quote(dir + '/pid')} 2>/dev/null)" 2>/dev/null; exit 0`,
496
+ ])
497
+ const none = {
498
+ written: [],
499
+ deleted: [],
500
+ conflicts: [],
501
+ held: [],
502
+ rejected: [],
503
+ }
504
+ return {
505
+ pid: p.pid ?? -1,
506
+ write: (data) => void p.stdin.write(data),
507
+ // The guest terminal keeps the size it started with.
508
+ resize: () => {},
509
+ onData: (cb) => {
510
+ p.stdout.on('data', (d: Buffer) => cb(d))
511
+ p.stderr.on('data', (d: Buffer) => {
512
+ cb(d)
513
+ if (noRoute(d.toString()))
514
+ cb(Buffer.from(`\r\nmirab: ${noRouteError().message}\r\n`))
515
+ })
516
+ },
517
+ onExit: (cb) =>
518
+ void p.on('exit', (code, signal) =>
519
+ cb({ exitCode: code ?? 1, signal: signal ? 1 : undefined }),
520
+ ),
521
+ pause: () => {
522
+ p.stdout.pause()
523
+ p.stderr.pause()
524
+ },
525
+ resume: () => {
526
+ p.stdout.resume()
527
+ p.stderr.resume()
528
+ },
529
+ kill: () => {
530
+ hangUp()
531
+ p.kill()
532
+ },
533
+ // Work leaves the VM through git (the agent pushes); nothing is copied back.
534
+ sync: async () => none,
535
+ dispose: async () => {
536
+ hangUp()
537
+ p.kill()
538
+ return none
539
+ },
540
+ }
541
+ }
@@ -0,0 +1,167 @@
1
+ // Network for `vm` isolation. Set up once by the owner with
2
+ // `sudo scripts/vm-network-setup.sh`: a bridge `mirabbr0`, a pool of tap devices
3
+ // `mirabtap0..N` owned by the runner user, and nftables rules that allow the
4
+ // internet but deny the host, the LAN, 100.64/10 and the metadata address.
5
+ // Without that setup a VM has no network device at all.
6
+ import {
7
+ existsSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ mkdirSync,
11
+ openSync,
12
+ closeSync,
13
+ writeSync,
14
+ unlinkSync,
15
+ } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+ export const BRIDGE = 'mirabbr0'
19
+ export const SETUP_COMMAND = 'sudo scripts/vm-network-setup.sh'
20
+ // Not gild's 172.31.255/24 (or its docker network's 172.31.254/24): both can
21
+ // run on one host.
22
+ const PREFIX = '172.31.250'
23
+
24
+ export interface NetworkState {
25
+ ready: boolean
26
+ /** Tap slots this user can use. */
27
+ slots: number[]
28
+ reason?: string
29
+ }
30
+
31
+ export function networkState(
32
+ uid = process.getuid?.() ?? -1,
33
+ sys = '/sys/class/net',
34
+ ): NetworkState {
35
+ if (!existsSync(join(sys, BRIDGE)))
36
+ return { ready: false, slots: [], reason: `no ${BRIDGE} bridge` }
37
+ const slots = readdirSync(sys)
38
+ .map((n) => /^mirabtap(\d+)$/.exec(n)?.[1])
39
+ .filter((n): n is string => n !== undefined)
40
+ .map(Number)
41
+ .filter((n) => {
42
+ try {
43
+ return (
44
+ Number(readFileSync(join(sys, `mirabtap${n}`, 'owner'), 'utf8')) ===
45
+ uid
46
+ )
47
+ } catch {
48
+ return false
49
+ }
50
+ })
51
+ .sort((a, b) => a - b)
52
+ return slots.length
53
+ ? { ready: true, slots }
54
+ : {
55
+ ready: false,
56
+ slots: [],
57
+ reason: `no mirabtap devices owned by uid ${uid}`,
58
+ }
59
+ }
60
+
61
+ export function guestNetwork(slot: number) {
62
+ const hex = (n: number) => n.toString(16).padStart(2, '0')
63
+ return {
64
+ tap: `mirabtap${slot}`,
65
+ mac: `06:00:ac:1f:fa:${hex(10 + slot)}`,
66
+ ip: `${PREFIX}.${10 + slot}`,
67
+ cidr: 24,
68
+ gateway: `${PREFIX}.1`,
69
+ dns: '1.1.1.1',
70
+ }
71
+ }
72
+
73
+ /** Kernel args read by /init-mirab.sh in the guest. */
74
+ export function bootNetArgs(n: ReturnType<typeof guestNetwork>) {
75
+ return `mirab.ip=${n.ip}/${n.cidr} mirab.gw=${n.gateway} mirab.dns=${n.dns}`
76
+ }
77
+
78
+ const alive = (pid: number) => {
79
+ try {
80
+ process.kill(pid, 0)
81
+ return true
82
+ } catch (e) {
83
+ return (e as NodeJS.ErrnoException).code === 'EPERM'
84
+ }
85
+ }
86
+
87
+ /** Claim a free tap slot with a pid lock file; stale locks of dead pids are taken over. */
88
+ export function claimTap(
89
+ slots: number[],
90
+ lockDir: string,
91
+ ): { slot: number; release: () => void } {
92
+ mkdirSync(lockDir, { recursive: true, mode: 0o700 })
93
+ for (const slot of slots) {
94
+ const file = join(lockDir, `mirabtap${slot}.lock`)
95
+ for (let attempt = 0; attempt < 2; attempt++) {
96
+ try {
97
+ const fd = openSync(file, 'wx', 0o600)
98
+ writeSync(fd, String(process.pid))
99
+ closeSync(fd)
100
+ return {
101
+ slot,
102
+ release: () => {
103
+ try {
104
+ unlinkSync(file)
105
+ } catch {}
106
+ },
107
+ }
108
+ } catch (e) {
109
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
110
+ const holder = Number(readFileSyncSafe(file))
111
+ if (holder && alive(holder)) break
112
+ try {
113
+ unlinkSync(file)
114
+ } catch {}
115
+ }
116
+ }
117
+ }
118
+ throw new Error('all VM network slots are in use')
119
+ }
120
+
121
+ function readFileSyncSafe(file: string) {
122
+ try {
123
+ return readFileSync(file, 'utf8')
124
+ } catch {
125
+ return ''
126
+ }
127
+ }
128
+
129
+ export function networkStatusLine(
130
+ mode: 'auto' | 'block' | 'allow',
131
+ s: NetworkState,
132
+ ) {
133
+ if (mode === 'block') return 'vm network: none (egress blocked by config)'
134
+ return s.ready
135
+ ? `vm network: egress allowed, LAN/host/metadata denied (${s.slots.length} slots on ${BRIDGE})`
136
+ : `vm network: none, egress blocked (${s.reason}; enable with: ${SETUP_COMMAND})`
137
+ }
138
+
139
+ export interface NetworkPlan {
140
+ net?: ReturnType<typeof guestNetwork>
141
+ release: () => void
142
+ /** Printed in the job log. */
143
+ note: string
144
+ }
145
+
146
+ /** Decide whether a VM gets a network device. `allow` without setup is an error, `auto` degrades loudly. */
147
+ export function planNetwork(
148
+ mode: 'auto' | 'block' | 'allow',
149
+ state: NetworkState,
150
+ lockDir: string,
151
+ ): NetworkPlan {
152
+ if (mode === 'block')
153
+ return { release() {}, note: networkStatusLine(mode, state) }
154
+ if (!state.ready) {
155
+ if (mode === 'allow')
156
+ throw new Error(
157
+ `egress is set to allow but the VM network is not set up (${state.reason}). Needs Sami: ${SETUP_COMMAND}`,
158
+ )
159
+ return { release() {}, note: networkStatusLine(mode, state) }
160
+ }
161
+ const claim = claimTap(state.slots, lockDir)
162
+ return {
163
+ net: guestNetwork(claim.slot),
164
+ release: claim.release,
165
+ note: networkStatusLine(mode, state),
166
+ }
167
+ }