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.
- package/LICENSE +201 -0
- package/README.md +223 -3
- package/package.json +56 -7
- 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
- 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
|
+
}
|