mirab 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -2
  3. package/package.json +58 -4
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
@@ -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
+ }