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,477 @@
1
+ import { z } from 'zod'
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { join, resolve } from 'node:path'
5
+ import {
6
+ defaultFirecracker,
7
+ firecrackerAvailable,
8
+ firecrackerHostReady,
9
+ startFirecracker,
10
+ type FirecrackerConfig,
11
+ type VmStartOptions,
12
+ } from './firecracker'
13
+ import { networkState, networkStatusLine } from './network'
14
+ import {
15
+ startVz,
16
+ vzAvailable,
17
+ vzHostReady,
18
+ vzNetworkState,
19
+ vzNetworkStatusLine,
20
+ } from './vz'
21
+ import { ociAvailable, startOci, type OciConfig } from './container'
22
+ import {
23
+ colimaMountFor,
24
+ colimaStatusLine,
25
+ detectColima,
26
+ systemColimaProbe,
27
+ type ColimaState,
28
+ } from './colima'
29
+ import {
30
+ hostUserState,
31
+ startHostUser,
32
+ systemProbe,
33
+ type HostUserState,
34
+ } from './host-user'
35
+ import {
36
+ IsolationRefused,
37
+ LEVELS,
38
+ labelFor,
39
+ resolveIsolation,
40
+ type Level,
41
+ type Resolved,
42
+ } from './policy'
43
+ import {
44
+ ensureVmImage,
45
+ vmImagePaths,
46
+ vmPlatform,
47
+ VmImageError,
48
+ type VmPlatform,
49
+ } from './image'
50
+ import type { Isolation } from './session'
51
+
52
+ export * from './policy'
53
+ export type { Isolation } from './session'
54
+ export { vmImagePaths } from './image'
55
+
56
+ const level = z.enum(LEVELS)
57
+
58
+ /** `<config dir>/isolation.json`: what the owner set once on this host. */
59
+ export const hostConfigSchema = (configDir: string) =>
60
+ z.strictObject({
61
+ floor: level.optional(),
62
+ default: level.optional(),
63
+ vm: z
64
+ .strictObject({
65
+ /** Linux: the bundled one next to mirab, else `firecracker` on PATH. */
66
+ firecracker: z.string().default(() => defaultFirecracker()),
67
+ /** macOS: the signed Virtualization.framework helper: the published
68
+ * one in <config dir>/vm when it was fetched, else `mirab-vz` on PATH
69
+ * (scripts/build-vz-helper.sh). */
70
+ vz: z
71
+ .string()
72
+ .default(() =>
73
+ existsSync(vmImagePaths(configDir).vz)
74
+ ? vmImagePaths(configDir).vz
75
+ : 'mirab-vz',
76
+ ),
77
+ kernel: z.string().default(vmImagePaths(configDir).kernel),
78
+ rootfs: z.string().default(vmImagePaths(configDir).rootfs),
79
+ memoryMiB: z.number().int().min(128).default(1024),
80
+ vcpus: z.number().int().min(1).default(2),
81
+ egress: z.enum(['auto', 'block', 'allow']).default('auto'),
82
+ })
83
+ .optional(),
84
+ container: z
85
+ .strictObject({
86
+ engine: z.string().default('docker'),
87
+ image: z.string().default('ubuntu:24.04'),
88
+ agent: z.string(),
89
+ memory: z.string().default('2g'),
90
+ cpus: z.string().default('2'),
91
+ pids: z.number().int().default(512),
92
+ egress: z.enum(['auto', 'block', 'allow']).default('auto'),
93
+ })
94
+ .optional(),
95
+ /** `host` level: the dedicated OS user scripts/host-user-setup.sh created. */
96
+ host: z
97
+ .strictObject({
98
+ user: z.string().regex(/^mirab-[a-z0-9-]{1,30}$/),
99
+ })
100
+ .optional(),
101
+ })
102
+ export type HostConfig = z.infer<ReturnType<typeof hostConfigSchema>>
103
+ export type VmConfig = NonNullable<HostConfig['vm']>
104
+
105
+ /** The `vm` level's backend: Virtualization.framework on macOS, Firecracker elsewhere. */
106
+ export const vmBackend = (platform: NodeJS.Platform = process.platform) =>
107
+ platform === 'darwin' ? 'vz' : 'firecracker'
108
+
109
+ export function vmAvailable(
110
+ vm: VmConfig,
111
+ platform: NodeJS.Platform = process.platform,
112
+ ) {
113
+ return vmBackend(platform) === 'vz'
114
+ ? vzAvailable({ helper: vm.vz, kernel: vm.kernel, rootfs: vm.rootfs })
115
+ : firecrackerAvailable(vm)
116
+ }
117
+
118
+ export type { VmStartOptions } from './firecracker'
119
+
120
+ /** Boot one microVM with `work` at /workspace (null: no work directory and
121
+ * nothing of the host's inside), on this platform's backend. */
122
+ export function startVm(
123
+ vm: VmConfig,
124
+ work: string | null,
125
+ vmDir: string,
126
+ log?: (line: string) => void,
127
+ platform: NodeJS.Platform = process.platform,
128
+ opts: VmStartOptions = {},
129
+ ): Promise<Isolation> {
130
+ if (vmBackend(platform) === 'vz')
131
+ return startVz(
132
+ { ...vm, helper: vm.vz, port: 9002 },
133
+ work,
134
+ vmDir,
135
+ log,
136
+ undefined,
137
+ opts,
138
+ )
139
+ const cfg: FirecrackerConfig = { ...vm, port: 9002 }
140
+ return startFirecracker(cfg, work, vmDir, log, opts)
141
+ }
142
+
143
+ export async function loadHostConfig(configDir: string): Promise<HostConfig> {
144
+ try {
145
+ return hostConfigSchema(configDir).parse(
146
+ JSON.parse(await readFile(join(configDir, 'isolation.json'), 'utf8')),
147
+ )
148
+ } catch (e) {
149
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {}
150
+ throw new Error(`isolation.json: ${(e as Error).message}`)
151
+ }
152
+ }
153
+
154
+ /** The level a run asks for, before availability: what decides whether the
155
+ * published VM image is worth fetching. */
156
+ export const requestedLevel = (host: HostConfig, req: Request) =>
157
+ req.flag ?? req.job ?? req.profile ?? host.default
158
+
159
+ export interface PreparedVm {
160
+ host: HostConfig
161
+ /** Why the published VM image could not be fetched; `vm` then falls to the next tier. */
162
+ vmUnavailable?: string
163
+ }
164
+
165
+ /**
166
+ * Load isolation.json and, when this run asks for `vm` and the owner did not
167
+ * point it at their own kernel and rootfs, make sure the published guest
168
+ * image for this mirab version is in <config dir>/vm (downloaded once, then
169
+ * used offline) and describe `vm` with it.
170
+ */
171
+ export async function prepareVm(
172
+ configDir: string,
173
+ o: {
174
+ want: boolean
175
+ log?: (line: string) => void
176
+ base?: string
177
+ /** The mirab release whose image to use (default: this copy's own). A
178
+ * package that embeds mirab from git names the canary of that commit. */
179
+ version?: string
180
+ platform?: VmPlatform
181
+ /** The backend's own prerequisites (Firecracker, /dev/kvm) are present. */
182
+ backendReady?: (host: HostConfig) => boolean
183
+ },
184
+ ): Promise<PreparedVm> {
185
+ const host = await loadHostConfig(configDir)
186
+ if (!o.want) return { host }
187
+ const paths = vmImagePaths(configDir)
188
+ if (
189
+ host.vm &&
190
+ (resolve(host.vm.kernel) !== paths.kernel ||
191
+ resolve(host.vm.rootfs) !== paths.rootfs)
192
+ )
193
+ return { host } // the owner's own image: theirs to manage
194
+ const platform = o.platform ?? vmPlatform()
195
+ const ready =
196
+ o.backendReady ??
197
+ ((h: HostConfig) =>
198
+ vmBackend() === 'vz'
199
+ ? vzHostReady()
200
+ : firecrackerHostReady(h.vm?.firecracker ?? defaultFirecracker()))
201
+ // No Firecracker, /dev/kvm or hypervisor: an image would not help; vmAvailable says what is missing.
202
+ if (platform && !ready(host)) return { host }
203
+ try {
204
+ await ensureVmImage({
205
+ configDir,
206
+ log: o.log,
207
+ base: o.base,
208
+ version: o.version,
209
+ platform,
210
+ })
211
+ } catch (e) {
212
+ if (!(e instanceof VmImageError)) throw e
213
+ return { host, vmUnavailable: e.message }
214
+ }
215
+ // Load again after the download: the vz helper default looks in <config dir>/vm.
216
+ const fresh = await loadHostConfig(configDir)
217
+ return {
218
+ host: {
219
+ ...fresh,
220
+ vm: fresh.vm ?? hostConfigSchema(configDir).parse({ vm: {} }).vm,
221
+ },
222
+ }
223
+ }
224
+
225
+ /** What this machine can do; tests pass their own. */
226
+ export interface Probes {
227
+ platform: NodeJS.Platform
228
+ /** The `vm` backend (vmBackend) works with this config. */
229
+ vm(vm: VmConfig): boolean
230
+ oci(engine: string, agent: string, endpoint?: string): boolean
231
+ colima(): ColimaState
232
+ hostUser(user: string): HostUserState
233
+ }
234
+
235
+ export const systemProbes = (): Probes => ({
236
+ platform: process.platform,
237
+ vm: (v) => vmAvailable(v),
238
+ oci: ociAvailable,
239
+ colima: () => detectColima(systemColimaProbe()),
240
+ hostUser: (user) => hostUserState(user, systemProbe()),
241
+ })
242
+
243
+ export interface Detected {
244
+ levels: Level[]
245
+ /** One line per backend that is configured or detected, for `mirab status`. */
246
+ notes: string[]
247
+ colima?: ColimaState
248
+ hostUser?: HostUserState
249
+ }
250
+
251
+ export function detect(host: HostConfig, p: Probes = systemProbes()): Detected {
252
+ const levels: Level[] = [],
253
+ notes: string[] = []
254
+ if (host.vm && p.vm(host.vm)) levels.push('vm')
255
+ let colima: ColimaState | undefined
256
+ if (p.platform === 'darwin') {
257
+ // macOS has no local OCI kernel: containers run inside Colima's VM.
258
+ colima = p.colima()
259
+ notes.push(colimaStatusLine(colima))
260
+ if (host.container && colima.running) {
261
+ if (!colimaMountFor(host.container.agent, colima.mounts, false))
262
+ notes.push(
263
+ `container: agent ${host.container.agent} is outside Colima's mounts`,
264
+ )
265
+ else if (
266
+ p.oci(host.container.engine, host.container.agent, colima.socket)
267
+ )
268
+ levels.push('container')
269
+ }
270
+ } else if (
271
+ host.container &&
272
+ p.oci(host.container.engine, host.container.agent)
273
+ )
274
+ levels.push('container')
275
+ let hostUser: HostUserState | undefined
276
+ if (host.host) {
277
+ hostUser = p.hostUser(host.host.user)
278
+ notes.push(
279
+ hostUser.ok
280
+ ? `host tier: configured, steps run as ${host.host.user} (uid ${hostUser.marker.uid}), LAN ${hostUser.marker.network === 'lan-denied' ? 'denied' : 'not filtered'}`
281
+ : `host tier: not configured (${hostUser.reason}); ${hostUser.fix}`,
282
+ )
283
+ if (hostUser.ok) levels.push('host')
284
+ }
285
+ levels.push('none')
286
+ return { levels, notes, colima, hostUser }
287
+ }
288
+
289
+ export function availableLevels(host: HostConfig, p?: Probes): Level[] {
290
+ return detect(host, p).levels
291
+ }
292
+
293
+ export interface Request {
294
+ flag?: Level
295
+ job?: Level
296
+ profile?: Level
297
+ }
298
+
299
+ /**
300
+ * Resolve a run's isolation. With `vmUnavailable` (the published VM image
301
+ * could not be fetched), a request for `vm` falls to the strongest other
302
+ * tier the floor allows, never to `none`, and the result says so.
303
+ */
304
+ export function resolveForHost(
305
+ host: HostConfig,
306
+ req: Request,
307
+ p?: Probes,
308
+ vmUnavailable?: string,
309
+ ): Resolved {
310
+ const available = availableLevels(host, p)
311
+ const input = {
312
+ ...req,
313
+ hostDefault: host.default,
314
+ floor: host.floor,
315
+ available,
316
+ }
317
+ try {
318
+ return resolveIsolation(input)
319
+ } catch (e) {
320
+ if (
321
+ !vmUnavailable ||
322
+ !(e instanceof IsolationRefused) ||
323
+ requestedLevel(host, req) !== 'vm' ||
324
+ available.includes('vm')
325
+ )
326
+ throw e
327
+ let next: Resolved
328
+ try {
329
+ next = resolveIsolation({ floor: host.floor, available })
330
+ } catch (e2) {
331
+ throw new IsolationRefused(
332
+ `isolation "vm" is unavailable: ${vmUnavailable}; and no other tier can take it: ${(e2 as Error).message}`,
333
+ )
334
+ }
335
+ const source = resolveIsolation({
336
+ ...input,
337
+ available: [...available, 'vm'],
338
+ }).source
339
+ return {
340
+ level: next.level,
341
+ source,
342
+ fallback: `vm image unavailable, fell back to ${next.level}: ${vmUnavailable}`,
343
+ }
344
+ }
345
+ }
346
+
347
+ const BACKEND: Record<Exclude<Level, 'vm'>, string> = {
348
+ container: 'oci',
349
+ host: 'dedicated OS user',
350
+ none: 'none',
351
+ }
352
+
353
+ export const backendName = (level: Level, platform = process.platform) =>
354
+ level === 'vm'
355
+ ? vmBackend(platform)
356
+ : level === 'container' && platform === 'darwin'
357
+ ? 'oci in Colima VM'
358
+ : BACKEND[level]
359
+
360
+ /** One line for `mirab status` and job logs. */
361
+ export function describe(resolved: Resolved, backend?: string) {
362
+ const line = `isolation: ${labelFor(resolved.level, backend ?? backendName(resolved.level))}, requested by ${resolved.source}`
363
+ return resolved.fallback ? `${line} (${resolved.fallback})` : line
364
+ }
365
+
366
+ export async function startIsolation(
367
+ level: Level,
368
+ host: HostConfig,
369
+ work: string,
370
+ vmDir: string,
371
+ log?: (line: string) => void,
372
+ p: Probes = systemProbes(),
373
+ ): Promise<Isolation | null> {
374
+ if (level === 'vm') return startVm(host.vm!, work, vmDir, log, p.platform)
375
+ if (level === 'container') {
376
+ const c: OciConfig = host.container!
377
+ if (p.platform !== 'darwin') return startOci(c, work, log)
378
+ const colima = p.colima()
379
+ if (!colima.running)
380
+ throw new Error(`container: ${colima.reason}; ${colima.fix}`)
381
+ if (!colimaMountFor(work, colima.mounts, true))
382
+ throw new Error(
383
+ `container: ${work} is not inside a writable Colima mount (${colima.mounts.map((m) => m.location).join(', ')}). ` +
384
+ `Needs Sami: add it under mounts in ~/.colima/default/colima.yaml, then colima restart`,
385
+ )
386
+ return startOci(
387
+ { ...c, host: colima.socket, where: `Colima ${colima.vmType} VM` },
388
+ work,
389
+ log,
390
+ )
391
+ }
392
+ if (level === 'host') {
393
+ const s = p.hostUser(host.host!.user)
394
+ if (!s.ok) throw new Error(`host tier: ${s.reason}; ${s.fix}`)
395
+ return startHostUser(s.marker, work, log)
396
+ }
397
+ return null // none: the runner keeps its existing local execution
398
+ }
399
+
400
+ export function statusLines(
401
+ host: HostConfig,
402
+ requested: Request = {},
403
+ p: Probes = systemProbes(),
404
+ ) {
405
+ const found = detect(host, p),
406
+ available = found.levels
407
+ const lines = [
408
+ ...(host.vm
409
+ ? [
410
+ vmBackend(p.platform) === 'vz'
411
+ ? vzNetworkStatusLine(host.vm.egress, vzNetworkState())
412
+ : networkStatusLine(host.vm.egress, networkState()),
413
+ ]
414
+ : []),
415
+ ...found.notes,
416
+ `isolation backends available: ${available.filter((l) => l !== 'none').join(', ') || 'none'}`,
417
+ `isolation floor: ${host.floor ?? 'none set'}`,
418
+ `isolation host default: ${host.default ?? 'none set'}`,
419
+ ]
420
+ try {
421
+ const r = resolveIsolation({
422
+ ...requested,
423
+ hostDefault: host.default,
424
+ floor: host.floor,
425
+ available,
426
+ })
427
+ lines.push(describe(r, backendName(r.level, p.platform)))
428
+ } catch (e) {
429
+ lines.push(`isolation: refused (${(e as Error).message})`)
430
+ }
431
+ return lines
432
+ }
433
+
434
+ /** Called once before a session starts: one that cannot isolate does not start. */
435
+ export function assertCanIsolate(
436
+ host: HostConfig,
437
+ flag?: Level,
438
+ p?: Probes,
439
+ vmUnavailable?: string,
440
+ ) {
441
+ try {
442
+ return resolveForHost(host, { flag }, p, vmUnavailable)
443
+ } catch (e) {
444
+ const message = (e as Error).message
445
+ // Only the "nothing available" case needs the how-to-fix text; a floor or
446
+ // availability refusal already says exactly what is wrong.
447
+ throw new Error(
448
+ message.includes('no isolation backend')
449
+ ? `${message}. This runner would run workflow steps with no isolation. ` +
450
+ `Fix it by describing a microVM, container or host-user backend in <config dir>/isolation.json ` +
451
+ `(see FINDINGS.md), or start with --isolation none to run unisolated on purpose.`
452
+ : message,
453
+ )
454
+ }
455
+ }
456
+
457
+ /** The VM this machine runs (the published image, fetched on first use), or why not. */
458
+ export async function requireVm(
459
+ configDir: string,
460
+ log?: (line: string) => void,
461
+ version?: string,
462
+ ): Promise<VmConfig> {
463
+ // Fetched on first use; later runs find it installed.
464
+ const { host, vmUnavailable } = await prepareVm(configDir, {
465
+ want: true,
466
+ log,
467
+ version,
468
+ })
469
+ if (vmUnavailable) throw new Error(vmUnavailable)
470
+ if (!host.vm || !vmAvailable(host.vm))
471
+ throw new Error(
472
+ vmBackend() === 'vz'
473
+ ? 'A VM needs a working vz setup: the published guest image and mirab-vz helper (fetched on first use), or `vm` (kernel, rootfs, vz helper) in <config dir>/isolation.json (docs/VM.md)'
474
+ : 'A VM needs a working Firecracker setup: Firecracker (shipped next to mirab in the npm package; else on PATH or `vm.firecracker` in <config dir>/isolation.json), mke2fs, and an accessible /dev/kvm (docs/VM.md)',
475
+ )
476
+ return host.vm
477
+ }