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,114 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { GildClient } from './api/client'
3
+ import type { AgentEvent } from './spawn-events'
4
+ import { sessionState, type SessionInput } from './api/sessions-contract'
5
+ export type ReportTarget = {
6
+ server: string
7
+ token: string
8
+ agent: string
9
+ owner: string
10
+ repo: string
11
+ sha: string
12
+ }
13
+ /** gild.gg answered a commit-session receipt in 2–4.5 s on 10 Oct; a 1 s
14
+ * abort dropped every report, so the channel never showed an agent's state.
15
+ * Only one request is in flight and the latest state wins, so a slow answer
16
+ * delays the next update rather than piling them up. */
17
+ export const REPORT_TIMEOUT_MS = 10_000
18
+ /** One in-flight update, latest state wins. No local content reaches the API. */
19
+ export class StateReporter {
20
+ private readonly id = randomUUID()
21
+ private readonly started = new Date().toISOString()
22
+ private readonly client: GildClient
23
+ private pending?: NonNullable<SessionInput['state']>
24
+ private timer?: ReturnType<typeof setTimeout>
25
+ private sending?: Promise<void>
26
+ private lastSent = 0
27
+ private ended = false
28
+ constructor(
29
+ private readonly target: ReportTarget,
30
+ private readonly model: string,
31
+ fetcher?: ConstructorParameters<typeof GildClient>[2],
32
+ ) {
33
+ this.client = new GildClient(
34
+ target.server + '/api/v1',
35
+ target.token,
36
+ fetcher,
37
+ )
38
+ }
39
+ event(event: AgentEvent) {
40
+ if (this.ended || event.type === 'message') return
41
+ const parsedTool = sessionState.shape.tool.safeParse(event.tool)
42
+ const tool = parsedTool.success ? parsedTool.data : undefined
43
+ this.pending = {
44
+ status: event.type,
45
+ ...(tool ? { tool } : {}),
46
+ last_activity: event.ts,
47
+ }
48
+ this.schedule()
49
+ }
50
+ private schedule() {
51
+ if (this.sending || this.timer || !this.pending) return
52
+ this.timer = setTimeout(
53
+ () => {
54
+ this.timer = undefined
55
+ void this.send()
56
+ },
57
+ Math.max(0, 1000 - (Date.now() - this.lastSent)),
58
+ )
59
+ }
60
+ private async send(timeoutMs = REPORT_TIMEOUT_MS) {
61
+ if (this.sending || !this.pending) return
62
+ const state = this.pending
63
+ this.pending = undefined
64
+ this.lastSent = Date.now()
65
+ this.sending = this.client
66
+ .request(
67
+ 'createCommitSession',
68
+ {
69
+ owner: this.target.owner,
70
+ repo: this.target.repo,
71
+ sha: this.target.sha,
72
+ },
73
+ {
74
+ id: this.id,
75
+ agent: this.target.agent,
76
+ started_at: this.started,
77
+ ended_at: state.status === 'ended' ? state.last_activity : null,
78
+ model: this.model,
79
+ state,
80
+ tokens_in: 0,
81
+ tokens_out: 0,
82
+ currency: 'USD',
83
+ commands: [],
84
+ files: { read: [], written: [] },
85
+ notes: '',
86
+ },
87
+ undefined,
88
+ { signal: AbortSignal.timeout(timeoutMs) },
89
+ )
90
+ .then(
91
+ () => {},
92
+ () => {},
93
+ )
94
+ await this.sending
95
+ this.lastSent = Date.now()
96
+ this.sending = undefined
97
+ this.schedule()
98
+ }
99
+ async close() {
100
+ this.ended = true
101
+ clearTimeout(this.timer)
102
+ this.timer = undefined
103
+ this.pending = { status: 'ended', last_activity: new Date().toISOString() }
104
+ await this.sending
105
+ clearTimeout(this.timer)
106
+ this.timer = undefined
107
+ // Honor the same rate limit for the final update.
108
+ await new Promise((resolve) =>
109
+ setTimeout(resolve, Math.max(0, 1000 - (Date.now() - this.lastSent))),
110
+ )
111
+ // Exit waits for this one, so it gets less time than a live update.
112
+ await this.send(5000)
113
+ }
114
+ }
@@ -0,0 +1,166 @@
1
+ import type { SessionState } from './spawn-events'
2
+ import type { NudgeStatus } from './spawn-nudge'
3
+ import { chmod, lstat, mkdir, readdir, unlink } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { createConnection } from 'node:net'
7
+
8
+ export type ChannelStatus = {
9
+ repo: string
10
+ state: 'connecting' | 'listening' | 'error'
11
+ error?: string
12
+ }
13
+ export type LocalSession = SessionState & {
14
+ id: string
15
+ agent: string
16
+ profile?: string
17
+ identity?: string
18
+ channels?: ChannelStatus[]
19
+ held?: { queued: number; reason: string }
20
+ nudges?: NudgeStatus[]
21
+ /** Detached sessions (`mirab spawn --detach`) only. */
22
+ detached?: true
23
+ viewers?: number
24
+ interactive?: boolean
25
+ cols?: number
26
+ rows?: number
27
+ log?: string
28
+ cwd: string
29
+ pid: number
30
+ childPid: number
31
+ started: string
32
+ }
33
+ export const MAX_MESSAGE_BYTES = 64 * 1024
34
+ export const sessionsDirectory = () => join(homedir(), '.mirab', 'sessions')
35
+
36
+ export function socketPath(id: string, directory = sessionsDirectory()) {
37
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/.test(id))
38
+ throw new Error(
39
+ 'Session name must be 1–32 letters, digits, underscores or hyphens',
40
+ )
41
+ const path = join(directory, id + '.sock')
42
+ if (Buffer.byteLength(path) > 103)
43
+ throw new Error('Session socket path is too long for a Unix socket')
44
+ return path
45
+ }
46
+ export function requireUnix() {
47
+ if (process.platform === 'win32')
48
+ throw new Error('Local agent sessions are not supported on Windows in v1')
49
+ }
50
+ export async function privateSessionsDirectory() {
51
+ requireUnix()
52
+ const directory = sessionsDirectory()
53
+ for (const path of [join(homedir(), '.mirab'), directory]) {
54
+ await mkdir(path, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => {
55
+ if (error.code !== 'EEXIST') throw error
56
+ })
57
+ const info = await lstat(path)
58
+ if (
59
+ !info.isDirectory() ||
60
+ info.isSymbolicLink() ||
61
+ info.uid !== process.getuid!()
62
+ )
63
+ throw new Error(`Unsafe session directory: ${path}`)
64
+ await chmod(path, 0o700)
65
+ }
66
+ return directory
67
+ }
68
+ export interface SyncSummary {
69
+ written: number
70
+ deleted: number
71
+ rejected: number
72
+ conflicts: { path: string; reason: string; saved?: string }[]
73
+ /** What the agent changed about itself: held aside, never applied. */
74
+ held: { path: string; reason: string; saved?: string }[]
75
+ }
76
+ export function localRequest(
77
+ path: string,
78
+ request: { type: 'sync' },
79
+ timeoutMs?: number,
80
+ ): Promise<SyncSummary>
81
+ export function localRequest(
82
+ path: string,
83
+ request: { type: 'info' } | { type: 'send'; message: string },
84
+ timeoutMs?: number,
85
+ ): Promise<LocalSession | { queued: true }>
86
+ export function localRequest(
87
+ path: string,
88
+ request:
89
+ { type: 'info' } | { type: 'send'; message: string } | { type: 'sync' },
90
+ timeoutMs = 3000,
91
+ ): Promise<LocalSession | { queued: true } | SyncSummary> {
92
+ const data = JSON.stringify(request) + '\n'
93
+ if (Buffer.byteLength(data) > MAX_MESSAGE_BYTES)
94
+ return Promise.reject(new Error('Message exceeds 64 KiB'))
95
+ return new Promise((resolve, reject) => {
96
+ const socket = createConnection(path)
97
+ let response = ''
98
+ const fail = (error: Error) => {
99
+ socket.destroy()
100
+ reject(error)
101
+ }
102
+ socket.setEncoding('utf8')
103
+ socket.setTimeout(timeoutMs, () =>
104
+ fail(new Error('Session did not respond')),
105
+ )
106
+ socket.on('error', fail)
107
+ socket.on('connect', () => socket.write(data))
108
+ socket.on('data', (chunk: string) => {
109
+ response += chunk
110
+ if (Buffer.byteLength(response) > MAX_MESSAGE_BYTES)
111
+ return fail(new Error('Invalid session response'))
112
+ if (!response.includes('\n')) return
113
+ try {
114
+ const result = JSON.parse(response.slice(0, response.indexOf('\n')))
115
+ socket.destroy()
116
+ if (result.error) reject(new Error(result.error))
117
+ else resolve(result)
118
+ } catch {
119
+ fail(new Error('Invalid session response'))
120
+ }
121
+ })
122
+ socket.on('end', () => fail(new Error('Session closed without a response')))
123
+ })
124
+ }
125
+ export async function liveSessions(): Promise<LocalSession[]> {
126
+ const directory = await privateSessionsDirectory()
127
+ const sessions: LocalSession[] = []
128
+ for (const entry of await readdir(directory)) {
129
+ if (!entry.endsWith('.sock')) continue
130
+ const path = join(directory, entry)
131
+ const socket = await lstat(path).catch(() => null)
132
+ if (!socket?.isSocket()) continue
133
+ try {
134
+ const result = await localRequest(path, { type: 'info' })
135
+ if ('id' in result) sessions.push(result)
136
+ } catch (error) {
137
+ if (!(await removeDeadSocket(path, error, socket))) throw error
138
+ }
139
+ }
140
+ return sessions.sort((a, b) => a.started.localeCompare(b.started))
141
+ }
142
+
143
+ /** Probe first; unlink only a refused socket whose inode still belongs to that probe. */
144
+ export async function removeDeadSocket(
145
+ path: string,
146
+ error?: unknown,
147
+ socket?: Awaited<ReturnType<typeof lstat>>,
148
+ ) {
149
+ socket ??= await lstat(path).catch(() => undefined)
150
+ if (!socket?.isSocket()) return false
151
+ if (!error) {
152
+ try {
153
+ await localRequest(path, { type: 'info' })
154
+ return false
155
+ } catch (probeError) {
156
+ error = probeError
157
+ }
158
+ }
159
+ const code = (error as NodeJS.ErrnoException).code
160
+ if (code !== 'ECONNREFUSED' && code !== 'ENOENT') return false
161
+ const current = await lstat(path).catch(() => undefined)
162
+ if (!current) return true
163
+ if (current.ino !== socket.ino || current.dev !== socket.dev) return false
164
+ await unlink(path)
165
+ return true
166
+ }
@@ -0,0 +1,356 @@
1
+ // `mirab spawn --vm`: the agent process runs on a pty inside a microVM
2
+ // (Firecracker on Linux, Virtualization.framework on macOS); the host keeps the terminal, session socket, hooks, injection queue
3
+ // and everything else (spawn-worker.ts). This file only builds the guest
4
+ // child and the hook relay.
5
+ import { projectAgent } from 'rixse'
6
+ import { createConnection } from 'node:net'
7
+ import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
8
+ import { basename, isAbsolute, join } from 'node:path'
9
+ import { execFileSync } from 'node:child_process'
10
+ import { requireVm, startVm, type Isolation, type VmConfig } from './isolation'
11
+ import { privateSessionsDirectory, socketPath } from './spawn-sessions'
12
+ import type { StationConfig } from './station/config'
13
+ import type { Upstream } from 'rixse'
14
+ import { GUEST_PROXY, vmStation, type VmStation } from './station/gateway'
15
+ import { claudeRoute, withClaudeRoute } from 'rixse'
16
+ import { hararVault } from './station/vault'
17
+ import {
18
+ describeSync,
19
+ hostManifest,
20
+ syncBack,
21
+ type SyncResult,
22
+ type RecoveryLocation,
23
+ } from './isolation/sync'
24
+
25
+ export const GUEST_AGENT = '/usr/local/bin/mirab-guest-agent'
26
+ const GUEST_PATH =
27
+ '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
28
+ const MAX_UPLOAD = 1 << 20
29
+
30
+ export interface VmChild {
31
+ pid: number
32
+ write(data: string | Uint8Array): void
33
+ resize(cols: number, rows: number): void
34
+ onData(cb: (data: Buffer) => void): void
35
+ onExit(cb: (e: { exitCode: number; signal?: number }) => void): void
36
+ pause(): void
37
+ resume(): void
38
+ /** Hang up the guest session (the agent gets SIGHUP). */
39
+ kill(): void
40
+ /** Copy the guest's changes to the working directory back to the host now. */
41
+ sync(): Promise<SyncResult>
42
+ /** Sync once, then stop; incomplete sync retains the workspace for recovery. */
43
+ dispose(): Promise<SyncResult | undefined>
44
+ /** Available even when final sync throws; contains only private local paths. */
45
+ readonly recovery?: RecoveryLocation
46
+ }
47
+
48
+ export class VmSyncError extends Error {
49
+ constructor(
50
+ message: string,
51
+ readonly recovery: RecoveryLocation,
52
+ cause: unknown,
53
+ ) {
54
+ super(message, { cause })
55
+ }
56
+ }
57
+
58
+ /** Shut down the VM without deleting work that could not safely reach the host. */
59
+ export async function finishVmSync(
60
+ sync: () => Promise<SyncResult>,
61
+ isolation: Pick<Isolation, 'close'>,
62
+ vmDir: string,
63
+ log: (line: string) => void = (line) => console.error(`mirab: ${line}`),
64
+ ) {
65
+ let result: SyncResult | undefined, failure: unknown
66
+ try {
67
+ result = await sync()
68
+ } catch (e) {
69
+ failure = e
70
+ }
71
+ const preserve =
72
+ failure !== undefined ||
73
+ !result ||
74
+ result.conflicts.length > 0 ||
75
+ result.rejected.length > 0
76
+ const recovery: RecoveryLocation = {
77
+ directory: vmDir,
78
+ note: join(vmDir, 'recovery.json'),
79
+ }
80
+ try {
81
+ await isolation.close({ preserveWorkspace: preserve })
82
+ } catch (e) {
83
+ log(`VM shutdown failed; recovery workspace: ${vmDir}`)
84
+ throw new VmSyncError(
85
+ `VM shutdown failed: ${(e as Error).message}`,
86
+ recovery,
87
+ e,
88
+ )
89
+ }
90
+ if (preserve) {
91
+ log(`VM workspace retained for recovery: ${vmDir}`)
92
+ // No tokens or process environment are persisted in this host-owned note.
93
+ try {
94
+ await writeFile(
95
+ join(vmDir, 'recovery.json'),
96
+ JSON.stringify(
97
+ {
98
+ state: 'sync-incomplete',
99
+ createdAt: new Date().toISOString(),
100
+ error:
101
+ failure instanceof Error
102
+ ? failure.message
103
+ : failure
104
+ ? String(failure)
105
+ : undefined,
106
+ result,
107
+ },
108
+ null,
109
+ 2,
110
+ ) + '\n',
111
+ { mode: 0o600 },
112
+ )
113
+ } catch (e) {
114
+ throw new VmSyncError(
115
+ `Writing recovery note failed: ${(e as Error).message}`,
116
+ recovery,
117
+ e,
118
+ )
119
+ }
120
+ if (result) result = { ...result, recovery }
121
+ }
122
+ if (failure !== undefined)
123
+ throw new VmSyncError(
124
+ failure instanceof Error ? failure.message : String(failure),
125
+ recovery,
126
+ failure,
127
+ )
128
+ return result!
129
+ }
130
+
131
+ /**
132
+ * Git in the guest trusts the work directory. Its files keep the host user's
133
+ * uid (mke2fs -d copies ownership) while the agent runs as root, so without
134
+ * this every git command in /workspace stops at "detected dubious ownership".
135
+ * Set through git's environment config, so it needs no change to the image.
136
+ */
137
+ export const guestGitEnvironment = (work = '/workspace') => ({
138
+ GIT_CONFIG_COUNT: '1',
139
+ GIT_CONFIG_KEY_0: 'safe.directory',
140
+ GIT_CONFIG_VALUE_0: work,
141
+ })
142
+
143
+ /** Env for the guest: a fixed baseline plus only what the adapter or profile added; never the host env. */
144
+ export function guestEnvironment(
145
+ hostEnv: Record<string, string | undefined>,
146
+ baseline: Record<string, string | undefined>,
147
+ allowlist?: string[],
148
+ ) {
149
+ const out: Record<string, string> = {
150
+ PATH: GUEST_PATH,
151
+ HOME: '/root',
152
+ TERM: 'xterm-256color',
153
+ LANG: 'C.UTF-8',
154
+ ...guestGitEnvironment(),
155
+ }
156
+ for (const [k, v] of Object.entries(hostEnv)) {
157
+ if (v === undefined || k in out) continue
158
+ const addedByAdapter = baseline[k] !== v
159
+ if (addedByAdapter || allowlist?.includes(k)) out[k] = v
160
+ }
161
+ return out
162
+ }
163
+
164
+ /** Moved to isolation (the runner side needs it without the agent session code). */
165
+ export { requireVm }
166
+
167
+ export async function startVmChild(opts: {
168
+ configDir: string
169
+ sessionId: string
170
+ cwd: string
171
+ binary: string
172
+ args: string[]
173
+ env: Record<string, string>
174
+ cols: number
175
+ rows: number
176
+ log?: (line: string) => void
177
+ /** The station, for Claude Code on the person's own subscription. */
178
+ station?: {
179
+ config: StationConfig
180
+ /** Where its decisions go (JSON lines). */
181
+ auditLog: string
182
+ /** Where the claude route goes; tests point it at a stand-in. */
183
+ upstream?: string
184
+ /** Where the hosts the station opens are; tests point them at a stand-in. */
185
+ opened?: (host: string) => Promise<Upstream>
186
+ }
187
+ }): Promise<VmChild> {
188
+ const vm = await requireVm(opts.configDir, opts.log)
189
+ const kb = Number(
190
+ execFileSync('du', ['-sk', opts.cwd], { encoding: 'utf8' }).split(/\s+/)[0],
191
+ )
192
+ if (kb > 2 * 1024 * 1024)
193
+ throw new Error(
194
+ `${opts.cwd} is larger than 2 GiB; --vm copies the working directory into the VM`,
195
+ )
196
+ // What the guest starts from: the baseline every later sync is compared to.
197
+ const baseline = await hostManifest(opts.cwd)
198
+ const vmDir = await mkdtemp(
199
+ join(await privateSessionsDirectory(), `${opts.sessionId}.vm-`),
200
+ )
201
+ const iso: Isolation = await startVm(vm, opts.cwd, vmDir, opts.log)
202
+ const fail = async (e: unknown) => {
203
+ await iso.close()
204
+ throw e
205
+ }
206
+ try {
207
+ // Hooks run inside the guest and arrive here over vsock; only this session's id is accepted.
208
+ iso.onGuestMessage!(9100, (m) => {
209
+ if (
210
+ m?.op !== 'hook' ||
211
+ m.session !== opts.sessionId ||
212
+ typeof m.raw !== 'string'
213
+ )
214
+ return
215
+ let raw: unknown
216
+ try {
217
+ raw = JSON.parse(m.raw)
218
+ } catch {
219
+ return
220
+ }
221
+ const s = createConnection(socketPath(opts.sessionId))
222
+ s.on('error', () => {})
223
+ s.on('connect', () =>
224
+ s.end(
225
+ JSON.stringify({
226
+ type: 'hook',
227
+ agent: String(m.agent ?? 'claude'),
228
+ raw,
229
+ }) + '\n',
230
+ ),
231
+ )
232
+ })
233
+ // Small files the host prepared (hook settings, a script agent) are copied into the guest tmpfs.
234
+ let n = 0
235
+ const upload = async (path: string, executable: boolean) => {
236
+ if (!isAbsolute(path)) return undefined
237
+ const st = await stat(path).catch(() => null)
238
+ if (!st?.isFile() || st.size > MAX_UPLOAD) return undefined
239
+ const target = `/tmp/mirab/${n++}-${basename(path)}`
240
+ await iso.put(
241
+ target,
242
+ await readFile(path, 'utf8'),
243
+ executable ? 0o700 : 0o600,
244
+ )
245
+ return target
246
+ }
247
+ const head = await readFile(opts.binary, {
248
+ encoding: 'latin1',
249
+ flag: 'r',
250
+ }).then(
251
+ (t) => t.slice(0, 2),
252
+ () => '',
253
+ )
254
+ const program =
255
+ (head === '#!' && (await upload(opts.binary, true))) ||
256
+ basename(opts.binary)
257
+ const args: string[] = []
258
+ for (const a of opts.args) args.push((await upload(a, false)) ?? a)
259
+ // The station is the VM's way out: Claude Code on the person's own
260
+ // subscription (the VM holds a placeholder, the station adds the token from
261
+ // harar), and the keys the project's .env names, as placeholders the
262
+ // station swaps on their own hosts. Any program named claude gets the
263
+ // route; whatever it is, it can reach only api.anthropic.com/v1/messages.
264
+ let env = opts.env
265
+ let station: VmStation | undefined
266
+ const isClaude = basename(opts.binary) === 'claude'
267
+ if (opts.station) {
268
+ const claude = isClaude
269
+ ? claudeRoute(
270
+ hararVault(opts.station.config.harar.socket),
271
+ opts.station.config.models.claude?.token,
272
+ opts.station.upstream,
273
+ )
274
+ : undefined
275
+ station = await vmStation({
276
+ project: opts.cwd,
277
+ station: opts.station.config,
278
+ configDir: opts.configDir,
279
+ // No harar identity for spawn sessions yet (#2): named by session.
280
+ zone: `vm-${opts.sessionId}`,
281
+ auditLog: opts.station.auditLog,
282
+ routes: claude ? { claude } : {},
283
+ allow: [],
284
+ agent: projectAgent(opts.cwd),
285
+ upstream: opts.station.opened,
286
+ })
287
+ for (const note of station.notes) opts.log?.(note)
288
+ await station.attach(iso)
289
+ env = { ...env, ...station.env }
290
+ if (claude) env = withClaudeRoute(env, GUEST_PROXY)
291
+ }
292
+ if (isClaude && !opts.station?.config.models.claude)
293
+ opts.log?.(
294
+ 'Claude Code in the VM has no login: add models.claude.token to station.json (the harar secret from `claude setup-token`)',
295
+ )
296
+ const pty = await iso.pty!({
297
+ argv: [program, ...args],
298
+ env,
299
+ cwd: '/workspace',
300
+ cols: opts.cols,
301
+ rows: opts.rows,
302
+ })
303
+ const conflictDir = join(
304
+ await privateSessionsDirectory(),
305
+ `${opts.sessionId}.conflicts`,
306
+ )
307
+ // One sync at a time; each one carries only what changed since the last.
308
+ let chain: Promise<unknown> = Promise.resolve()
309
+ const sync = () => {
310
+ const next = chain.then(async () => {
311
+ const r = await syncBack({
312
+ root: opts.cwd,
313
+ guestRoot: '/workspace',
314
+ files: iso.files!,
315
+ baseline,
316
+ conflictDir,
317
+ })
318
+ opts.log?.(`synced ${describeSync(r)}`)
319
+ return r
320
+ })
321
+ chain = next.catch(() => {})
322
+ return next
323
+ }
324
+ let disposed: Promise<SyncResult | undefined> | undefined
325
+ let recovery: RecoveryLocation | undefined
326
+ const dispose = async () => {
327
+ await station?.close()
328
+ try {
329
+ const result = await finishVmSync(sync, iso, vmDir, opts.log)
330
+ recovery = result.recovery
331
+ return result
332
+ } catch (e) {
333
+ if (e instanceof VmSyncError) recovery = e.recovery
334
+ throw e
335
+ }
336
+ }
337
+ return {
338
+ pid: -1,
339
+ write: (d) => pty.write(d),
340
+ resize: (c, r) => pty.resize(c, r),
341
+ onData: (cb) => pty.onData(cb),
342
+ // guestPty replays an exit that happened before this listener.
343
+ onExit: (cb) => pty.onExit((code) => cb({ exitCode: code })),
344
+ pause() {},
345
+ resume() {},
346
+ kill: () => pty.close(),
347
+ sync,
348
+ dispose: () => (disposed ??= dispose()),
349
+ get recovery() {
350
+ return recovery
351
+ },
352
+ }
353
+ } catch (e) {
354
+ return fail(e)
355
+ }
356
+ }