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
|
@@ -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
|
+
}
|
package/src/dev-sync.ts
ADDED
|
@@ -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
|
+
}
|