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.
- package/LICENSE +201 -0
- package/README.md +223 -3
- package/package.json +56 -7
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
- 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
|
+
}
|
package/src/spawn-vm.ts
ADDED
|
@@ -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
|
+
}
|