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
@@ -0,0 +1,336 @@
1
+ // The published microVM guest image: built by the release workflow for each
2
+ // VM platform, published next to mirab's own release, and fetched here on
3
+ // first VM use into <config dir>/vm/, so `mirab spawn --vm` works without
4
+ // building anything.
5
+ //
6
+ // Layout of one release on mirab-releases.addis.gg (R2 mirab-releases; directories are
7
+ // the release's full version with no "v", scripts/vm-release.ts writes vm/):
8
+ // <release>/vm/manifest.json VmManifest (sha256 of every file)
9
+ // <release>/vm/<platform>/<name>.gz one gzip per role
10
+ // Every file is checked against the manifest's sha256 (compressed and
11
+ // unpacked) before it replaces anything; the manifest itself must name this
12
+ // release and this CLI's guest protocol. At boot the guest agent handshake
13
+ // (session.ts checkGuestProtocol) checks the protocol once more.
14
+ import { createHash } from 'node:crypto'
15
+ import { createWriteStream, existsSync, readFileSync, statSync } from 'node:fs'
16
+ import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
17
+ import { join } from 'node:path'
18
+ import { Readable, Transform } from 'node:stream'
19
+ import { pipeline } from 'node:stream/promises'
20
+ import { createGunzip } from 'node:zlib'
21
+ import { z } from 'zod'
22
+ import pkg from '../../package.json'
23
+ import { GUEST_PROTOCOL } from './session'
24
+
25
+ /** Where mirab's releases and guest images live. */
26
+ export const RELEASES = 'https://mirab-releases.addis.gg'
27
+ export const MIRAB_VERSION: string = pkg.version
28
+
29
+ /** The release this copy of mirab was installed from (`0.1.0-canary-abc1234`):
30
+ * the release workflow stamps release.json into what it ships. From a
31
+ * checkout there is none, and the package version stands in. */
32
+ export const MIRAB_RELEASE: string = (() => {
33
+ try {
34
+ const stamped = JSON.parse(
35
+ readFileSync(join(import.meta.dir, '..', '..', 'release.json'), 'utf8'),
36
+ )
37
+ return typeof stamped.version === 'string' ? stamped.version : pkg.version
38
+ } catch {
39
+ return pkg.version
40
+ }
41
+ })()
42
+
43
+ /** Where the guest kernel, rootfs and (macOS) vz helper live, published or
44
+ * built by `bun run vm:image`; `vm` uses them when isolation.json names no
45
+ * other files. */
46
+ export function vmImagePaths(configDir: string) {
47
+ const dir = join(configDir, 'vm')
48
+ return {
49
+ dir,
50
+ kernel: join(dir, 'vmlinux'),
51
+ rootfs: join(dir, 'rootfs.ext4'),
52
+ vz: join(dir, 'mirab-vz'),
53
+ record: join(dir, 'image.json'),
54
+ }
55
+ }
56
+
57
+ /** The VM platforms a release publishes, and the file each role is built as. */
58
+ export const VM_PLATFORMS = {
59
+ 'linux-x64': { kernel: 'vmlinux', rootfs: 'rootfs.ext4' },
60
+ 'darwin-x64': { kernel: 'bzImage', rootfs: 'rootfs.ext4', vz: 'mirab-vz' },
61
+ 'darwin-arm64': { kernel: 'Image', rootfs: 'rootfs.ext4', vz: 'mirab-vz' },
62
+ } as const
63
+ export type VmPlatform = keyof typeof VM_PLATFORMS
64
+ export type VmRole = 'kernel' | 'rootfs' | 'vz'
65
+
66
+ export const vmPlatform = (
67
+ platform: string = process.platform,
68
+ arch: string = process.arch,
69
+ ): VmPlatform | undefined =>
70
+ `${platform}-${arch}` in VM_PLATFORMS
71
+ ? (`${platform}-${arch}` as VmPlatform)
72
+ : undefined
73
+
74
+ const hex = z.string().regex(/^[0-9a-f]{64}$/)
75
+ const size = z.number().int().nonnegative()
76
+ const entry = z.object({
77
+ /** Relative to the manifest: `<platform>/<name>.gz`. */
78
+ file: z
79
+ .string()
80
+ .regex(/^(linux-x64|darwin-x64|darwin-arm64)\/[A-Za-z0-9._-]+\.gz$/),
81
+ sha256: hex,
82
+ size,
83
+ unpacked: z.object({ sha256: hex, size }),
84
+ })
85
+ export type VmFileEntry = z.infer<typeof entry>
86
+ export const vmManifestSchema = z.object({
87
+ version: z.string(),
88
+ protocol: z.number().int(),
89
+ commit: z.string(),
90
+ platforms: z.object({
91
+ 'linux-x64': z.object({ kernel: entry, rootfs: entry }).optional(),
92
+ 'darwin-x64': z
93
+ .object({ kernel: entry, rootfs: entry, vz: entry })
94
+ .optional(),
95
+ 'darwin-arm64': z
96
+ .object({ kernel: entry, rootfs: entry, vz: entry })
97
+ .optional(),
98
+ }),
99
+ })
100
+ export type VmManifest = z.infer<typeof vmManifestSchema>
101
+
102
+ /** <config dir>/vm/image.json for a published image (`bun run vm:image` writes its own shape). */
103
+ const recordSchema = z.object({
104
+ source: z.literal('release'),
105
+ version: z.string(),
106
+ platform: z.string(),
107
+ protocol: z.number().int(),
108
+ commit: z.string(),
109
+ files: z.record(z.string(), z.object({ sha256: hex, size })),
110
+ })
111
+ type ImageRecord = z.infer<typeof recordSchema>
112
+
113
+ export class VmImageError extends Error {}
114
+
115
+ /** What the next tier hint says when no image could be fetched. */
116
+ export const VM_IMAGE_FALLBACK_HINT =
117
+ 'connect and retry, build the image yourself (docs/VM.md), or use --isolation container|host'
118
+
119
+ function readRecord(configDir: string): ImageRecord | 'local' | undefined {
120
+ const path = vmImagePaths(configDir).record
121
+ if (!existsSync(path)) return undefined
122
+ try {
123
+ const parsed = recordSchema.safeParse(
124
+ JSON.parse(readFileSync(path, 'utf8')),
125
+ )
126
+ return parsed.success ? parsed.data : 'local'
127
+ } catch {
128
+ return 'local'
129
+ }
130
+ }
131
+
132
+ const rolesOf = (platform: VmPlatform) =>
133
+ Object.keys(VM_PLATFORMS[platform]) as VmRole[]
134
+
135
+ /** The published image for this CLI version and platform is installed and intact (cheap: sizes). */
136
+ export function releaseImageInstalled(
137
+ configDir: string,
138
+ version = MIRAB_RELEASE,
139
+ platform = vmPlatform(),
140
+ ): boolean {
141
+ const record = readRecord(configDir)
142
+ if (!platform || !record || record === 'local') return false
143
+ if (
144
+ record.version !== version ||
145
+ record.platform !== platform ||
146
+ record.protocol !== GUEST_PROTOCOL
147
+ )
148
+ return false
149
+ const paths = vmImagePaths(configDir)
150
+ return rolesOf(platform).every((role) => {
151
+ try {
152
+ return statSync(paths[role]).size === record.files[role]?.size
153
+ } catch {
154
+ return false
155
+ }
156
+ })
157
+ }
158
+
159
+ /** `bun run vm:image` (or another hand build) owns <config dir>/vm: never overwrite it. */
160
+ export const localImageInstalled = (configDir: string) =>
161
+ readRecord(configDir) === 'local'
162
+
163
+ export interface EnsureOptions {
164
+ configDir: string
165
+ version?: string
166
+ platform?: VmPlatform
167
+ /** The release root (cli/ on the bucket); tests point it at a fake. */
168
+ base?: string
169
+ log?: (line: string) => void
170
+ }
171
+
172
+ /**
173
+ * Make sure <config dir>/vm holds the published guest image for this mirab
174
+ * version and platform: nothing to do when it is already there (offline is
175
+ * fine), otherwise download, verify and install it. Throws VmImageError with
176
+ * the reason and the fallback when it cannot.
177
+ */
178
+ export async function ensureVmImage(
179
+ o: EnsureOptions,
180
+ ): Promise<'installed' | 'downloaded' | 'local'> {
181
+ const version = o.version ?? MIRAB_RELEASE,
182
+ platform = o.platform ?? vmPlatform(),
183
+ base = o.base ?? RELEASES,
184
+ log = o.log ?? (() => {})
185
+ if (!platform)
186
+ throw new VmImageError(
187
+ `no VM image is published for ${process.platform}-${process.arch} (published: ${Object.keys(VM_PLATFORMS).join(', ')})`,
188
+ )
189
+ if (localImageInstalled(o.configDir)) return 'local'
190
+ if (releaseImageInstalled(o.configDir, version, platform)) return 'installed'
191
+
192
+ const url = `${base}/${version}/vm/manifest.json`
193
+ const offline = (why: string) =>
194
+ new VmImageError(
195
+ `could not fetch the VM image for mirab ${version} (${platform}) from ${url}: ${why}; ${VM_IMAGE_FALLBACK_HINT}`,
196
+ )
197
+ let res: Response
198
+ try {
199
+ res = await fetch(url)
200
+ } catch (e) {
201
+ throw offline((e as Error).message)
202
+ }
203
+ if (!res.ok)
204
+ throw offline(
205
+ res.status === 404
206
+ ? `HTTP 404, no image was published for this version`
207
+ : `HTTP ${res.status}`,
208
+ )
209
+ let manifest: VmManifest
210
+ try {
211
+ manifest = vmManifestSchema.parse(await res.json())
212
+ } catch (e) {
213
+ throw new VmImageError(
214
+ `the VM image manifest ${url} is malformed: ${(e as Error).message}`,
215
+ )
216
+ }
217
+ if (manifest.version !== version)
218
+ throw new VmImageError(
219
+ `refusing the VM image manifest ${url}: it is for mirab ${manifest.version}, this is mirab ${version}`,
220
+ )
221
+ if (manifest.protocol !== GUEST_PROTOCOL)
222
+ throw new VmImageError(
223
+ `refusing the VM image manifest ${url}: its guest agent speaks protocol ${manifest.protocol}, this mirab speaks ${GUEST_PROTOCOL}`,
224
+ )
225
+ const files = manifest.platforms[platform] as
226
+ Partial<Record<VmRole, VmFileEntry>> | undefined
227
+ const roles = rolesOf(platform)
228
+ if (!files || roles.some((r) => !files[r]))
229
+ throw new VmImageError(
230
+ `the VM image manifest ${url} has no complete ${platform} image`,
231
+ )
232
+
233
+ const paths = vmImagePaths(o.configDir)
234
+ await mkdir(paths.dir, { recursive: true, mode: 0o700 })
235
+ const stage = await mkdtemp(join(paths.dir, '.download-'))
236
+ try {
237
+ const total = roles.reduce((n, r) => n + files[r]!.size, 0)
238
+ log(
239
+ `downloading the VM image for mirab ${version} (${platform}, ${mb(total)}) into ${paths.dir}`,
240
+ )
241
+ for (const role of roles)
242
+ await download(
243
+ `${base}/${version}/vm/${files[role]!.file}`,
244
+ files[role]!,
245
+ join(stage, role),
246
+ role,
247
+ log,
248
+ )
249
+ for (const role of roles) {
250
+ await chmod(join(stage, role), role === 'vz' ? 0o755 : 0o644)
251
+ await rename(join(stage, role), paths[role])
252
+ }
253
+ const record: ImageRecord = {
254
+ source: 'release',
255
+ version,
256
+ platform,
257
+ protocol: manifest.protocol,
258
+ commit: manifest.commit,
259
+ files: Object.fromEntries(roles.map((r) => [r, files[r]!.unpacked])),
260
+ }
261
+ // Written last: an interrupted download leaves no record, so it is retried.
262
+ await writeFile(paths.record, JSON.stringify(record, null, 2) + '\n')
263
+ log(`VM image for mirab ${version} verified and installed`)
264
+ return 'downloaded'
265
+ } finally {
266
+ await rm(stage, { recursive: true, force: true })
267
+ }
268
+ }
269
+
270
+ const mb = (n: number) => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`
271
+
272
+ function hashing(onBytes?: (n: number) => void) {
273
+ const hash = createHash('sha256')
274
+ let bytes = 0
275
+ const tap = new Transform({
276
+ transform(chunk: Buffer, _enc, done) {
277
+ hash.update(chunk)
278
+ bytes += chunk.length
279
+ onBytes?.(bytes)
280
+ done(null, chunk)
281
+ },
282
+ })
283
+ return { tap, digest: () => hash.digest('hex'), bytes: () => bytes }
284
+ }
285
+
286
+ async function download(
287
+ url: string,
288
+ want: VmFileEntry,
289
+ to: string,
290
+ role: VmRole,
291
+ log: (line: string) => void,
292
+ ) {
293
+ let res: Response
294
+ try {
295
+ res = await fetch(url)
296
+ } catch (e) {
297
+ throw new VmImageError(
298
+ `could not fetch ${url}: ${(e as Error).message}; ${VM_IMAGE_FALLBACK_HINT}`,
299
+ )
300
+ }
301
+ if (!res.ok || !res.body)
302
+ throw new VmImageError(`could not fetch ${url}: HTTP ${res.status}`)
303
+ let shown = -1
304
+ const packed = hashing((n) => {
305
+ const pct = Math.floor((n / Math.max(want.size, 1)) * 10) * 10
306
+ if (pct > shown && want.size > 0) {
307
+ shown = pct
308
+ log(` ${role}: ${mb(n)} / ${mb(want.size)} (${Math.min(pct, 100)}%)`)
309
+ }
310
+ })
311
+ const unpacked = hashing()
312
+ try {
313
+ await pipeline(
314
+ Readable.fromWeb(res.body as never),
315
+ packed.tap,
316
+ createGunzip(),
317
+ unpacked.tap,
318
+ createWriteStream(to, { mode: 0o600 }),
319
+ )
320
+ } catch (e) {
321
+ throw new VmImageError(`${url}: ${(e as Error).message}`)
322
+ }
323
+ const got = packed.digest()
324
+ if (got !== want.sha256 || packed.bytes() !== want.size)
325
+ throw new VmImageError(
326
+ `refusing ${url}: sha256 ${got} (${packed.bytes()} bytes) is not the manifest's ${want.sha256} (${want.size} bytes)`,
327
+ )
328
+ const gotUnpacked = unpacked.digest()
329
+ if (
330
+ gotUnpacked !== want.unpacked.sha256 ||
331
+ unpacked.bytes() !== want.unpacked.size
332
+ )
333
+ throw new VmImageError(
334
+ `refusing ${url}: unpacked sha256 ${gotUnpacked} is not the manifest's ${want.unpacked.sha256}`,
335
+ )
336
+ }