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.
- package/LICENSE +201 -0
- package/README.md +226 -2
- package/package.json +58 -4
- 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/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
|
+
}
|