mirab 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -2
  3. package/package.json +58 -4
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
package/src/images.ts ADDED
@@ -0,0 +1,498 @@
1
+ // Agent images (docs/architecture.md section 5b): an agent's `packages`,
2
+ // installed with apt on the release's base image, built once and cached under
3
+ // the hash of the base and the package list; updated by booting the image and
4
+ // running apt again. Both happen in a mirab VM whose only way out is the
5
+ // station, with only Ubuntu's mirrors on its whitelist, so vz and Firecracker
6
+ // build and update images the same way: each VM has its own copy of the root
7
+ // disk, and the one apt ran in is the one kept.
8
+ import { createHash } from 'node:crypto'
9
+ import {
10
+ link,
11
+ mkdir,
12
+ mkdtemp,
13
+ open,
14
+ readdir,
15
+ readFile,
16
+ realpath,
17
+ rename,
18
+ rm,
19
+ stat,
20
+ utimes,
21
+ writeFile,
22
+ } from 'node:fs/promises'
23
+ import { join } from 'node:path'
24
+ import { startVm, type VmConfig } from './isolation'
25
+ import type { Isolation } from './isolation/session'
26
+ import { EGRESS_GUEST_PORT, EGRESS_HOST_PORT } from './isolation/vz'
27
+ import { startEgress } from 'rixse'
28
+
29
+ /** Where apt goes, over HTTPS: the station lets nothing else out. */
30
+ export const UBUNTU_MIRRORS = [
31
+ 'archive.ubuntu.com',
32
+ 'security.ubuntu.com',
33
+ 'ports.ubuntu.com',
34
+ ]
35
+ /** How long one apt step may take (a big install included). */
36
+ const APT_TIMEOUT_MS = 30 * 60_000
37
+ const ROOT_ENV = {
38
+ PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
39
+ HOME: '/root',
40
+ DEBIAN_FRONTEND: 'noninteractive',
41
+ LANG: 'C.UTF-8',
42
+ }
43
+ /** In the VM: apt's mirrors over HTTPS, and the station as its proxy. */
44
+ const APT_SETUP = [
45
+ "sed -i 's|http://|https://|g' /etc/apt/sources.list.d/ubuntu.sources",
46
+ `printf 'Acquire::http::Proxy "http://127.0.0.1:${EGRESS_GUEST_PORT}";\\nAcquire::https::Proxy "http://127.0.0.1:${EGRESS_GUEST_PORT}";\\n' > /etc/apt/apt.conf.d/90mirab-station`,
47
+ ].join(' && ')
48
+
49
+ export interface ImageMeta {
50
+ key: string
51
+ packages: string[]
52
+ base: string
53
+ builtAt: string
54
+ updatedAt: string
55
+ /** What the last update upgraded. */
56
+ upgraded: string[]
57
+ }
58
+
59
+ export const imagesDir = (configDir: string) => join(configDir, 'images')
60
+
61
+ /** The base image: where it is, its size, when it was written, and a hash of
62
+ * its first 4 MB (the filesystem's superblock and early blocks), without
63
+ * reading all 3 GB. A new base is a new key. */
64
+ export async function baseId(rootfs: string): Promise<string> {
65
+ const path = await realpath(rootfs)
66
+ const st = await stat(path)
67
+ const handle = await open(path, 'r')
68
+ try {
69
+ const head = Buffer.alloc(Math.min(st.size, 4 * 1024 * 1024))
70
+ await handle.read(head, 0, head.length, 0)
71
+ const sum = createHash('sha256').update(head).digest('hex').slice(0, 16)
72
+ return `${path}:${st.size}:${Math.floor(st.mtimeMs)}:${sum}`
73
+ } finally {
74
+ await handle.close()
75
+ }
76
+ }
77
+
78
+ /** A cache key for any build recipe: the base plus the recipe as canonical
79
+ * JSON (object keys sorted at every level), so equal recipes share a key. */
80
+ export function recipeKey(base: string, recipe: unknown): string {
81
+ const canon = (v: unknown): unknown =>
82
+ Array.isArray(v)
83
+ ? v.map(canon)
84
+ : v && typeof v === 'object'
85
+ ? Object.fromEntries(
86
+ Object.keys(v as object)
87
+ .sort()
88
+ .map((k) => [k, canon((v as Record<string, unknown>)[k])]),
89
+ )
90
+ : v
91
+ return createHash('sha256')
92
+ .update(JSON.stringify({ base, recipe: canon(recipe) }))
93
+ .digest('hex')
94
+ .slice(0, 32)
95
+ }
96
+
97
+ /** The cache key: the base and the package list, order and repeats aside. */
98
+ export function imageKey(base: string, packages: string[]): string {
99
+ const list = [...new Set(packages)].sort()
100
+ return createHash('sha256')
101
+ .update(JSON.stringify({ base, packages: list }))
102
+ .digest('hex')
103
+ .slice(0, 32)
104
+ }
105
+
106
+ export interface CachedImage {
107
+ rootfs: string
108
+ meta: ImageMeta
109
+ }
110
+
111
+ export async function cachedImage(
112
+ configDir: string,
113
+ key: string,
114
+ base?: string,
115
+ ): Promise<CachedImage | undefined> {
116
+ const dir = join(imagesDir(configDir), key)
117
+ try {
118
+ const meta = JSON.parse(
119
+ await readFile(join(dir, 'meta.json'), 'utf8'),
120
+ ) as ImageMeta
121
+ // Built on another base (a key collision, or a base changed in place): a miss.
122
+ if (base !== undefined && meta.base !== base) return undefined
123
+ const rootfs = join(dir, 'rootfs.ext4')
124
+ await stat(rootfs)
125
+ return { rootfs, meta }
126
+ } catch {
127
+ return undefined
128
+ }
129
+ }
130
+
131
+ /** In a build VM: tools other than apt (curl, rustup) reach the station too. */
132
+ export const BUILD_ENV = {
133
+ ...ROOT_ENV,
134
+ HTTP_PROXY: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
135
+ HTTPS_PROXY: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
136
+ http_proxy: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
137
+ https_proxy: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
138
+ NO_PROXY: 'localhost,127.0.0.1',
139
+ }
140
+
141
+ /** Runs one command as root in the VM; a failure carries the end of its output. */
142
+ export async function run(
143
+ iso: Pick<Isolation, 'exec'>,
144
+ argv: string[],
145
+ log?: (l: string) => void,
146
+ env: Record<string, string> = BUILD_ENV,
147
+ ) {
148
+ const lines: string[] = []
149
+ const code = await iso.exec(argv, {
150
+ cwd: '/',
151
+ env,
152
+ timeoutMs: APT_TIMEOUT_MS,
153
+ signal: new AbortController().signal,
154
+ onLine: async (line) => {
155
+ lines.push(line)
156
+ if (lines.length > 400) lines.shift()
157
+ log?.(line)
158
+ },
159
+ })
160
+ if (code !== 0)
161
+ throw new Error(
162
+ `${argv.slice(0, 2).join(' ')} failed (${code}): ${lines.slice(-8).join(' | ')}`,
163
+ )
164
+ return lines
165
+ }
166
+
167
+ /** A build VM on `rootfs` whose only way out is the station, to the hosts in
168
+ * `allow` (default: Ubuntu's mirrors, for apt). `rootGiB` grows its root
169
+ * disk first; the build then grows the filesystem (resize2fs). */
170
+ export async function bootForBuild(o: {
171
+ configDir: string
172
+ vm: VmConfig
173
+ rootfs: string
174
+ key: string
175
+ allow?: string[]
176
+ rootGiB?: number
177
+ log?: (line: string) => void
178
+ }): Promise<{ iso: Isolation; cleanup: () => Promise<void> }> {
179
+ await mkdir(imagesDir(o.configDir), { recursive: true, mode: 0o700 })
180
+ const scratch = await mkdtemp(join(imagesDir(o.configDir), '.vm-'))
181
+ const work = join(scratch, 'work')
182
+ await mkdir(work)
183
+ const iso = await startVm(
184
+ { ...o.vm, rootfs: o.rootfs, egress: 'block' },
185
+ work,
186
+ join(scratch, 'vm'),
187
+ o.log,
188
+ undefined,
189
+ { rootGiB: o.rootGiB },
190
+ )
191
+ const cleanup = () => rm(scratch, { recursive: true, force: true })
192
+ try {
193
+ await iso.onGuestConnect!(EGRESS_HOST_PORT, (socketPath) =>
194
+ startEgress({
195
+ socketPath,
196
+ zone: `image-${o.key.slice(0, 12)}`,
197
+ allow: o.allow ?? UBUNTU_MIRRORS,
198
+ routes: {},
199
+ auditLog: join(o.configDir, 'egress.log'),
200
+ }),
201
+ )
202
+ await run(iso, ['sh', '-c', APT_SETUP])
203
+ return { iso, cleanup }
204
+ } catch (e) {
205
+ await iso.close().catch(() => {})
206
+ await cleanup()
207
+ throw e
208
+ }
209
+ }
210
+
211
+ /** Grows the running guest's root filesystem to fill its (grown) disk, while
212
+ * mounted. resize2fs can't: the kernel mounts the root as /dev/root, so it
213
+ * doesn't see /dev/vda as mounted and refuses an exclusive open. This is the
214
+ * ioctl resize2fs itself uses online (EXT4_IOC_RESIZE_FS). */
215
+ export const GROW_ROOT_FS =
216
+ "python3 -c \"import fcntl,os,struct; f=os.open('/',os.O_RDONLY); " +
217
+ "n=int(open('/sys/block/vda/size').read())*512//os.statvfs('/').f_bsize; " +
218
+ "fcntl.ioctl(f,0x40086610,struct.pack('Q',n))\""
219
+
220
+ /** Stops the VM and keeps its root disk as the image in `dir`, moving any
221
+ * image there now aside as the previous one. */
222
+ export async function keep(
223
+ iso: Pick<Isolation, 'exec' | 'close'>,
224
+ dir: string,
225
+ meta: object,
226
+ opts: { previous?: boolean } = {},
227
+ ): Promise<string> {
228
+ // The station's proxy is only for building; sessions use their own station.
229
+ await run(iso, [
230
+ 'sh',
231
+ '-c',
232
+ 'rm -f /etc/apt/apt.conf.d/90mirab-station && sync',
233
+ ])
234
+ const tag = `${process.pid}.${Date.now()}`
235
+ const part = join(dir, `rootfs.ext4.${tag}`)
236
+ try {
237
+ await iso.close({ keepRoot: part })
238
+ } catch (e) {
239
+ await rm(part, { force: true })
240
+ throw e
241
+ }
242
+ // The new image is whole before anything moves: the old one is kept by a
243
+ // second name, then replaced in one rename, so there is always an image.
244
+ const rootfs = join(dir, 'rootfs.ext4')
245
+ const previous = join(dir, 'previous.rootfs.ext4')
246
+ const metaPart = join(dir, `meta.json.${tag}`)
247
+ await writeFile(metaPart, JSON.stringify(meta, null, 2) + '\n')
248
+ await rm(previous, { force: true })
249
+ // Agent images keep the one before; rebuildable images (runners) don't.
250
+ if (opts.previous !== false) await link(rootfs, previous).catch(() => {})
251
+ await rename(part, rootfs)
252
+ await rename(metaPart, join(dir, 'meta.json'))
253
+ return rootfs
254
+ }
255
+
256
+ /** The agent's image: from the cache, or built now. */
257
+ export async function agentImage(o: {
258
+ configDir: string
259
+ vm: VmConfig
260
+ packages: string[]
261
+ log?: (line: string) => void
262
+ }): Promise<string> {
263
+ const base = await baseId(o.vm.rootfs)
264
+ const key = imageKey(base, o.packages)
265
+ const hit = await cachedImage(o.configDir, key, base)
266
+ if (hit) {
267
+ // When it was last used, for `mirab image prune`.
268
+ const now = new Date()
269
+ await utimes(
270
+ join(imagesDir(o.configDir), key, 'meta.json'),
271
+ now,
272
+ now,
273
+ ).catch(() => {})
274
+ return hit.rootfs
275
+ }
276
+ const packages = [...new Set(o.packages)].sort()
277
+ o.log?.(`building the agent's image: ${packages.join(' ')}`)
278
+ const dir = join(imagesDir(o.configDir), key)
279
+ await mkdir(dir, { recursive: true, mode: 0o700 })
280
+ const { iso, cleanup } = await bootForBuild({
281
+ ...o,
282
+ rootfs: o.vm.rootfs,
283
+ key,
284
+ })
285
+ try {
286
+ await run(iso, ['apt-get', 'update'])
287
+ // Names only (the manifest's schema): apt takes them as arguments, no shell.
288
+ await run(
289
+ iso,
290
+ ['apt-get', 'install', '-y', '--no-install-recommends', ...packages],
291
+ o.log,
292
+ )
293
+ await run(iso, ['apt-get', 'clean'])
294
+ const now = new Date().toISOString()
295
+ return await keep(iso, dir, {
296
+ key,
297
+ packages,
298
+ base,
299
+ builtAt: now,
300
+ updatedAt: now,
301
+ upgraded: [],
302
+ })
303
+ } catch (e) {
304
+ await iso.close().catch(() => {})
305
+ throw e
306
+ } finally {
307
+ await cleanup()
308
+ }
309
+ }
310
+
311
+ /** The packages a dry-run `apt-get -s upgrade` would upgrade. */
312
+ export function upgradesIn(lines: string[]): string[] {
313
+ return lines
314
+ .map((l) => /^Inst (\S+)/.exec(l)?.[1])
315
+ .filter((name): name is string => !!name)
316
+ }
317
+
318
+ /** Boots the agent's image and upgrades it in place when apt has anything
319
+ * newer; the image before stays as the previous one. */
320
+ export async function updateImage(o: {
321
+ configDir: string
322
+ vm: VmConfig
323
+ packages: string[]
324
+ log?: (line: string) => void
325
+ }): Promise<{ rootfs: string; upgraded: string[] }> {
326
+ const base = await baseId(o.vm.rootfs)
327
+ const key = imageKey(base, o.packages)
328
+ const hit = await cachedImage(o.configDir, key, base)
329
+ if (!hit) return { rootfs: await agentImage(o), upgraded: [] }
330
+ const dir = join(imagesDir(o.configDir), key)
331
+ const { iso, cleanup } = await bootForBuild({ ...o, rootfs: hit.rootfs, key })
332
+ try {
333
+ await run(iso, ['apt-get', 'update'])
334
+ const upgraded = upgradesIn(await run(iso, ['apt-get', '-s', 'upgrade']))
335
+ if (!upgraded.length) {
336
+ await iso.close()
337
+ const now = new Date()
338
+ await utimes(join(dir, 'meta.json'), now, now).catch(() => {})
339
+ return { rootfs: hit.rootfs, upgraded }
340
+ }
341
+ o.log?.(`upgrading: ${upgraded.join(' ')}`)
342
+ await run(
343
+ iso,
344
+ ['apt-get', 'upgrade', '-y', '-o', 'Dpkg::Options::=--force-confold'],
345
+ o.log,
346
+ )
347
+ await run(iso, ['apt-get', 'clean'])
348
+ const rootfs = await keep(iso, dir, {
349
+ ...hit.meta,
350
+ updatedAt: new Date().toISOString(),
351
+ upgraded,
352
+ })
353
+ return { rootfs, upgraded }
354
+ } catch (e) {
355
+ await iso.close().catch(() => {})
356
+ throw e
357
+ } finally {
358
+ await cleanup()
359
+ }
360
+ }
361
+
362
+ export interface ListedImage {
363
+ key: string
364
+ packages: string[]
365
+ /** Built on the base this mirab boots now; otherwise it is never used again. */
366
+ current: boolean
367
+ /** When it was built, updated or booted last. */
368
+ usedAt: Date
369
+ bytes: number
370
+ }
371
+
372
+ /** Bytes on disk (images are sparse), not length: an image and the copy an update keeps (`previous.rootfs.ext4`), counted
373
+ * once where they are still the same file. */
374
+ async function imageBytes(dir: string) {
375
+ const now = await stat(join(dir, 'rootfs.ext4'))
376
+ const before = await stat(join(dir, 'previous.rootfs.ext4')).catch(
377
+ () => undefined,
378
+ )
379
+ return (
380
+ now.blocks * 512 +
381
+ (before && before.ino !== now.ino ? before.blocks * 512 : 0)
382
+ )
383
+ }
384
+
385
+ const DAY_MS = 86_400_000
386
+ /** Used this recently, an image is never removed: a `mirab dev` that just
387
+ * found it in the cache may be about to boot it. */
388
+ const IN_USE_MS = 60 * 60 * 1000
389
+
390
+ /** Every cached agent image, newest use first. */
391
+ export async function listImages(
392
+ configDir: string,
393
+ currentBase: string,
394
+ ): Promise<ListedImage[]> {
395
+ let names: string[] = []
396
+ try {
397
+ names = await readdir(imagesDir(configDir))
398
+ } catch {
399
+ return []
400
+ }
401
+ const out: ListedImage[] = []
402
+ for (const key of names) {
403
+ if (!/^[0-9a-f]{32}$/.test(key)) continue
404
+ const dir = join(imagesDir(configDir), key)
405
+ try {
406
+ const hit = await cachedImage(configDir, key)
407
+ if (!hit) continue
408
+ const meta = await stat(join(dir, 'meta.json'))
409
+ out.push({
410
+ key,
411
+ packages: hit.meta.packages,
412
+ current: hit.meta.base === currentBase,
413
+ usedAt: meta.mtime,
414
+ bytes: await imageBytes(dir),
415
+ })
416
+ } catch {
417
+ // Gone, or going, as it was read: not one to list.
418
+ }
419
+ }
420
+ return out.sort((a, b) => b.usedAt.getTime() - a.usedAt.getTime())
421
+ }
422
+
423
+ /**
424
+ * What `mirab image prune` removes: images built on a base this mirab no
425
+ * longer boots (after an upgrade they are never used again), images unused
426
+ * for `days` (at least one), and what a build left behind a day or more ago:
427
+ * scratch and an image directory that never got as far as its meta.json. An
428
+ * image used in the last hour always stays, and a VM boots from its own copy,
429
+ * so a running one is never touched. One that can't be removed is reported,
430
+ * and the rest go on.
431
+ */
432
+ export async function pruneImages(o: {
433
+ configDir: string
434
+ currentBase: string
435
+ days: number
436
+ dryRun?: boolean
437
+ now?: Date
438
+ }): Promise<{
439
+ removed: { key: string; why: string; bytes: number }[]
440
+ failed: { key: string; error: string }[]
441
+ }> {
442
+ if (!Number.isInteger(o.days) || o.days < 1)
443
+ throw new Error('days is a whole number, at least 1')
444
+ const now = (o.now ?? new Date()).getTime()
445
+ const removed: { key: string; why: string; bytes: number }[] = []
446
+ const failed: { key: string; error: string }[] = []
447
+ const remove = async (key: string, why: string, bytes: number) => {
448
+ try {
449
+ if (!o.dryRun)
450
+ await rm(join(imagesDir(o.configDir), key), {
451
+ recursive: true,
452
+ force: true,
453
+ })
454
+ removed.push({ key, why, bytes })
455
+ } catch (e) {
456
+ failed.push({ key, error: (e as Error).message })
457
+ }
458
+ }
459
+ const images = await listImages(o.configDir, o.currentBase)
460
+ for (const image of images) {
461
+ // Looked at again just before it goes: a use since the list keeps it.
462
+ const meta = await stat(
463
+ join(imagesDir(o.configDir), image.key, 'meta.json'),
464
+ ).catch(() => undefined)
465
+ if (!meta || now - meta.mtimeMs < IN_USE_MS) continue
466
+ const idle = (now - meta.mtimeMs) / DAY_MS
467
+ const why = !image.current
468
+ ? 'built on an older base'
469
+ : idle >= o.days
470
+ ? `unused for ${Math.floor(idle)} days`
471
+ : undefined
472
+ if (why) await remove(image.key, why, image.bytes)
473
+ }
474
+ // What a build left: scratch, and an image's directory that never got its
475
+ // meta.json; only once a day old, so a build under way keeps its own.
476
+ const listed = new Set(images.map((i) => i.key))
477
+ let names: string[] = []
478
+ try {
479
+ names = await readdir(imagesDir(o.configDir))
480
+ } catch {}
481
+ // Sorted, so what it says is the same on every filesystem.
482
+ for (const name of names.sort()) {
483
+ const leftover =
484
+ name.startsWith('.vm-') ||
485
+ (/^[0-9a-f]{32}$/.test(name) &&
486
+ !listed.has(name) &&
487
+ !(await stat(join(imagesDir(o.configDir), name, 'meta.json')).catch(
488
+ () => undefined,
489
+ )))
490
+ if (!leftover) continue
491
+ const st = await stat(join(imagesDir(o.configDir), name)).catch(
492
+ () => undefined,
493
+ )
494
+ if (!st || now - st.mtimeMs < DAY_MS) continue
495
+ await remove(name, 'left by a build that stopped', 0)
496
+ }
497
+ return { removed, failed }
498
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ // What agent projects and runner definitions import from mirab.
2
+ export { agentNameProblem, defineAgent, defineTool } from './agent/define'
3
+ export type { Agent, Tool } from './agent/define'
4
+ export { loadManifest, manifestSchema, parseManifest } from './agent/manifest'
5
+ export type { Manifest } from './agent/manifest'
6
+ export {
7
+ defineRunner,
8
+ RUNNER_PLATFORMS,
9
+ RUNNER_SETPRIV,
10
+ RUNNER_USER,
11
+ TOOLSETS,
12
+ } from './runner/define'
13
+ export type {
14
+ Forge,
15
+ RunnerDefinition,
16
+ RunnerFile,
17
+ RunnerInput,
18
+ RunnerPlatform,
19
+ RunnerStep,
20
+ RunnerTicket,
21
+ } from './runner/define'
@@ -0,0 +1,34 @@
1
+ import { accessSync, constants, realpathSync } from 'node:fs'
2
+ import { delimiter, resolve } from 'node:path'
3
+ import { shellQuote } from './spawn-adapters/types'
4
+
5
+ export function mirabInvocation(): string[] {
6
+ return import.meta.url.includes('$bunfs')
7
+ ? [process.execPath]
8
+ : [process.execPath, 'run', new URL('./mirab.ts', import.meta.url).pathname]
9
+ }
10
+
11
+ /** Prefer plain mirab only when PATH selects this exact mirab. */
12
+ export function promptMirab(command: string[], path = process.env.PATH ?? '') {
13
+ const target = command.length === 1 ? command[0] : command.at(-1)!
14
+ for (const dir of path.split(delimiter)) {
15
+ const candidate = resolve(
16
+ dir || '.',
17
+ process.platform === 'win32' ? 'mirab.cmd' : 'mirab',
18
+ )
19
+ let real: string
20
+ try {
21
+ accessSync(candidate, constants.X_OK)
22
+ real = realpathSync(candidate)
23
+ } catch {
24
+ continue
25
+ }
26
+ try {
27
+ if (realpathSync(target) === real) return 'mirab'
28
+ } catch {
29
+ /* A broken launcher still shadows ours. */
30
+ }
31
+ break
32
+ }
33
+ return command.map(shellQuote).join(' ')
34
+ }