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/spawn.ts ADDED
@@ -0,0 +1,468 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createConnection } from 'node:net'
3
+ import { realAgent, agentEnvironment } from './spawn-binary'
4
+ import { profileSchema, resolveProfile } from './agent-profiles'
5
+ import { MULTIPASS_UNAVAILABLE, usesMultipass } from './isolation/multipass'
6
+ import { parseNudge } from './spawn-nudge'
7
+ import type { ReportTarget } from './spawn-report'
8
+ import type { BridgeTarget } from './spawn-bridge'
9
+ import { runNative, supportsPty, debugFallback } from './spawn-native'
10
+ import { DEFAULT_COLS, DEFAULT_ROWS } from './spawn-detach'
11
+ import {
12
+ detachCommands,
13
+ detachedReady,
14
+ detachedSpawn,
15
+ dimension,
16
+ } from './spawn-attach'
17
+ import { readFile, rename, writeFile } from 'node:fs/promises'
18
+ import { createHash, randomBytes } from 'node:crypto'
19
+ import { dirname, join } from 'node:path'
20
+ import { homedir } from 'node:os'
21
+ import { loadHostConfig, parseLevel, prepareVm, statusLines } from './isolation'
22
+ import { Command, InvalidArgumentError } from 'commander'
23
+ import { spawnWorkerSource } from './spawn-bundle' with { type: 'macro' }
24
+ import {
25
+ liveSessions,
26
+ localRequest,
27
+ privateSessionsDirectory,
28
+ socketPath,
29
+ MAX_MESSAGE_BYTES,
30
+ } from './spawn-sessions'
31
+
32
+ /** Linux caps one argv string at 128 KiB, so the bundled worker runs from a
33
+ * content-addressed file in the private ~/.mirab directory, not `node -e`. */
34
+ async function workerFile() {
35
+ const source = await spawnWorkerSource()
36
+ const directory = dirname(await privateSessionsDirectory())
37
+ const path = join(
38
+ directory,
39
+ `spawn-worker-${createHash('sha256').update(source).digest('hex').slice(0, 16)}.mjs`,
40
+ )
41
+ if ((await readFile(path, 'utf8').catch(() => null)) !== source) {
42
+ const partial = `${path}.${process.pid}.tmp`
43
+ await writeFile(partial, source, { mode: 0o600 })
44
+ await rename(partial, path)
45
+ }
46
+ return path
47
+ }
48
+
49
+ function idleMilliseconds(value: string) {
50
+ const number = Number(value)
51
+ if (!Number.isSafeInteger(number) || number < 0 || number > 60000)
52
+ throw new InvalidArgumentError('idle-ms must be an integer from 0 to 60000')
53
+ return number
54
+ }
55
+ export function spawnCommands(
56
+ program: Command,
57
+ resolveIdentity?: (
58
+ label: string,
59
+ cwd: string,
60
+ localProfile: boolean,
61
+ ) => Promise<{ agent: string; report?: ReportTarget; bridge?: BridgeTarget }>,
62
+ ) {
63
+ program
64
+ .command('spawn <agent> [args...]')
65
+ .description('run a terminal agent with a private local message endpoint')
66
+ .passThroughOptions()
67
+ .option(
68
+ '--as <label>',
69
+ 'report state with an approved local agent identity',
70
+ )
71
+ .option(
72
+ '--channel <owner/repo>',
73
+ "with --as: hear @mentions in this repo's channel (typed into the agent); repeat",
74
+ (value: string, previous: string[] = []) => [...previous, value],
75
+ )
76
+ .option('--print-id', 'print the local session id to stderr')
77
+ .option(
78
+ '--nudge <rule>',
79
+ "watchdog rule added to the profile's: idle:<duration>[:<limit>][:<message>], waiting:<duration>, ci:<owner/repo>, mention-unanswered:<duration>; repeat; put it before <agent>",
80
+ (value: string, previous: string[] = []) => {
81
+ try {
82
+ parseNudge(value)
83
+ } catch (error) {
84
+ throw new InvalidArgumentError((error as Error).message)
85
+ }
86
+ return [...previous, value]
87
+ },
88
+ )
89
+ .option(
90
+ '--vm',
91
+ 'run the agent inside a VM: vz on macOS, Firecracker on Linux (each with its image fetched on first use), Multipass on Windows',
92
+ )
93
+ .option(
94
+ '--config-dir <path>',
95
+ 'mirab config directory (isolation.json)',
96
+ join(homedir(), '.config', 'mirab'),
97
+ )
98
+ .option('--name <id>', 'memorable local session name')
99
+ .option(
100
+ '--idle-ms <ms>',
101
+ 'wait for user input to be idle before injecting',
102
+ idleMilliseconds,
103
+ 1500,
104
+ )
105
+ .option(
106
+ '--detach',
107
+ 'run in the background with no terminal; prints the session id (see mirab attach, mirab stop)',
108
+ )
109
+ .option('--cols <n>', 'detached terminal width', dimension, DEFAULT_COLS)
110
+ .option('--rows <n>', 'detached terminal height', dimension, DEFAULT_ROWS)
111
+ .allowUnknownOption()
112
+ .action(
113
+ async (
114
+ agent: string,
115
+ args: string[],
116
+ opts: {
117
+ name?: string
118
+ idleMs: number
119
+ as?: string
120
+ channel?: string[]
121
+ printId?: boolean
122
+ nudge?: string[]
123
+ detach?: boolean
124
+ cols: number
125
+ rows: number
126
+ vm?: boolean
127
+ configDir: string
128
+ },
129
+ ) => {
130
+ const resolved =
131
+ agent === 'agent'
132
+ ? await resolveProfile(args.shift() ?? '', args)
133
+ : undefined
134
+ let profile = resolved?.profile
135
+ if (profile && opts.as && opts.as !== profile.name)
136
+ throw Error(
137
+ 'A profile uses its own approved agent label; --as must match the profile name',
138
+ )
139
+ if (profile && opts.name)
140
+ throw Error('Profile sessions are named after the agent; omit --name')
141
+ if (opts.channel?.length) {
142
+ if (profile)
143
+ throw Error(
144
+ 'A profile lists its own channels; use mirab agent edit --channel',
145
+ )
146
+ if (!opts.as)
147
+ throw Error(
148
+ '--channel needs --as <label>: mentions are addressed to an agent',
149
+ )
150
+ // A profile for this session only: the bridge hears the agent's mentions there.
151
+ profile = profileSchema.parse({
152
+ name: opts.as,
153
+ runtime: agent,
154
+ directory: process.cwd(),
155
+ args,
156
+ channels: opts.channel,
157
+ })
158
+ }
159
+ // Without an identity provider (mirab#2), an identity or a channel
160
+ // would be silently ignored; say so instead.
161
+ if (!resolveIdentity && (opts.as || profile?.channels?.length))
162
+ throw Error(
163
+ '--as and --channel need an agent identity, which mirab gets from harar (addiscorp/mirab#2); run without --as, and remove the channels from a profile, for now',
164
+ )
165
+ agent = profile?.runtime ?? agent
166
+ args = resolved?.args ?? args
167
+ const cwd = profile?.directory ?? process.cwd()
168
+ const label = profile?.name ?? opts.as
169
+ const binary = realAgent(agent, cwd)
170
+ if (opts.detach && !supportsPty())
171
+ throw Error(
172
+ 'Detached sessions need a PTY, unavailable on this platform',
173
+ )
174
+ // macOS and Windows: Multipass, once mirab is on npm for its VM to
175
+ // install. Until then it refuses here, before anything else.
176
+ if (opts.vm && usesMultipass(opts.configDir))
177
+ throw Error(
178
+ `--vm: ${MULTIPASS_UNAVAILABLE} It never falls back to running on the host.`,
179
+ )
180
+ // Detached, the guest pty is the terminal; attached, it needs a real one.
181
+ if (
182
+ opts.vm &&
183
+ !opts.detach &&
184
+ (!process.stdin.isTTY || !process.stdout.isTTY || !supportsPty())
185
+ )
186
+ throw Error(
187
+ '--vm needs an interactive terminal (or --detach); it never falls back to running on the host',
188
+ )
189
+ // First --vm use: fetch the published guest image here, where the
190
+ // terminal shows its progress; the worker then finds it installed.
191
+ if (opts.vm) {
192
+ const { vmUnavailable } = await prepareVm(opts.configDir, {
193
+ want: true,
194
+ log: (line) => console.error(`mirab: ${line}`),
195
+ })
196
+ if (vmUnavailable)
197
+ throw Error(
198
+ `--vm: ${vmUnavailable}. It never falls back to running on the host`,
199
+ )
200
+ }
201
+ if (
202
+ !opts.detach &&
203
+ (!process.stdin.isTTY || !process.stdout.isTTY || !supportsPty())
204
+ ) {
205
+ process.exitCode = await runNative(
206
+ binary,
207
+ args,
208
+ cwd,
209
+ agentEnvironment(binary, profile?.env),
210
+ )
211
+ return
212
+ }
213
+ const identity =
214
+ label && resolveIdentity
215
+ ? await resolveIdentity(label, cwd, !!profile)
216
+ : undefined
217
+ const report = identity?.report
218
+ const nudges = [...(profile?.nudges ?? []), ...(opts.nudge ?? [])]
219
+ const rules = nudges.map(parseNudge)
220
+ // ci and mention rules read the profile's events subscription.
221
+ if (rules.some((r) => r.kind === 'ci') && !(profile && identity))
222
+ throw Error(
223
+ 'ci nudges need an agent profile with an approved identity (mirab spawn agent <name>)',
224
+ )
225
+ if (
226
+ rules.some((r) => r.kind === 'mention-unanswered') &&
227
+ !(profile?.channels?.length && identity)
228
+ )
229
+ throw Error(
230
+ 'mention-unanswered nudges need an agent profile with --channel and an approved identity',
231
+ )
232
+ const bridge =
233
+ profile?.channels?.length || rules.some((r) => r.kind === 'ci')
234
+ ? identity?.bridge
235
+ : undefined
236
+ const id =
237
+ profile?.name ??
238
+ opts.name ??
239
+ `agent-${randomBytes(3).toString('hex')}`
240
+ socketPath(id)
241
+ const ptyModule = import.meta.url.includes('$bunfs')
242
+ ? join(
243
+ dirname(process.execPath),
244
+ '..',
245
+ 'vendor',
246
+ 'node-pty',
247
+ 'lib',
248
+ 'index.js',
249
+ )
250
+ : new URL('../node_modules/node-pty/lib/index.js', import.meta.url)
251
+ .pathname
252
+ const worker = spawn(
253
+ opts.vm ? process.execPath : 'node',
254
+ [
255
+ ...(opts.vm
256
+ ? import.meta.url.includes('$bunfs')
257
+ ? ['__vm-worker']
258
+ : [
259
+ 'run',
260
+ new URL('./mirab.ts', import.meta.url).pathname,
261
+ '__vm-worker',
262
+ ]
263
+ : [await workerFile()]),
264
+ JSON.stringify({
265
+ agent,
266
+ args,
267
+ id,
268
+ idleMs: opts.idleMs,
269
+ ptyModule,
270
+ vm: opts.vm ? { configDir: opts.configDir } : undefined,
271
+ // Inside the guest the hook is the guest agent, which relays over vsock.
272
+ hookCommand: opts.vm
273
+ ? ['/usr/local/bin/mirab-guest-agent']
274
+ : import.meta.url.includes('$bunfs')
275
+ ? [process.execPath]
276
+ : [
277
+ process.execPath,
278
+ 'run',
279
+ new URL('./mirab.ts', import.meta.url).pathname,
280
+ ],
281
+ reporting: !!report,
282
+ bridging: !!bridge,
283
+ identity: identity?.agent,
284
+ printId: opts.printId,
285
+ profile: profile
286
+ ? {
287
+ name: profile.name,
288
+ channels: profile.channels,
289
+ on: profile.on,
290
+ }
291
+ : undefined,
292
+ envAllowlist: profile?.env,
293
+ nudges,
294
+ detach: opts.detach
295
+ ? { cols: opts.cols, rows: opts.rows }
296
+ : undefined,
297
+ }),
298
+ ],
299
+ opts.detach
300
+ ? detachedSpawn(cwd)
301
+ : {
302
+ cwd,
303
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
304
+ },
305
+ )
306
+ if (report)
307
+ worker.once('spawn', () => worker.send({ type: 'report', report }))
308
+ if (bridge)
309
+ worker.once('spawn', () => worker.send({ type: 'bridge', bridge }))
310
+ if (opts.detach) return console.log(await detachedReady(worker))
311
+ const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGHUP', 'SIGWINCH']
312
+ const interrupted = () => {}
313
+ process.on('SIGINT', interrupted)
314
+ const disconnected = () => worker.kill('SIGHUP')
315
+ process.on('disconnect', disconnected)
316
+ const handlers = signals.map((signal) => () => worker.kill(signal))
317
+ signals.forEach((signal, index) => process.on(signal, handlers[index]))
318
+ try {
319
+ process.exitCode = await new Promise<number>((resolve, reject) => {
320
+ worker.once('error', reject)
321
+ worker.once('exit', (code, signal) =>
322
+ resolve(
323
+ code ??
324
+ (signal === 'SIGINT' ? 130 : signal === 'SIGHUP' ? 129 : 143),
325
+ ),
326
+ )
327
+ })
328
+ } catch (error) {
329
+ if (opts.vm) throw error
330
+ debugFallback(error)
331
+ process.exitCode = await runNative(
332
+ realAgent(agent, cwd),
333
+ args,
334
+ cwd,
335
+ agentEnvironment(realAgent(agent, cwd), profile?.env),
336
+ )
337
+ } finally {
338
+ process.off('SIGINT', interrupted)
339
+ process.off('disconnect', disconnected)
340
+ signals.forEach((signal, index) =>
341
+ process.off(signal, handlers[index]),
342
+ )
343
+ }
344
+ },
345
+ )
346
+ program
347
+ .command('send <id> <message>')
348
+ .description(
349
+ 'queue a prompt in a local agent session (use - to read stdin)',
350
+ )
351
+ .action(async (id: string, message: string) => {
352
+ const directory = await privateSessionsDirectory()
353
+ if (message === '-') {
354
+ const chunks: Buffer[] = []
355
+ let bytes = 0
356
+ for await (const chunk of process.stdin) {
357
+ bytes += chunk.length
358
+ if (bytes > MAX_MESSAGE_BYTES)
359
+ throw new Error('Message exceeds 64 KiB')
360
+ chunks.push(Buffer.from(chunk))
361
+ }
362
+ message = Buffer.concat(chunks).toString('utf8')
363
+ }
364
+ await localRequest(socketPath(id, directory), { type: 'send', message })
365
+ // The sender (often another agent) needs to know the session accepted
366
+ // it; delivery waits for the agent to be ready (see mirab status).
367
+ console.log(`queued for ${id}`)
368
+ })
369
+ program
370
+ .command('sync <id>')
371
+ .description(
372
+ "copy a --vm session's working-directory changes back to the host now (also done when it exits)",
373
+ )
374
+ .action(async (id: string) => {
375
+ const r = await localRequest(
376
+ socketPath(id, await privateSessionsDirectory()),
377
+ { type: 'sync' },
378
+ 10 * 60_000,
379
+ )
380
+ console.log(
381
+ `${id}: ${r.written} written, ${r.deleted} deleted, ${r.conflicts.length} conflicts${r.held.length ? `, ${r.held.length} held` : ''}${r.rejected ? `, ${r.rejected} rejected` : ''}`,
382
+ )
383
+ for (const c of r.conflicts)
384
+ console.log(
385
+ ` kept host copy of ${c.path} (${c.reason})${c.saved ? `; guest copy: ${c.saved}` : ''}`,
386
+ )
387
+ for (const h of r.held)
388
+ console.log(
389
+ ` held ${h.path}: the agent changed its own definition${h.saved ? `; its version: ${h.saved}` : ''}`,
390
+ )
391
+ })
392
+ program
393
+ .command('status [id]')
394
+ .description(
395
+ "inspect a live local agent session, or (no id) show this machine's isolation",
396
+ )
397
+ .option(
398
+ '--isolation <level>',
399
+ 'with no id: show the outcome of this request',
400
+ )
401
+ .option(
402
+ '--config-dir <path>',
403
+ 'mirab config directory',
404
+ join(homedir(), '.config', 'mirab'),
405
+ )
406
+ .action(async (id: string | undefined, opts) => {
407
+ if (!id) {
408
+ const host = await loadHostConfig(opts.configDir)
409
+ for (const line of statusLines(host, {
410
+ flag: parseLevel(opts.isolation, '--isolation'),
411
+ }))
412
+ console.log(line)
413
+ return
414
+ }
415
+ const directory = await privateSessionsDirectory()
416
+ console.log(
417
+ JSON.stringify(
418
+ await localRequest(socketPath(id, directory), { type: 'info' }),
419
+ ),
420
+ )
421
+ })
422
+ detachCommands(program)
423
+ program
424
+ .command('sessions')
425
+ .description('list live local agent sessions and remove stale sockets')
426
+ .option('--json', 'print session metadata as JSON')
427
+ .action(async (opts: { json?: boolean }) => {
428
+ const sessions = await liveSessions()
429
+ if (opts.json) console.log(JSON.stringify(sessions))
430
+ else {
431
+ console.log(
432
+ 'ID\tAGENT\tPROFILE\tSTATE\tTOOL\tLAST ACTIVITY\tCWD\tPID\tSTARTED',
433
+ )
434
+ for (const session of sessions)
435
+ console.log(
436
+ [
437
+ session.id,
438
+ session.agent,
439
+ session.profile ?? '',
440
+ session.state === 'tool_start' ? 'running a tool' : session.state,
441
+ session.tool ?? '',
442
+ session.lastActivity,
443
+ session.cwd,
444
+ session.pid,
445
+ session.started,
446
+ ].join('\t'),
447
+ )
448
+ }
449
+ })
450
+ }
451
+
452
+ export async function localEvents(id: string) {
453
+ const directory = await privateSessionsDirectory()
454
+ await new Promise<void>((resolve, reject) => {
455
+ const socket = createConnection(socketPath(id, directory))
456
+ socket.on('error', reject)
457
+ socket.on('connect', () => socket.write('{"type":"subscribe"}\n'))
458
+ socket.on('data', (data) => {
459
+ if (!process.stdout.write(data)) socket.pause()
460
+ })
461
+ const drain = () => socket.resume()
462
+ process.stdout.on('drain', drain)
463
+ socket.on('close', () => {
464
+ process.stdout.off('drain', drain)
465
+ resolve()
466
+ })
467
+ })
468
+ }
@@ -0,0 +1,89 @@
1
+ // station.json in mirab's config directory: where the station's harar is,
2
+ // and which harar secret each model route adds. Explicit, so nothing comes
3
+ // from the environment.
4
+ import { readFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { z } from 'zod'
7
+ import { humanNameProblem } from '../agent/define'
8
+
9
+ export const stationConfigSchema = z.strictObject({
10
+ harar: z.strictObject({
11
+ /** The harar primary's (or per-user agent's) socket. */
12
+ socket: z.string().startsWith('/', 'the harar socket is an absolute path'),
13
+ }),
14
+ models: z.strictObject({
15
+ anthropic: z
16
+ .strictObject({
17
+ /** The harar secret holding the API key, e.g. `personal/anthropic/API_KEY`. */
18
+ key: z.string().min(1),
19
+ })
20
+ .optional(),
21
+ claude: z
22
+ .strictObject({
23
+ /** The harar secret holding Claude Code's token (`claude setup-token`),
24
+ * for Claude Code in a VM on the person's own subscription. */
25
+ token: z.string().min(1),
26
+ })
27
+ .optional(),
28
+ }),
29
+ /** Keys for any service: each harar secret a project's .env may name, and
30
+ * the hosts it may go to. The VM gets placeholders; the station swaps them. */
31
+ /** Where a secret may go is harar's to say now: a station.json that still
32
+ * lists secrets loads, the list is ignored, and the station says where
33
+ * hosts went. */
34
+ secrets: z.unknown().optional(),
35
+ /** The person at this station, by harar name (`gg.addis.human.sami`): who
36
+ * they are in the channels it hosts. */
37
+ human: z
38
+ .string()
39
+ .refine((name) => !humanNameProblem(name), {
40
+ message: 'human is a harar name like gg.addis.human.sami',
41
+ })
42
+ .optional(),
43
+ })
44
+ export type StationConfig = z.infer<typeof stationConfigSchema>
45
+
46
+ export const STATION_EXAMPLE = JSON.stringify(
47
+ {
48
+ harar: { socket: '/var/run/harar.sock' },
49
+ models: {
50
+ anthropic: { key: 'personal/anthropic/API_KEY' },
51
+ claude: { token: 'personal/claude/OAUTH_TOKEN' },
52
+ },
53
+ },
54
+ null,
55
+ 2,
56
+ )
57
+
58
+ /** station.json, or undefined when there is none and `optional` says that's fine. */
59
+ export async function loadStationConfig(
60
+ configDir: string,
61
+ ): Promise<StationConfig>
62
+ export async function loadStationConfig(
63
+ configDir: string,
64
+ options: { optional: true },
65
+ ): Promise<StationConfig | undefined>
66
+ export async function loadStationConfig(
67
+ configDir: string,
68
+ options?: { optional: true },
69
+ ): Promise<StationConfig | undefined> {
70
+ const path = join(configDir, 'station.json')
71
+ const text = await readFile(path, 'utf8').catch(() => undefined)
72
+ if (text === undefined && options?.optional) return undefined
73
+ if (text === undefined)
74
+ throw new Error(
75
+ `${path} is missing: it says where harar is and which secret is the model key, like\n${STATION_EXAMPLE}`,
76
+ )
77
+ let data: unknown
78
+ try {
79
+ data = JSON.parse(text)
80
+ } catch {
81
+ throw new Error(`${path} isn't JSON`)
82
+ }
83
+ const parsed = stationConfigSchema.safeParse(data)
84
+ if (!parsed.success)
85
+ throw new Error(
86
+ `${path}: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`,
87
+ )
88
+ return parsed.data
89
+ }
@@ -0,0 +1,94 @@
1
+ // mirab's side of rixse's gateway (`import { gateway } from 'rixse'`): one
2
+ // gateway session per VM, mounted as the VM's only way out. The gateway
3
+ // knows nothing about VMs; this maps the guest's egress port to it, puts its
4
+ // certificate authority where the guest's programs trust it, and passes its
5
+ // environment on. Shared by `mirab spawn --vm` and `mirab dev`.
6
+ import { gateway, type GatewayConfig, type Route, type Upstream } from 'rixse'
7
+ import type { Isolation } from '../isolation/session'
8
+ import { EGRESS_GUEST_PORT, EGRESS_HOST_PORT } from '../isolation/vz'
9
+ import type { StationConfig } from './config'
10
+ import { hararVault } from './vault'
11
+ import { shellQuote } from '../spawn-adapters/types'
12
+
13
+ /** Where the gateway's authority lands in the guest. */
14
+ export const GUEST_CA = '/usr/local/share/ca-certificates/mirab-station.crt'
15
+ /** The bundle curl, git, Go and OpenSSL programs read. */
16
+ const GUEST_BUNDLE = '/etc/ssl/certs/ca-certificates.crt'
17
+ /** The gateway's address in the guest. */
18
+ export const GUEST_PROXY = `http://127.0.0.1:${EGRESS_GUEST_PORT}`
19
+
20
+ export interface VmStation {
21
+ /** What the guest's programs get: a placeholder per secret, and the gateway as their proxy. */
22
+ env: Record<string, string>
23
+ /** Things the person should know. */
24
+ notes: string[]
25
+ /** Serves the VM's way out, and makes the guest trust the gateway's authority. */
26
+ attach(iso: Isolation): Promise<void>
27
+ close(): Promise<void>
28
+ }
29
+
30
+ export async function vmStation(o: {
31
+ project: string
32
+ station: StationConfig
33
+ configDir: string
34
+ zone: string
35
+ auditLog: string
36
+ routes: Record<string, Route>
37
+ allow: string[]
38
+ /** Whose grants: `project:<real path>`. */
39
+ agent: string
40
+ /** Tests point the hosts the gateway opens at a local stand-in. */
41
+ upstream?: (host: string) => Promise<Upstream>
42
+ /** The agent's gateway section, as the person approved it. */
43
+ config?: GatewayConfig
44
+ }): Promise<VmStation> {
45
+ const session = await gateway({
46
+ project: o.project,
47
+ vault: hararVault(o.station.harar.socket),
48
+ configDir: o.configDir,
49
+ zone: o.zone,
50
+ auditLog: o.auditLog,
51
+ routes: o.routes,
52
+ allow: o.allow,
53
+ agent: o.agent,
54
+ guest: { proxy: GUEST_PROXY, caPath: GUEST_CA },
55
+ upstream: o.upstream,
56
+ config: o.config,
57
+ })
58
+ // The gateway says what's missing; mirab says how to give it, here.
59
+ const notes = session.notes.map((note) => {
60
+ // The agent is the one mirab named; a project path can hold spaces.
61
+ const m = /names harar:\/\/(\S+), which /.exec(note)
62
+ return m && note.includes(`, which ${o.agent} hasn't been allowed`)
63
+ ? `${note}. Allow it: mirab secrets allow ${m[1]} --agent ${shellQuote(o.agent)}`
64
+ : note
65
+ })
66
+ if (o.station.secrets !== undefined)
67
+ notes.push(
68
+ "station.json's secrets are ignored now: harar says where each secret may go (harar hosts allow <secret> <host>, or a role's send at a primary)",
69
+ )
70
+ return {
71
+ env: session.env,
72
+ notes,
73
+ async attach(iso) {
74
+ await iso.onGuestConnect!(EGRESS_HOST_PORT, (socketPath) =>
75
+ session.listen(socketPath),
76
+ )
77
+ if (!session.caCert) return
78
+ await iso.put(GUEST_CA, session.caCert, 0o644)
79
+ const code = await iso.exec(
80
+ ['sh', '-c', `cat ${GUEST_CA} >> ${GUEST_BUNDLE}`],
81
+ {
82
+ cwd: '/',
83
+ env: { PATH: '/usr/bin:/bin' },
84
+ timeoutMs: 10_000,
85
+ signal: new AbortController().signal,
86
+ onLine: async () => {},
87
+ },
88
+ )
89
+ if (code !== 0)
90
+ throw new Error("the VM couldn't be told to trust the gateway")
91
+ },
92
+ close: () => session.close(),
93
+ }
94
+ }