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
package/src/images.ts
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// Agent images (docs/architecture.md section 5b): an agent's `packages`,
|
|
2
|
+
// installed with apt on the release's base image, built once and cached under
|
|
3
|
+
// the hash of the base and the package list; updated by booting the image and
|
|
4
|
+
// running apt again. Both happen in a mirab VM whose only way out is the
|
|
5
|
+
// station, with only Ubuntu's mirrors on its whitelist, so vz and Firecracker
|
|
6
|
+
// build and update images the same way: each VM has its own copy of the root
|
|
7
|
+
// disk, and the one apt ran in is the one kept.
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import {
|
|
10
|
+
link,
|
|
11
|
+
mkdir,
|
|
12
|
+
mkdtemp,
|
|
13
|
+
open,
|
|
14
|
+
readdir,
|
|
15
|
+
readFile,
|
|
16
|
+
realpath,
|
|
17
|
+
rename,
|
|
18
|
+
rm,
|
|
19
|
+
stat,
|
|
20
|
+
utimes,
|
|
21
|
+
writeFile,
|
|
22
|
+
} from 'node:fs/promises'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { startVm, type VmConfig } from './isolation'
|
|
25
|
+
import type { Isolation } from './isolation/session'
|
|
26
|
+
import { EGRESS_GUEST_PORT, EGRESS_HOST_PORT } from './isolation/vz'
|
|
27
|
+
import { startEgress } from 'rixse'
|
|
28
|
+
|
|
29
|
+
/** Where apt goes, over HTTPS: the station lets nothing else out. */
|
|
30
|
+
export const UBUNTU_MIRRORS = [
|
|
31
|
+
'archive.ubuntu.com',
|
|
32
|
+
'security.ubuntu.com',
|
|
33
|
+
'ports.ubuntu.com',
|
|
34
|
+
]
|
|
35
|
+
/** How long one apt step may take (a big install included). */
|
|
36
|
+
const APT_TIMEOUT_MS = 30 * 60_000
|
|
37
|
+
const ROOT_ENV = {
|
|
38
|
+
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
|
|
39
|
+
HOME: '/root',
|
|
40
|
+
DEBIAN_FRONTEND: 'noninteractive',
|
|
41
|
+
LANG: 'C.UTF-8',
|
|
42
|
+
}
|
|
43
|
+
/** In the VM: apt's mirrors over HTTPS, and the station as its proxy. */
|
|
44
|
+
const APT_SETUP = [
|
|
45
|
+
"sed -i 's|http://|https://|g' /etc/apt/sources.list.d/ubuntu.sources",
|
|
46
|
+
`printf 'Acquire::http::Proxy "http://127.0.0.1:${EGRESS_GUEST_PORT}";\\nAcquire::https::Proxy "http://127.0.0.1:${EGRESS_GUEST_PORT}";\\n' > /etc/apt/apt.conf.d/90mirab-station`,
|
|
47
|
+
].join(' && ')
|
|
48
|
+
|
|
49
|
+
export interface ImageMeta {
|
|
50
|
+
key: string
|
|
51
|
+
packages: string[]
|
|
52
|
+
base: string
|
|
53
|
+
builtAt: string
|
|
54
|
+
updatedAt: string
|
|
55
|
+
/** What the last update upgraded. */
|
|
56
|
+
upgraded: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const imagesDir = (configDir: string) => join(configDir, 'images')
|
|
60
|
+
|
|
61
|
+
/** The base image: where it is, its size, when it was written, and a hash of
|
|
62
|
+
* its first 4 MB (the filesystem's superblock and early blocks), without
|
|
63
|
+
* reading all 3 GB. A new base is a new key. */
|
|
64
|
+
export async function baseId(rootfs: string): Promise<string> {
|
|
65
|
+
const path = await realpath(rootfs)
|
|
66
|
+
const st = await stat(path)
|
|
67
|
+
const handle = await open(path, 'r')
|
|
68
|
+
try {
|
|
69
|
+
const head = Buffer.alloc(Math.min(st.size, 4 * 1024 * 1024))
|
|
70
|
+
await handle.read(head, 0, head.length, 0)
|
|
71
|
+
const sum = createHash('sha256').update(head).digest('hex').slice(0, 16)
|
|
72
|
+
return `${path}:${st.size}:${Math.floor(st.mtimeMs)}:${sum}`
|
|
73
|
+
} finally {
|
|
74
|
+
await handle.close()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A cache key for any build recipe: the base plus the recipe as canonical
|
|
79
|
+
* JSON (object keys sorted at every level), so equal recipes share a key. */
|
|
80
|
+
export function recipeKey(base: string, recipe: unknown): string {
|
|
81
|
+
const canon = (v: unknown): unknown =>
|
|
82
|
+
Array.isArray(v)
|
|
83
|
+
? v.map(canon)
|
|
84
|
+
: v && typeof v === 'object'
|
|
85
|
+
? Object.fromEntries(
|
|
86
|
+
Object.keys(v as object)
|
|
87
|
+
.sort()
|
|
88
|
+
.map((k) => [k, canon((v as Record<string, unknown>)[k])]),
|
|
89
|
+
)
|
|
90
|
+
: v
|
|
91
|
+
return createHash('sha256')
|
|
92
|
+
.update(JSON.stringify({ base, recipe: canon(recipe) }))
|
|
93
|
+
.digest('hex')
|
|
94
|
+
.slice(0, 32)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The cache key: the base and the package list, order and repeats aside. */
|
|
98
|
+
export function imageKey(base: string, packages: string[]): string {
|
|
99
|
+
const list = [...new Set(packages)].sort()
|
|
100
|
+
return createHash('sha256')
|
|
101
|
+
.update(JSON.stringify({ base, packages: list }))
|
|
102
|
+
.digest('hex')
|
|
103
|
+
.slice(0, 32)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CachedImage {
|
|
107
|
+
rootfs: string
|
|
108
|
+
meta: ImageMeta
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function cachedImage(
|
|
112
|
+
configDir: string,
|
|
113
|
+
key: string,
|
|
114
|
+
base?: string,
|
|
115
|
+
): Promise<CachedImage | undefined> {
|
|
116
|
+
const dir = join(imagesDir(configDir), key)
|
|
117
|
+
try {
|
|
118
|
+
const meta = JSON.parse(
|
|
119
|
+
await readFile(join(dir, 'meta.json'), 'utf8'),
|
|
120
|
+
) as ImageMeta
|
|
121
|
+
// Built on another base (a key collision, or a base changed in place): a miss.
|
|
122
|
+
if (base !== undefined && meta.base !== base) return undefined
|
|
123
|
+
const rootfs = join(dir, 'rootfs.ext4')
|
|
124
|
+
await stat(rootfs)
|
|
125
|
+
return { rootfs, meta }
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** In a build VM: tools other than apt (curl, rustup) reach the station too. */
|
|
132
|
+
export const BUILD_ENV = {
|
|
133
|
+
...ROOT_ENV,
|
|
134
|
+
HTTP_PROXY: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
|
|
135
|
+
HTTPS_PROXY: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
|
|
136
|
+
http_proxy: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
|
|
137
|
+
https_proxy: `http://127.0.0.1:${EGRESS_GUEST_PORT}`,
|
|
138
|
+
NO_PROXY: 'localhost,127.0.0.1',
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Runs one command as root in the VM; a failure carries the end of its output. */
|
|
142
|
+
export async function run(
|
|
143
|
+
iso: Pick<Isolation, 'exec'>,
|
|
144
|
+
argv: string[],
|
|
145
|
+
log?: (l: string) => void,
|
|
146
|
+
env: Record<string, string> = BUILD_ENV,
|
|
147
|
+
) {
|
|
148
|
+
const lines: string[] = []
|
|
149
|
+
const code = await iso.exec(argv, {
|
|
150
|
+
cwd: '/',
|
|
151
|
+
env,
|
|
152
|
+
timeoutMs: APT_TIMEOUT_MS,
|
|
153
|
+
signal: new AbortController().signal,
|
|
154
|
+
onLine: async (line) => {
|
|
155
|
+
lines.push(line)
|
|
156
|
+
if (lines.length > 400) lines.shift()
|
|
157
|
+
log?.(line)
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
if (code !== 0)
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${argv.slice(0, 2).join(' ')} failed (${code}): ${lines.slice(-8).join(' | ')}`,
|
|
163
|
+
)
|
|
164
|
+
return lines
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A build VM on `rootfs` whose only way out is the station, to the hosts in
|
|
168
|
+
* `allow` (default: Ubuntu's mirrors, for apt). `rootGiB` grows its root
|
|
169
|
+
* disk first; the build then grows the filesystem (resize2fs). */
|
|
170
|
+
export async function bootForBuild(o: {
|
|
171
|
+
configDir: string
|
|
172
|
+
vm: VmConfig
|
|
173
|
+
rootfs: string
|
|
174
|
+
key: string
|
|
175
|
+
allow?: string[]
|
|
176
|
+
rootGiB?: number
|
|
177
|
+
log?: (line: string) => void
|
|
178
|
+
}): Promise<{ iso: Isolation; cleanup: () => Promise<void> }> {
|
|
179
|
+
await mkdir(imagesDir(o.configDir), { recursive: true, mode: 0o700 })
|
|
180
|
+
const scratch = await mkdtemp(join(imagesDir(o.configDir), '.vm-'))
|
|
181
|
+
const work = join(scratch, 'work')
|
|
182
|
+
await mkdir(work)
|
|
183
|
+
const iso = await startVm(
|
|
184
|
+
{ ...o.vm, rootfs: o.rootfs, egress: 'block' },
|
|
185
|
+
work,
|
|
186
|
+
join(scratch, 'vm'),
|
|
187
|
+
o.log,
|
|
188
|
+
undefined,
|
|
189
|
+
{ rootGiB: o.rootGiB },
|
|
190
|
+
)
|
|
191
|
+
const cleanup = () => rm(scratch, { recursive: true, force: true })
|
|
192
|
+
try {
|
|
193
|
+
await iso.onGuestConnect!(EGRESS_HOST_PORT, (socketPath) =>
|
|
194
|
+
startEgress({
|
|
195
|
+
socketPath,
|
|
196
|
+
zone: `image-${o.key.slice(0, 12)}`,
|
|
197
|
+
allow: o.allow ?? UBUNTU_MIRRORS,
|
|
198
|
+
routes: {},
|
|
199
|
+
auditLog: join(o.configDir, 'egress.log'),
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
await run(iso, ['sh', '-c', APT_SETUP])
|
|
203
|
+
return { iso, cleanup }
|
|
204
|
+
} catch (e) {
|
|
205
|
+
await iso.close().catch(() => {})
|
|
206
|
+
await cleanup()
|
|
207
|
+
throw e
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Grows the running guest's root filesystem to fill its (grown) disk, while
|
|
212
|
+
* mounted. resize2fs can't: the kernel mounts the root as /dev/root, so it
|
|
213
|
+
* doesn't see /dev/vda as mounted and refuses an exclusive open. This is the
|
|
214
|
+
* ioctl resize2fs itself uses online (EXT4_IOC_RESIZE_FS). */
|
|
215
|
+
export const GROW_ROOT_FS =
|
|
216
|
+
"python3 -c \"import fcntl,os,struct; f=os.open('/',os.O_RDONLY); " +
|
|
217
|
+
"n=int(open('/sys/block/vda/size').read())*512//os.statvfs('/').f_bsize; " +
|
|
218
|
+
"fcntl.ioctl(f,0x40086610,struct.pack('Q',n))\""
|
|
219
|
+
|
|
220
|
+
/** Stops the VM and keeps its root disk as the image in `dir`, moving any
|
|
221
|
+
* image there now aside as the previous one. */
|
|
222
|
+
export async function keep(
|
|
223
|
+
iso: Pick<Isolation, 'exec' | 'close'>,
|
|
224
|
+
dir: string,
|
|
225
|
+
meta: object,
|
|
226
|
+
opts: { previous?: boolean } = {},
|
|
227
|
+
): Promise<string> {
|
|
228
|
+
// The station's proxy is only for building; sessions use their own station.
|
|
229
|
+
await run(iso, [
|
|
230
|
+
'sh',
|
|
231
|
+
'-c',
|
|
232
|
+
'rm -f /etc/apt/apt.conf.d/90mirab-station && sync',
|
|
233
|
+
])
|
|
234
|
+
const tag = `${process.pid}.${Date.now()}`
|
|
235
|
+
const part = join(dir, `rootfs.ext4.${tag}`)
|
|
236
|
+
try {
|
|
237
|
+
await iso.close({ keepRoot: part })
|
|
238
|
+
} catch (e) {
|
|
239
|
+
await rm(part, { force: true })
|
|
240
|
+
throw e
|
|
241
|
+
}
|
|
242
|
+
// The new image is whole before anything moves: the old one is kept by a
|
|
243
|
+
// second name, then replaced in one rename, so there is always an image.
|
|
244
|
+
const rootfs = join(dir, 'rootfs.ext4')
|
|
245
|
+
const previous = join(dir, 'previous.rootfs.ext4')
|
|
246
|
+
const metaPart = join(dir, `meta.json.${tag}`)
|
|
247
|
+
await writeFile(metaPart, JSON.stringify(meta, null, 2) + '\n')
|
|
248
|
+
await rm(previous, { force: true })
|
|
249
|
+
// Agent images keep the one before; rebuildable images (runners) don't.
|
|
250
|
+
if (opts.previous !== false) await link(rootfs, previous).catch(() => {})
|
|
251
|
+
await rename(part, rootfs)
|
|
252
|
+
await rename(metaPart, join(dir, 'meta.json'))
|
|
253
|
+
return rootfs
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The agent's image: from the cache, or built now. */
|
|
257
|
+
export async function agentImage(o: {
|
|
258
|
+
configDir: string
|
|
259
|
+
vm: VmConfig
|
|
260
|
+
packages: string[]
|
|
261
|
+
log?: (line: string) => void
|
|
262
|
+
}): Promise<string> {
|
|
263
|
+
const base = await baseId(o.vm.rootfs)
|
|
264
|
+
const key = imageKey(base, o.packages)
|
|
265
|
+
const hit = await cachedImage(o.configDir, key, base)
|
|
266
|
+
if (hit) {
|
|
267
|
+
// When it was last used, for `mirab image prune`.
|
|
268
|
+
const now = new Date()
|
|
269
|
+
await utimes(
|
|
270
|
+
join(imagesDir(o.configDir), key, 'meta.json'),
|
|
271
|
+
now,
|
|
272
|
+
now,
|
|
273
|
+
).catch(() => {})
|
|
274
|
+
return hit.rootfs
|
|
275
|
+
}
|
|
276
|
+
const packages = [...new Set(o.packages)].sort()
|
|
277
|
+
o.log?.(`building the agent's image: ${packages.join(' ')}`)
|
|
278
|
+
const dir = join(imagesDir(o.configDir), key)
|
|
279
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
280
|
+
const { iso, cleanup } = await bootForBuild({
|
|
281
|
+
...o,
|
|
282
|
+
rootfs: o.vm.rootfs,
|
|
283
|
+
key,
|
|
284
|
+
})
|
|
285
|
+
try {
|
|
286
|
+
await run(iso, ['apt-get', 'update'])
|
|
287
|
+
// Names only (the manifest's schema): apt takes them as arguments, no shell.
|
|
288
|
+
await run(
|
|
289
|
+
iso,
|
|
290
|
+
['apt-get', 'install', '-y', '--no-install-recommends', ...packages],
|
|
291
|
+
o.log,
|
|
292
|
+
)
|
|
293
|
+
await run(iso, ['apt-get', 'clean'])
|
|
294
|
+
const now = new Date().toISOString()
|
|
295
|
+
return await keep(iso, dir, {
|
|
296
|
+
key,
|
|
297
|
+
packages,
|
|
298
|
+
base,
|
|
299
|
+
builtAt: now,
|
|
300
|
+
updatedAt: now,
|
|
301
|
+
upgraded: [],
|
|
302
|
+
})
|
|
303
|
+
} catch (e) {
|
|
304
|
+
await iso.close().catch(() => {})
|
|
305
|
+
throw e
|
|
306
|
+
} finally {
|
|
307
|
+
await cleanup()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The packages a dry-run `apt-get -s upgrade` would upgrade. */
|
|
312
|
+
export function upgradesIn(lines: string[]): string[] {
|
|
313
|
+
return lines
|
|
314
|
+
.map((l) => /^Inst (\S+)/.exec(l)?.[1])
|
|
315
|
+
.filter((name): name is string => !!name)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Boots the agent's image and upgrades it in place when apt has anything
|
|
319
|
+
* newer; the image before stays as the previous one. */
|
|
320
|
+
export async function updateImage(o: {
|
|
321
|
+
configDir: string
|
|
322
|
+
vm: VmConfig
|
|
323
|
+
packages: string[]
|
|
324
|
+
log?: (line: string) => void
|
|
325
|
+
}): Promise<{ rootfs: string; upgraded: string[] }> {
|
|
326
|
+
const base = await baseId(o.vm.rootfs)
|
|
327
|
+
const key = imageKey(base, o.packages)
|
|
328
|
+
const hit = await cachedImage(o.configDir, key, base)
|
|
329
|
+
if (!hit) return { rootfs: await agentImage(o), upgraded: [] }
|
|
330
|
+
const dir = join(imagesDir(o.configDir), key)
|
|
331
|
+
const { iso, cleanup } = await bootForBuild({ ...o, rootfs: hit.rootfs, key })
|
|
332
|
+
try {
|
|
333
|
+
await run(iso, ['apt-get', 'update'])
|
|
334
|
+
const upgraded = upgradesIn(await run(iso, ['apt-get', '-s', 'upgrade']))
|
|
335
|
+
if (!upgraded.length) {
|
|
336
|
+
await iso.close()
|
|
337
|
+
const now = new Date()
|
|
338
|
+
await utimes(join(dir, 'meta.json'), now, now).catch(() => {})
|
|
339
|
+
return { rootfs: hit.rootfs, upgraded }
|
|
340
|
+
}
|
|
341
|
+
o.log?.(`upgrading: ${upgraded.join(' ')}`)
|
|
342
|
+
await run(
|
|
343
|
+
iso,
|
|
344
|
+
['apt-get', 'upgrade', '-y', '-o', 'Dpkg::Options::=--force-confold'],
|
|
345
|
+
o.log,
|
|
346
|
+
)
|
|
347
|
+
await run(iso, ['apt-get', 'clean'])
|
|
348
|
+
const rootfs = await keep(iso, dir, {
|
|
349
|
+
...hit.meta,
|
|
350
|
+
updatedAt: new Date().toISOString(),
|
|
351
|
+
upgraded,
|
|
352
|
+
})
|
|
353
|
+
return { rootfs, upgraded }
|
|
354
|
+
} catch (e) {
|
|
355
|
+
await iso.close().catch(() => {})
|
|
356
|
+
throw e
|
|
357
|
+
} finally {
|
|
358
|
+
await cleanup()
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface ListedImage {
|
|
363
|
+
key: string
|
|
364
|
+
packages: string[]
|
|
365
|
+
/** Built on the base this mirab boots now; otherwise it is never used again. */
|
|
366
|
+
current: boolean
|
|
367
|
+
/** When it was built, updated or booted last. */
|
|
368
|
+
usedAt: Date
|
|
369
|
+
bytes: number
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Bytes on disk (images are sparse), not length: an image and the copy an update keeps (`previous.rootfs.ext4`), counted
|
|
373
|
+
* once where they are still the same file. */
|
|
374
|
+
async function imageBytes(dir: string) {
|
|
375
|
+
const now = await stat(join(dir, 'rootfs.ext4'))
|
|
376
|
+
const before = await stat(join(dir, 'previous.rootfs.ext4')).catch(
|
|
377
|
+
() => undefined,
|
|
378
|
+
)
|
|
379
|
+
return (
|
|
380
|
+
now.blocks * 512 +
|
|
381
|
+
(before && before.ino !== now.ino ? before.blocks * 512 : 0)
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const DAY_MS = 86_400_000
|
|
386
|
+
/** Used this recently, an image is never removed: a `mirab dev` that just
|
|
387
|
+
* found it in the cache may be about to boot it. */
|
|
388
|
+
const IN_USE_MS = 60 * 60 * 1000
|
|
389
|
+
|
|
390
|
+
/** Every cached agent image, newest use first. */
|
|
391
|
+
export async function listImages(
|
|
392
|
+
configDir: string,
|
|
393
|
+
currentBase: string,
|
|
394
|
+
): Promise<ListedImage[]> {
|
|
395
|
+
let names: string[] = []
|
|
396
|
+
try {
|
|
397
|
+
names = await readdir(imagesDir(configDir))
|
|
398
|
+
} catch {
|
|
399
|
+
return []
|
|
400
|
+
}
|
|
401
|
+
const out: ListedImage[] = []
|
|
402
|
+
for (const key of names) {
|
|
403
|
+
if (!/^[0-9a-f]{32}$/.test(key)) continue
|
|
404
|
+
const dir = join(imagesDir(configDir), key)
|
|
405
|
+
try {
|
|
406
|
+
const hit = await cachedImage(configDir, key)
|
|
407
|
+
if (!hit) continue
|
|
408
|
+
const meta = await stat(join(dir, 'meta.json'))
|
|
409
|
+
out.push({
|
|
410
|
+
key,
|
|
411
|
+
packages: hit.meta.packages,
|
|
412
|
+
current: hit.meta.base === currentBase,
|
|
413
|
+
usedAt: meta.mtime,
|
|
414
|
+
bytes: await imageBytes(dir),
|
|
415
|
+
})
|
|
416
|
+
} catch {
|
|
417
|
+
// Gone, or going, as it was read: not one to list.
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out.sort((a, b) => b.usedAt.getTime() - a.usedAt.getTime())
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* What `mirab image prune` removes: images built on a base this mirab no
|
|
425
|
+
* longer boots (after an upgrade they are never used again), images unused
|
|
426
|
+
* for `days` (at least one), and what a build left behind a day or more ago:
|
|
427
|
+
* scratch and an image directory that never got as far as its meta.json. An
|
|
428
|
+
* image used in the last hour always stays, and a VM boots from its own copy,
|
|
429
|
+
* so a running one is never touched. One that can't be removed is reported,
|
|
430
|
+
* and the rest go on.
|
|
431
|
+
*/
|
|
432
|
+
export async function pruneImages(o: {
|
|
433
|
+
configDir: string
|
|
434
|
+
currentBase: string
|
|
435
|
+
days: number
|
|
436
|
+
dryRun?: boolean
|
|
437
|
+
now?: Date
|
|
438
|
+
}): Promise<{
|
|
439
|
+
removed: { key: string; why: string; bytes: number }[]
|
|
440
|
+
failed: { key: string; error: string }[]
|
|
441
|
+
}> {
|
|
442
|
+
if (!Number.isInteger(o.days) || o.days < 1)
|
|
443
|
+
throw new Error('days is a whole number, at least 1')
|
|
444
|
+
const now = (o.now ?? new Date()).getTime()
|
|
445
|
+
const removed: { key: string; why: string; bytes: number }[] = []
|
|
446
|
+
const failed: { key: string; error: string }[] = []
|
|
447
|
+
const remove = async (key: string, why: string, bytes: number) => {
|
|
448
|
+
try {
|
|
449
|
+
if (!o.dryRun)
|
|
450
|
+
await rm(join(imagesDir(o.configDir), key), {
|
|
451
|
+
recursive: true,
|
|
452
|
+
force: true,
|
|
453
|
+
})
|
|
454
|
+
removed.push({ key, why, bytes })
|
|
455
|
+
} catch (e) {
|
|
456
|
+
failed.push({ key, error: (e as Error).message })
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const images = await listImages(o.configDir, o.currentBase)
|
|
460
|
+
for (const image of images) {
|
|
461
|
+
// Looked at again just before it goes: a use since the list keeps it.
|
|
462
|
+
const meta = await stat(
|
|
463
|
+
join(imagesDir(o.configDir), image.key, 'meta.json'),
|
|
464
|
+
).catch(() => undefined)
|
|
465
|
+
if (!meta || now - meta.mtimeMs < IN_USE_MS) continue
|
|
466
|
+
const idle = (now - meta.mtimeMs) / DAY_MS
|
|
467
|
+
const why = !image.current
|
|
468
|
+
? 'built on an older base'
|
|
469
|
+
: idle >= o.days
|
|
470
|
+
? `unused for ${Math.floor(idle)} days`
|
|
471
|
+
: undefined
|
|
472
|
+
if (why) await remove(image.key, why, image.bytes)
|
|
473
|
+
}
|
|
474
|
+
// What a build left: scratch, and an image's directory that never got its
|
|
475
|
+
// meta.json; only once a day old, so a build under way keeps its own.
|
|
476
|
+
const listed = new Set(images.map((i) => i.key))
|
|
477
|
+
let names: string[] = []
|
|
478
|
+
try {
|
|
479
|
+
names = await readdir(imagesDir(o.configDir))
|
|
480
|
+
} catch {}
|
|
481
|
+
// Sorted, so what it says is the same on every filesystem.
|
|
482
|
+
for (const name of names.sort()) {
|
|
483
|
+
const leftover =
|
|
484
|
+
name.startsWith('.vm-') ||
|
|
485
|
+
(/^[0-9a-f]{32}$/.test(name) &&
|
|
486
|
+
!listed.has(name) &&
|
|
487
|
+
!(await stat(join(imagesDir(o.configDir), name, 'meta.json')).catch(
|
|
488
|
+
() => undefined,
|
|
489
|
+
)))
|
|
490
|
+
if (!leftover) continue
|
|
491
|
+
const st = await stat(join(imagesDir(o.configDir), name)).catch(
|
|
492
|
+
() => undefined,
|
|
493
|
+
)
|
|
494
|
+
if (!st || now - st.mtimeMs < DAY_MS) continue
|
|
495
|
+
await remove(name, 'left by a build that stopped', 0)
|
|
496
|
+
}
|
|
497
|
+
return { removed, failed }
|
|
498
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// What agent projects and runner definitions import from mirab.
|
|
2
|
+
export { agentNameProblem, defineAgent, defineTool } from './agent/define'
|
|
3
|
+
export type { Agent, Tool } from './agent/define'
|
|
4
|
+
export { loadManifest, manifestSchema, parseManifest } from './agent/manifest'
|
|
5
|
+
export type { Manifest } from './agent/manifest'
|
|
6
|
+
export {
|
|
7
|
+
defineRunner,
|
|
8
|
+
RUNNER_PLATFORMS,
|
|
9
|
+
RUNNER_SETPRIV,
|
|
10
|
+
RUNNER_USER,
|
|
11
|
+
TOOLSETS,
|
|
12
|
+
} from './runner/define'
|
|
13
|
+
export type {
|
|
14
|
+
Forge,
|
|
15
|
+
RunnerDefinition,
|
|
16
|
+
RunnerFile,
|
|
17
|
+
RunnerInput,
|
|
18
|
+
RunnerPlatform,
|
|
19
|
+
RunnerStep,
|
|
20
|
+
RunnerTicket,
|
|
21
|
+
} from './runner/define'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync } from 'node:fs'
|
|
2
|
+
import { delimiter, resolve } from 'node:path'
|
|
3
|
+
import { shellQuote } from './spawn-adapters/types'
|
|
4
|
+
|
|
5
|
+
export function mirabInvocation(): string[] {
|
|
6
|
+
return import.meta.url.includes('$bunfs')
|
|
7
|
+
? [process.execPath]
|
|
8
|
+
: [process.execPath, 'run', new URL('./mirab.ts', import.meta.url).pathname]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Prefer plain mirab only when PATH selects this exact mirab. */
|
|
12
|
+
export function promptMirab(command: string[], path = process.env.PATH ?? '') {
|
|
13
|
+
const target = command.length === 1 ? command[0] : command.at(-1)!
|
|
14
|
+
for (const dir of path.split(delimiter)) {
|
|
15
|
+
const candidate = resolve(
|
|
16
|
+
dir || '.',
|
|
17
|
+
process.platform === 'win32' ? 'mirab.cmd' : 'mirab',
|
|
18
|
+
)
|
|
19
|
+
let real: string
|
|
20
|
+
try {
|
|
21
|
+
accessSync(candidate, constants.X_OK)
|
|
22
|
+
real = realpathSync(candidate)
|
|
23
|
+
} catch {
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
if (realpathSync(target) === real) return 'mirab'
|
|
28
|
+
} catch {
|
|
29
|
+
/* A broken launcher still shadows ours. */
|
|
30
|
+
}
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
return command.map(shellQuote).join(' ')
|
|
34
|
+
}
|