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,104 @@
1
+ // How a station channel reaches the agent in its VM: one JSON line each way
2
+ // on the guest pty, the same code on both ends. In: a message from another
3
+ // member. Out: what the agent says, prefixed so nothing else the guest prints
4
+ // can pass for it. The host checks every line it reads; the guest is
5
+ // untrusted, and so is every message.
6
+ import { StringDecoder } from 'node:string_decoder'
7
+ import { z } from 'zod'
8
+ import { MAX_MESSAGE } from './local'
9
+
10
+ /** What the guest prints before each thing the agent says. */
11
+ export const SAY_LINE = 'mirab-say '
12
+
13
+ export const toAgent = z.strictObject({
14
+ id: z.string().min(1).max(64),
15
+ from: z.string().min(1).max(512),
16
+ text: z.string(),
17
+ })
18
+ export type ToAgent = z.infer<typeof toAgent>
19
+
20
+ /** What the guest says, cut to fit a message (with a marker), never dropped. */
21
+ export function fitted(text: string): string {
22
+ if (Buffer.byteLength(text) <= MAX_MESSAGE) return text
23
+ const marker = ' [cut: too long for one message]'
24
+ let cut = Buffer.from(text)
25
+ .subarray(0, MAX_MESSAGE - marker.length - 4)
26
+ .toString()
27
+ cut = cut.replace(/\uFFFD$/, '')
28
+ return cut + marker
29
+ }
30
+
31
+ export const fromAgent = z.strictObject({
32
+ text: z.string().refine((t) => Buffer.byteLength(t) <= MAX_MESSAGE, {
33
+ message: `a message is at most ${MAX_MESSAGE} bytes`,
34
+ }),
35
+ /** Said about the chat, not in it. */
36
+ note: z.literal(true).optional(),
37
+ /** The message this finishes: its reply, or why there is none. */
38
+ answers: z.string().min(1).max(64).optional(),
39
+ })
40
+ export type FromAgent = z.infer<typeof fromAgent>
41
+
42
+ export const encodeToAgent = (m: ToAgent) => JSON.stringify(m) + '\n'
43
+ export const encodeFromAgent = (m: FromAgent) =>
44
+ SAY_LINE + JSON.stringify(m) + '\n'
45
+
46
+ /** A line the guest printed: what the agent said, or undefined for anything else. */
47
+ export function decodeFromAgent(line: string): FromAgent | undefined {
48
+ if (!line.startsWith(SAY_LINE)) return undefined
49
+ try {
50
+ const parsed = fromAgent.safeParse(JSON.parse(line.slice(SAY_LINE.length)))
51
+ return parsed.success ? parsed.data : undefined
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ /** A line the host wrote: a message for the agent, or undefined. */
58
+ export function decodeToAgent(line: string): ToAgent | undefined {
59
+ try {
60
+ const parsed = toAgent.safeParse(JSON.parse(line))
61
+ return parsed.success ? parsed.data : undefined
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
67
+ /** A message's text as a terminal may show it: no escape sequences, no
68
+ * carriage returns, no control characters but newline and tab, so what
69
+ * someone else wrote can't move the cursor, set the title or the clipboard,
70
+ * or pass for a line the person typed. */
71
+ export function printable(text: string): string {
72
+ return (
73
+ text
74
+ // Controls, DEL and C1; bidi overrides and isolates, zero-width marks,
75
+ // line and paragraph separators: anything that hides or reorders text.
76
+ .replace(
77
+ /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g,
78
+ '�',
79
+ )
80
+ // A line it starts is indented, so it can't stand as one of the person's.
81
+ .replace(/\n/g, '\n ')
82
+ )
83
+ }
84
+
85
+ /** Splits a byte stream into lines (`\r\n` or `\n`), holding the tail. A
86
+ * character split across chunks stays whole. The limit allows for JSON's
87
+ * escapes (up to 6 characters for one) in a message at its largest. */
88
+ export function lineSplitter(
89
+ onLine: (line: string) => void,
90
+ max = 7 * MAX_MESSAGE,
91
+ ) {
92
+ let pending = ''
93
+ const decoder = new StringDecoder('utf8')
94
+ return (chunk: Buffer | string) => {
95
+ pending += typeof chunk === 'string' ? chunk : decoder.write(chunk)
96
+ let at: number
97
+ while ((at = pending.indexOf('\n')) >= 0) {
98
+ onLine(pending.slice(0, at).replace(/\r$/, ''))
99
+ pending = pending.slice(at + 1)
100
+ }
101
+ // A line longer than any message is noise: dropped, never buffered forever.
102
+ if (pending.length > max) pending = ''
103
+ }
104
+ }
@@ -0,0 +1,206 @@
1
+ // Live sync for `mirab dev`: the person's edits on the host reach the agent's
2
+ // VM as they save, and the agent's changes come back when the session ends.
3
+ //
4
+ // Into the VM, per path, against the baseline (the tree as the VM last got
5
+ // it): a change on the host is pushed only while the guest still matches the
6
+ // baseline. If the agent changed that path too, the person's version stays on
7
+ // the host and the agent's comes back as a conflict at the end (sync.ts),
8
+ // never silently overwritten either way. Host Git metadata is never pushed.
9
+ import { watch, type FSWatcher } from 'node:fs'
10
+ import { AnchoredDirectory } from './isolation/anchored-fs'
11
+ import type { Isolation } from './isolation/session'
12
+ import {
13
+ hostManifest,
14
+ protectedMetadata,
15
+ sameEntry,
16
+ validPath,
17
+ type Manifest,
18
+ } from './isolation/sync'
19
+
20
+ /** How long the host's edits settle before they are pushed. */
21
+ const SETTLE_MS = 250
22
+ const GUEST_ROOT = '/workspace'
23
+
24
+ export interface LiveSync {
25
+ /** Pushes whatever changed now (also what the watcher calls). */
26
+ push(): Promise<{ pushed: string[]; kept: string[] }>
27
+ /** Stops watching and waits for a push under way, so sync-back never
28
+ * runs beside one. */
29
+ stop(): Promise<void>
30
+ }
31
+
32
+ export function liveSync(o: {
33
+ project: string
34
+ iso: Pick<Isolation, 'put' | 'exec' | 'files'>
35
+ /** The tree as the VM got it; updated as paths are pushed (sync-back reads it). */
36
+ baseline: Manifest
37
+ log?: (line: string) => void
38
+ /** Off: only `push()` pushes (tests drive it by hand). */
39
+ watch?: boolean
40
+ }): LiveSync {
41
+ // What the host had when each path was last looked at.
42
+ const host = new Map(o.baseline)
43
+ let running: Promise<{ pushed: string[]; kept: string[] }> | undefined
44
+ let again = false
45
+ let stopped = false
46
+ const run = async (argv: string[]) => {
47
+ const code = await o.iso.exec(argv, {
48
+ cwd: GUEST_ROOT,
49
+ env: { PATH: '/usr/bin:/bin' },
50
+ timeoutMs: 30_000,
51
+ signal: new AbortController().signal,
52
+ onLine: async () => {},
53
+ })
54
+ if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
55
+ }
56
+ const once = async () => {
57
+ const pushed: string[] = []
58
+ const kept: string[] = []
59
+ if (stopped) return { pushed, kept }
60
+ const next = await hostManifest(o.project)
61
+ const changed = [...new Set([...host.keys(), ...next.keys()])].filter(
62
+ (p) =>
63
+ !sameEntry(host.get(p), next.get(p)) &&
64
+ validPath(p) &&
65
+ !protectedMetadata(p),
66
+ )
67
+ if (!changed.length) return { pushed, kept }
68
+ const guest: Manifest = new Map(
69
+ (await o.iso.files!.list(GUEST_ROOT)).map((e) => [e.p, e]),
70
+ )
71
+ /** The agent changed `p`, or anything under it. */
72
+ const paths = [...new Set([...guest.keys(), ...o.baseline.keys()])]
73
+ const agentTouched = (p: string) => {
74
+ if (!sameEntry(guest.get(p), o.baseline.get(p))) return true
75
+ // Only a directory has anything under it.
76
+ if (guest.get(p)?.k !== 'd' && o.baseline.get(p)?.k !== 'd') return false
77
+ const under = `${p}/`
78
+ for (const q of paths)
79
+ if (q.startsWith(under) && !sameEntry(guest.get(q), o.baseline.get(q)))
80
+ return true
81
+ return false
82
+ }
83
+ // Deletions deepest first, then directories and files parents first.
84
+ const order = [
85
+ ...changed
86
+ .filter((p) => !next.has(p))
87
+ .sort()
88
+ .reverse(),
89
+ ...changed.filter((p) => next.has(p)).sort(),
90
+ ]
91
+ const root = AnchoredDirectory.open(o.project)
92
+ try {
93
+ for (const p of order) {
94
+ const now = next.get(p)
95
+ const before = host.get(p)
96
+ if (now) host.set(p, now)
97
+ else host.delete(p)
98
+ const was = guest.get(p)
99
+ // Removing a directory, or one of a different kind in the way,
100
+ // would take whatever the agent made inside it.
101
+ const removes = !now || (was && was.k !== now.k)
102
+ if (agentTouched(p) || now?.k === 'l') {
103
+ // The agent changed it too (the person's version waits on the
104
+ // host for sync-back), or it is a link, which stays as the VM has
105
+ // it until then.
106
+ kept.push(p)
107
+ continue
108
+ }
109
+ const at = `${GUEST_ROOT}/${p}`
110
+ try {
111
+ let entry = now
112
+ if (removes && was) await run(['rm', '-rf', '--', at])
113
+ if (now?.k === 'd') {
114
+ await run(['mkdir', '-p', '--', at])
115
+ await run(['chmod', (now.m & 0o777).toString(8), '--', at])
116
+ } else if (now?.k === 'f') {
117
+ // Read through the anchored root (never a link), and record the
118
+ // bytes that went, not the ones listed a moment before.
119
+ const parent = root.parent(p)
120
+ let read
121
+ try {
122
+ read = parent.read(p.split('/').at(-1)!, p)
123
+ } finally {
124
+ if (parent !== root) parent.close()
125
+ }
126
+ if (!read) throw new Error('it changed while it was read')
127
+ entry = read.entry
128
+ await o.iso.put(at, read.data, read.entry.m & 0o777)
129
+ await run(['chmod', (read.entry.m & 0o777).toString(8), '--', at])
130
+ host.set(p, read.entry)
131
+ }
132
+ if (entry) o.baseline.set(p, entry)
133
+ else o.baseline.delete(p)
134
+ pushed.push(p)
135
+ } catch (e) {
136
+ // One path that can't go doesn't hold up the rest; the next edit
137
+ // tries again, and sync-back settles it at the end.
138
+ if (before) host.set(p, before)
139
+ else host.delete(p)
140
+ kept.push(p)
141
+ o.log?.(`live sync: ${p}: ${(e as Error).message}`)
142
+ }
143
+ }
144
+ } finally {
145
+ root.close()
146
+ }
147
+ if (pushed.length) o.log?.(`synced into the VM: ${pushed.join(', ')}`)
148
+ if (kept.length)
149
+ o.log?.(`changed in the VM too, kept on this machine: ${kept.join(', ')}`)
150
+ return { pushed, kept }
151
+ }
152
+ // One push at a time; edits during one start another after it, even when
153
+ // it failed.
154
+ const push = () => {
155
+ if (running) {
156
+ again = true
157
+ return running
158
+ }
159
+ running = (async () => {
160
+ let result = { pushed: [] as string[], kept: [] as string[] }
161
+ do {
162
+ again = false
163
+ try {
164
+ result = await once()
165
+ } catch (e) {
166
+ if (!again || stopped) throw e
167
+ o.log?.(`live sync: ${(e as Error).message}`)
168
+ }
169
+ } while (again && !stopped)
170
+ return result
171
+ })().finally(() => (running = undefined))
172
+ return running
173
+ }
174
+ let timer: ReturnType<typeof setTimeout> | undefined
175
+ let watcher: FSWatcher | undefined
176
+ try {
177
+ if (o.watch !== false)
178
+ watcher = watch(o.project, { recursive: true }, (_, name) => {
179
+ if (name && (name === '.git' || String(name).startsWith('.git/')))
180
+ return
181
+ clearTimeout(timer)
182
+ timer = setTimeout(
183
+ () =>
184
+ void push().catch((e) =>
185
+ o.log?.(`live sync: ${(e as Error).message}`),
186
+ ),
187
+ SETTLE_MS,
188
+ )
189
+ })
190
+ watcher?.on('error', (e) => {
191
+ o.log?.(`live sync is off: ${e.message}`)
192
+ watcher?.close()
193
+ })
194
+ } catch (e) {
195
+ o.log?.(`live sync is off: ${(e as Error).message}`)
196
+ }
197
+ return {
198
+ push,
199
+ async stop() {
200
+ stopped = true
201
+ clearTimeout(timer)
202
+ watcher?.close()
203
+ await running?.catch(() => {})
204
+ },
205
+ }
206
+ }