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,243 @@
|
|
|
1
|
+
import type { ChildProcess, SpawnOptions } from 'node:child_process'
|
|
2
|
+
import { createConnection } from 'node:net'
|
|
3
|
+
import { Command, InvalidArgumentError } from 'commander'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { endedLogPath, type StartMessage } from './spawn-detach'
|
|
6
|
+
import { privateSessionsDirectory, socketPath } from './spawn-sessions'
|
|
7
|
+
|
|
8
|
+
/** Caller side of detached sessions: `mirab spawn --detach`, `mirab attach`,
|
|
9
|
+
* `mirab stop`. The worker side is spawn-detach.ts. */
|
|
10
|
+
export const DETACH_KEY = 0x1d // Ctrl-], as in telnet
|
|
11
|
+
const CTRL_C = 0x03
|
|
12
|
+
|
|
13
|
+
export function dimension(value: string) {
|
|
14
|
+
const number = Number(value)
|
|
15
|
+
if (!Number.isInteger(number) || number < 1 || number > 1000)
|
|
16
|
+
throw new InvalidArgumentError('must be an integer from 1 to 1000')
|
|
17
|
+
return number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The worker runs in its own session (setsid) with none of the caller's
|
|
21
|
+
* stdio, so a caller without a terminal (an agent's Bash tool) gets EOF as
|
|
22
|
+
* soon as `mirab spawn --detach` exits; only the IPC channel is shared. */
|
|
23
|
+
export function detachedSpawn(cwd: string): SpawnOptions {
|
|
24
|
+
return { cwd, detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }
|
|
25
|
+
}
|
|
26
|
+
/** Resolves with the session id once the socket listens and the agent has
|
|
27
|
+
* started, then lets go of the worker. */
|
|
28
|
+
export async function detachedReady(worker: ChildProcess): Promise<string> {
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
30
|
+
try {
|
|
31
|
+
const result = await new Promise<StartMessage>((resolve, reject) => {
|
|
32
|
+
timer = setTimeout(() => {
|
|
33
|
+
worker.kill('SIGTERM')
|
|
34
|
+
reject(new Error('The detached session did not start within 30 s'))
|
|
35
|
+
}, 30000)
|
|
36
|
+
worker.on('message', (message: StartMessage) => {
|
|
37
|
+
if (message?.type === 'ready' || message?.type === 'failed')
|
|
38
|
+
resolve(message)
|
|
39
|
+
})
|
|
40
|
+
worker.once('error', reject)
|
|
41
|
+
worker.once('exit', (code) =>
|
|
42
|
+
reject(
|
|
43
|
+
new Error(`The detached session exited while starting (${code})`),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
if (result.type === 'failed') throw new Error(result.error)
|
|
48
|
+
return result.id
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
worker.removeAllListeners()
|
|
52
|
+
if (worker.connected) worker.disconnect()
|
|
53
|
+
worker.unref()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function connect(id: string, directory: string) {
|
|
58
|
+
const socket = createConnection(socketPath(id, directory))
|
|
59
|
+
return socket
|
|
60
|
+
}
|
|
61
|
+
function sessionError(id: string, error: NodeJS.ErrnoException) {
|
|
62
|
+
return error.code === 'ENOENT' || error.code === 'ECONNREFUSED'
|
|
63
|
+
? new Error(`No live session ${id}`)
|
|
64
|
+
: error
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Replays the session's recent output, then streams it live. Interactive
|
|
68
|
+
* viewers type into the agent through the session's injection queue. */
|
|
69
|
+
export async function attachSession(id: string, watch: boolean) {
|
|
70
|
+
const directory = await privateSessionsDirectory()
|
|
71
|
+
const terminal = !!process.stdin.isTTY && !!process.stdout.isTTY
|
|
72
|
+
if (!watch && !terminal)
|
|
73
|
+
throw new Error(
|
|
74
|
+
'mirab attach needs a terminal; use mirab attach --watch to stream output read-only',
|
|
75
|
+
)
|
|
76
|
+
const size = () => ({
|
|
77
|
+
cols: process.stdout.columns || 80,
|
|
78
|
+
rows: process.stdout.rows || 24,
|
|
79
|
+
})
|
|
80
|
+
const socket = connect(id, directory)
|
|
81
|
+
const frame = (value: object) => {
|
|
82
|
+
if (socket.writable) socket.write(JSON.stringify(value) + '\n')
|
|
83
|
+
}
|
|
84
|
+
const wasRaw = process.stdin.isRaw ?? false
|
|
85
|
+
let raw = false
|
|
86
|
+
let byKey = false
|
|
87
|
+
const keys = watch ? [DETACH_KEY, CTRL_C] : [DETACH_KEY]
|
|
88
|
+
const input = (data: Buffer) => {
|
|
89
|
+
let at = -1
|
|
90
|
+
for (const key of keys) {
|
|
91
|
+
const index = data.indexOf(key)
|
|
92
|
+
if (index >= 0 && (at < 0 || index < at)) at = index
|
|
93
|
+
}
|
|
94
|
+
const typed = at < 0 ? data : data.subarray(0, at)
|
|
95
|
+
if (!watch && typed.length)
|
|
96
|
+
frame({ t: 'i', d: Buffer.from(typed).toString('base64') })
|
|
97
|
+
if (at >= 0) {
|
|
98
|
+
byKey = true
|
|
99
|
+
socket.end()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const resized = () => frame({ t: 'r', ...size() })
|
|
103
|
+
const drain = () => socket.resume()
|
|
104
|
+
const cleanup = () => {
|
|
105
|
+
process.stdin.off('data', input)
|
|
106
|
+
process.off('SIGWINCH', resized)
|
|
107
|
+
process.stdout.off('drain', drain)
|
|
108
|
+
if (raw) process.stdin.setRawMode(wasRaw)
|
|
109
|
+
process.stdin.pause()
|
|
110
|
+
}
|
|
111
|
+
await new Promise<void>((resolve, reject) => {
|
|
112
|
+
let header: Buffer | undefined = Buffer.alloc(0)
|
|
113
|
+
socket.on('connect', () =>
|
|
114
|
+
socket.write(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
type: 'attach',
|
|
117
|
+
mode: watch ? 'watch' : 'interactive',
|
|
118
|
+
...(watch ? {} : size()),
|
|
119
|
+
}) + '\n',
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
socket.on('error', (error) => {
|
|
123
|
+
cleanup()
|
|
124
|
+
const failure = sessionError(id, error)
|
|
125
|
+
if (failure === error) return reject(failure)
|
|
126
|
+
// An ended session: show the output it left behind.
|
|
127
|
+
const path = endedLogPath(directory, id)
|
|
128
|
+
readFile(path).then(
|
|
129
|
+
(log) => {
|
|
130
|
+
process.stdout.write(log)
|
|
131
|
+
process.stderr.write(
|
|
132
|
+
`\r\n[${id} has ended; its output is kept in ${path}]\r\n`,
|
|
133
|
+
)
|
|
134
|
+
resolve()
|
|
135
|
+
},
|
|
136
|
+
() => reject(failure),
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
const show = (data: Buffer) => {
|
|
140
|
+
if (data.length && !process.stdout.write(data)) socket.pause()
|
|
141
|
+
}
|
|
142
|
+
socket.on('data', (data: Buffer) => {
|
|
143
|
+
if (!header) return show(data)
|
|
144
|
+
header = Buffer.concat([header, data])
|
|
145
|
+
const end = header.indexOf(10)
|
|
146
|
+
if (end < 0) return
|
|
147
|
+
const reply = JSON.parse(header.subarray(0, end).toString())
|
|
148
|
+
const rest = header.subarray(end + 1)
|
|
149
|
+
header = undefined
|
|
150
|
+
if (reply.error) {
|
|
151
|
+
socket.destroy()
|
|
152
|
+
cleanup()
|
|
153
|
+
return reject(new Error(reply.error))
|
|
154
|
+
}
|
|
155
|
+
if (terminal) {
|
|
156
|
+
process.stdin.setRawMode(true)
|
|
157
|
+
raw = true
|
|
158
|
+
}
|
|
159
|
+
if (terminal || !watch) {
|
|
160
|
+
process.stdin.on('data', input)
|
|
161
|
+
process.stdin.resume()
|
|
162
|
+
}
|
|
163
|
+
if (!watch) process.on('SIGWINCH', resized)
|
|
164
|
+
process.stdout.on('drain', drain)
|
|
165
|
+
show(rest)
|
|
166
|
+
})
|
|
167
|
+
socket.on('close', () => {
|
|
168
|
+
cleanup()
|
|
169
|
+
if (byKey && terminal)
|
|
170
|
+
process.stderr.write(
|
|
171
|
+
`\r\n[detached from ${id}; it is still running]\r\n`,
|
|
172
|
+
)
|
|
173
|
+
resolve()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Takes a session offline and prints its final `exited` event. */
|
|
179
|
+
export async function stopSession(id: string, graceMs: number) {
|
|
180
|
+
const directory = await privateSessionsDirectory()
|
|
181
|
+
const socket = connect(id, directory)
|
|
182
|
+
const text = await new Promise<string>((resolve, reject) => {
|
|
183
|
+
let data = ''
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
socket.destroy()
|
|
186
|
+
reject(new Error(`Session ${id} did not stop`))
|
|
187
|
+
}, graceMs + 10000)
|
|
188
|
+
socket.setEncoding('utf8')
|
|
189
|
+
socket.on('connect', () =>
|
|
190
|
+
socket.write(JSON.stringify({ type: 'stop', graceMs }) + '\n'),
|
|
191
|
+
)
|
|
192
|
+
socket.on('data', (chunk: string) => (data += chunk))
|
|
193
|
+
socket.on('error', (error) => {
|
|
194
|
+
clearTimeout(timer)
|
|
195
|
+
reject(sessionError(id, error))
|
|
196
|
+
})
|
|
197
|
+
socket.on('close', () => {
|
|
198
|
+
clearTimeout(timer)
|
|
199
|
+
resolve(data)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
for (const line of text.split('\n')) {
|
|
203
|
+
if (!line) continue
|
|
204
|
+
const event = JSON.parse(line)
|
|
205
|
+
if (event.error) throw new Error(event.error)
|
|
206
|
+
if (event.type === 'exited') return line
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`Session ${id} closed without reporting its exit`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function detachCommands(program: Command) {
|
|
212
|
+
program
|
|
213
|
+
.command('attach <id>')
|
|
214
|
+
.description('watch and type into a detached agent session')
|
|
215
|
+
.option('--watch', 'stream output read-only (several may watch at once)')
|
|
216
|
+
.addHelpText(
|
|
217
|
+
'after',
|
|
218
|
+
'\nPress Ctrl-] to detach; the agent keeps running (mirab stop <id> ends it).\n' +
|
|
219
|
+
'Ctrl-C goes to the agent. With --watch, Ctrl-C also detaches.',
|
|
220
|
+
)
|
|
221
|
+
.action((id: string, opts: { watch?: boolean }) =>
|
|
222
|
+
attachSession(id, !!opts.watch),
|
|
223
|
+
)
|
|
224
|
+
program
|
|
225
|
+
.command('stop <id>')
|
|
226
|
+
.description(
|
|
227
|
+
'hang up a local agent session, SIGKILL it after the grace period, print its exit',
|
|
228
|
+
)
|
|
229
|
+
.option(
|
|
230
|
+
'--grace-ms <ms>',
|
|
231
|
+
'how long the agent has to exit after the hangup',
|
|
232
|
+
(value: string) => {
|
|
233
|
+
const number = Number(value)
|
|
234
|
+
if (!Number.isSafeInteger(number) || number < 0 || number > 60000)
|
|
235
|
+
throw new InvalidArgumentError('must be an integer from 0 to 60000')
|
|
236
|
+
return number
|
|
237
|
+
},
|
|
238
|
+
3000,
|
|
239
|
+
)
|
|
240
|
+
.action(async (id: string, opts: { graceMs: number }) => {
|
|
241
|
+
console.log(await stopSession(id, opts.graceMs))
|
|
242
|
+
})
|
|
243
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { agentEnvironment as baseEnvironment } from './spawn-native'
|
|
2
|
+
import {
|
|
3
|
+
accessSync,
|
|
4
|
+
constants,
|
|
5
|
+
openSync,
|
|
6
|
+
readSync,
|
|
7
|
+
closeSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { basename, delimiter, resolve } from 'node:path'
|
|
11
|
+
/** Resolve PATH without executing shims; aliases are expanded by the caller's shell. */
|
|
12
|
+
export function realAgent(
|
|
13
|
+
agent: string,
|
|
14
|
+
cwd = process.cwd(),
|
|
15
|
+
platform = process.platform,
|
|
16
|
+
): string {
|
|
17
|
+
// Native Windows spawning owns executable-suffix lookup.
|
|
18
|
+
if (platform === 'win32') return agent
|
|
19
|
+
let visited: string[] = []
|
|
20
|
+
try {
|
|
21
|
+
const chain = JSON.parse(process.env.MIRAB_SPAWN_CHAIN ?? '[]')
|
|
22
|
+
if (Array.isArray(chain))
|
|
23
|
+
visited = chain.filter((p): p is string => typeof p === 'string')
|
|
24
|
+
} catch {}
|
|
25
|
+
const candidates = agent.includes('/')
|
|
26
|
+
? [resolve(cwd, agent)]
|
|
27
|
+
: (process.env.PATH ?? '')
|
|
28
|
+
.split(delimiter)
|
|
29
|
+
.map((dir) => resolve(cwd, dir || '.', agent))
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
try {
|
|
32
|
+
accessSync(candidate, constants.X_OK)
|
|
33
|
+
const real = realpathSync(candidate)
|
|
34
|
+
if (visited.includes(real)) continue
|
|
35
|
+
if (
|
|
36
|
+
real === realpathSync(process.execPath) &&
|
|
37
|
+
basename(agent) === 'mirab'
|
|
38
|
+
)
|
|
39
|
+
continue
|
|
40
|
+
// Shell/script wrappers that re-enter mirab cannot be the native agent.
|
|
41
|
+
const fd = openSync(candidate, 'r')
|
|
42
|
+
const buffer = Buffer.alloc(8192)
|
|
43
|
+
let prefix: string
|
|
44
|
+
try {
|
|
45
|
+
prefix = buffer
|
|
46
|
+
.subarray(0, readSync(fd, buffer, 0, buffer.length, 0))
|
|
47
|
+
.toString()
|
|
48
|
+
} finally {
|
|
49
|
+
closeSync(fd)
|
|
50
|
+
}
|
|
51
|
+
if (prefix.startsWith('#!') && /\bmirab\s+spawn\b/.test(prefix)) continue
|
|
52
|
+
if (basename(real) === 'mirab' || basename(real) === 'mirab.js') continue
|
|
53
|
+
return candidate
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
throw new Error(
|
|
57
|
+
`No native ${agent} executable found on PATH (mirab spawn wrappers are skipped)`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
export function agentEnvironment(binary?: string, allowlist?: string[]) {
|
|
61
|
+
const env = baseEnvironment()
|
|
62
|
+
const baseline = ['PATH', 'HOME', 'TERM', 'LANG', 'USER', 'SHELL', 'TMPDIR']
|
|
63
|
+
for (const name of Object.keys(env)) {
|
|
64
|
+
if (allowlist && !allowlist.includes(name) && !baseline.includes(name))
|
|
65
|
+
delete env[name]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let chain: string[] = []
|
|
69
|
+
try {
|
|
70
|
+
const previous = JSON.parse(process.env.MIRAB_SPAWN_CHAIN ?? '[]')
|
|
71
|
+
if (Array.isArray(previous))
|
|
72
|
+
chain = previous.filter((p): p is string => typeof p === 'string')
|
|
73
|
+
} catch {}
|
|
74
|
+
if (binary && process.platform !== 'win32') {
|
|
75
|
+
const fd = openSync(binary, 'r'),
|
|
76
|
+
prefix = Buffer.alloc(2)
|
|
77
|
+
let script = false
|
|
78
|
+
try {
|
|
79
|
+
script = readSync(fd, prefix, 0, 2, 0) === 2 && prefix.toString() === '#!'
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(fd)
|
|
82
|
+
}
|
|
83
|
+
// Native executables cannot re-enter mirab as shell shims. Do not carry their
|
|
84
|
+
// paths into an agent's own later spawn command.
|
|
85
|
+
if (script) chain.push(realpathSync(binary))
|
|
86
|
+
else chain = []
|
|
87
|
+
}
|
|
88
|
+
env.MIRAB_SPAWN_CHAIN = JSON.stringify(chain)
|
|
89
|
+
return env
|
|
90
|
+
}
|