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.
Files changed (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +223 -3
  3. package/package.json +56 -7
  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
  106. package/index.js +0 -1
@@ -0,0 +1,284 @@
1
+ // All sync mutations are relative to opened directories. Pathname checks alone
2
+ // cannot protect a later write when a directory is replaced during guest I/O.
3
+ import { createRequire } from 'node:module'
4
+ const requireNative = createRequire(import.meta.url)
5
+ const ffi = () => requireNative('bun:ffi') as typeof import('bun:ffi')
6
+ const ptr = (buffer: Buffer) => ffi().ptr(buffer)
7
+ import {
8
+ closeSync,
9
+ constants,
10
+ fchmodSync,
11
+ fstatSync,
12
+ openSync,
13
+ readSync,
14
+ writeFileSync,
15
+ } from 'node:fs'
16
+ import { createHash, randomBytes } from 'node:crypto'
17
+ import type { Entry } from './session'
18
+
19
+ const CLOEXEC = process.platform === 'darwin' ? 0x1000000 : 0x80000
20
+
21
+ function native() {
22
+ if (process.platform !== 'darwin' && process.platform !== 'linux')
23
+ throw new Error('anchored VM sync is supported only on macOS and Linux')
24
+ const errnoSymbol =
25
+ process.platform === 'darwin' ? '__error' : '__errno_location'
26
+ const statSymbol =
27
+ process.platform === 'darwin' && process.arch === 'x64'
28
+ ? 'fstatat$INODE64'
29
+ : 'fstatat'
30
+ const lib = ffi().dlopen(
31
+ process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6',
32
+ {
33
+ openat: { args: ['i32', 'ptr', 'i32', 'u32'], returns: 'i32' },
34
+ mkdirat: { args: ['i32', 'ptr', 'u32'], returns: 'i32' },
35
+ unlinkat: { args: ['i32', 'ptr', 'i32'], returns: 'i32' },
36
+ renameat: { args: ['i32', 'ptr', 'i32', 'ptr'], returns: 'i32' },
37
+ symlinkat: { args: ['ptr', 'i32', 'ptr'], returns: 'i32' },
38
+ readlinkat: { args: ['i32', 'ptr', 'ptr', 'u64'], returns: 'i64' },
39
+ [statSymbol]: { args: ['i32', 'ptr', 'ptr', 'i32'], returns: 'i32' },
40
+ [errnoSymbol]: { args: [], returns: 'ptr' },
41
+ } as const,
42
+ )
43
+ return {
44
+ ...lib.symbols,
45
+ metadata: lib.symbols[statSymbol],
46
+ errno: () =>
47
+ new Int32Array(
48
+ ffi().toArrayBuffer(lib.symbols[errnoSymbol]()! as number, 0, 4),
49
+ )[0],
50
+ }
51
+ }
52
+ let api: ReturnType<typeof native> | undefined
53
+ const sys = () => (api ??= native())
54
+ const cstr = (s: string) => Buffer.from(s + '\0')
55
+ const check = (n: number | bigint, op: string) => {
56
+ if (n < 0) throw new Error(`${op} failed (errno ${sys().errno()})`)
57
+ return Number(n)
58
+ }
59
+
60
+ export class AnchoredDirectory {
61
+ constructor(readonly fd: number) {}
62
+ static open(path: string) {
63
+ return new AnchoredDirectory(
64
+ openSync(
65
+ path,
66
+ constants.O_RDONLY |
67
+ constants.O_DIRECTORY |
68
+ constants.O_NOFOLLOW |
69
+ CLOEXEC,
70
+ ),
71
+ )
72
+ }
73
+ close() {
74
+ closeSync(this.fd)
75
+ }
76
+ directory(name: string, create = false) {
77
+ const p = cstr(name)
78
+ if (create) {
79
+ const r = sys().mkdirat(this.fd, ptr(p), 0o700)
80
+ if (r < 0 && sys().errno() !== 17) check(r, 'mkdirat') // EEXIST
81
+ }
82
+ const fd = check(
83
+ sys().openat(
84
+ this.fd,
85
+ ptr(p),
86
+ constants.O_RDONLY |
87
+ constants.O_DIRECTORY |
88
+ constants.O_NOFOLLOW |
89
+ CLOEXEC,
90
+ 0,
91
+ ),
92
+ 'openat directory',
93
+ )
94
+ return new AnchoredDirectory(fd)
95
+ }
96
+ parent(rel: string, create = false) {
97
+ let at: AnchoredDirectory = this
98
+ try {
99
+ for (const part of rel.split('/').slice(0, -1)) {
100
+ const next = at.directory(part, create)
101
+ if (at !== this) at.close()
102
+ at = next
103
+ }
104
+ return at
105
+ } catch (e) {
106
+ if (at !== this) at.close()
107
+ throw e
108
+ }
109
+ }
110
+ entry(name: string, rel: string, skipUnsupported = false): Entry | undefined {
111
+ const p = cstr(name),
112
+ link = Buffer.alloc(4096)
113
+ // Inspect without opening a FIFO/device/socket. The stat ABI is fixed for
114
+ // the supported 64-bit platforms; only mode is read from an oversized buffer.
115
+ if (!['x64', 'arm64'].includes(process.arch))
116
+ throw new Error('anchored sync metadata requires x64 or arm64')
117
+ const info = Buffer.alloc(256)
118
+ const status = Number(
119
+ sys().metadata(
120
+ this.fd,
121
+ ptr(p),
122
+ ptr(info),
123
+ process.platform === 'darwin' ? 0x20 : 0x100, // AT_SYMLINK_NOFOLLOW
124
+ ),
125
+ )
126
+ if (status < 0 && sys().errno() === 2) return undefined
127
+ check(status, 'fstatat entry')
128
+ const mode =
129
+ process.platform === 'darwin'
130
+ ? info.readUInt16LE(4)
131
+ : info.readUInt32LE(process.arch === 'x64' ? 24 : 16)
132
+ if (
133
+ ![constants.S_IFREG, constants.S_IFDIR, constants.S_IFLNK].includes(
134
+ mode & constants.S_IFMT,
135
+ )
136
+ ) {
137
+ if (skipUnsupported) return undefined
138
+ throw new Error('unsupported host file type')
139
+ }
140
+ const n = Number(sys().readlinkat(this.fd, ptr(p), ptr(link), link.length))
141
+ if (n >= 0) {
142
+ if (n === link.length) throw new Error('link target exceeds sync limit')
143
+ return { p: rel, k: 'l', m: 0, t: link.subarray(0, n).toString() }
144
+ }
145
+ const fd = sys().openat(
146
+ this.fd,
147
+ ptr(p),
148
+ constants.O_RDONLY |
149
+ constants.O_NOFOLLOW |
150
+ constants.O_NONBLOCK |
151
+ CLOEXEC,
152
+ 0,
153
+ )
154
+ if (fd < 0 && sys().errno() === 2) return undefined // ENOENT only
155
+ check(fd, 'openat entry')
156
+ try {
157
+ const st = fstatSync(fd)
158
+ if (st.isDirectory()) return { p: rel, k: 'd', m: st.mode & 0o7777 }
159
+ if (!st.isFile()) throw new Error('unsupported host file type')
160
+ const hash = createHash('sha256'),
161
+ buffer = Buffer.alloc(64 * 1024)
162
+ let n: number
163
+ while ((n = readSync(fd, buffer, 0, buffer.length, null)) > 0)
164
+ hash.update(buffer.subarray(0, n))
165
+ return {
166
+ p: rel,
167
+ k: 'f',
168
+ m: st.mode & 0o7777,
169
+ s: st.size,
170
+ h: hash.digest('hex'),
171
+ }
172
+ } finally {
173
+ closeSync(fd)
174
+ }
175
+ }
176
+ remove(name: string, directory: boolean) {
177
+ const p = cstr(name)
178
+ check(
179
+ sys().unlinkat(
180
+ this.fd,
181
+ ptr(p),
182
+ directory ? (process.platform === 'darwin' ? 0x80 : 0x200) : 0,
183
+ ),
184
+ 'unlinkat',
185
+ )
186
+ }
187
+ mode(name: string, mode: number, directory: boolean) {
188
+ const p = cstr(name)
189
+ const fd = check(
190
+ sys().openat(
191
+ this.fd,
192
+ ptr(p),
193
+ constants.O_RDONLY |
194
+ constants.O_NOFOLLOW |
195
+ CLOEXEC |
196
+ (directory ? constants.O_DIRECTORY : constants.O_NONBLOCK),
197
+ 0,
198
+ ),
199
+ 'openat mode',
200
+ )
201
+ try {
202
+ if (!(directory ? fstatSync(fd).isDirectory() : fstatSync(fd).isFile()))
203
+ throw new Error('host file type changed')
204
+ fchmodSync(fd, mode)
205
+ } finally {
206
+ closeSync(fd)
207
+ }
208
+ }
209
+ /** A regular file's bytes and its entry, hashed from those same bytes;
210
+ * undefined when `name` is gone or is anything but a file. */
211
+ read(name: string, rel: string): { entry: Entry; data: Buffer } | undefined {
212
+ const p = cstr(name)
213
+ const fd = sys().openat(
214
+ this.fd,
215
+ ptr(p),
216
+ constants.O_RDONLY |
217
+ constants.O_NOFOLLOW |
218
+ constants.O_NONBLOCK |
219
+ CLOEXEC,
220
+ 0,
221
+ )
222
+ if (fd < 0) return undefined
223
+ try {
224
+ const st = fstatSync(fd)
225
+ if (!st.isFile()) return undefined
226
+ const chunks: Buffer[] = []
227
+ const buffer = Buffer.alloc(64 * 1024)
228
+ let n: number
229
+ while ((n = readSync(fd, buffer, 0, buffer.length, null)) > 0)
230
+ chunks.push(Buffer.from(buffer.subarray(0, n)))
231
+ const data = Buffer.concat(chunks)
232
+ return {
233
+ entry: {
234
+ p: rel,
235
+ k: 'f',
236
+ m: st.mode & 0o7777,
237
+ s: data.length,
238
+ h: createHash('sha256').update(data).digest('hex'),
239
+ },
240
+ data,
241
+ }
242
+ } finally {
243
+ closeSync(fd)
244
+ }
245
+ }
246
+ put(name: string, e: Entry, data?: Buffer, replaceDirectory = false) {
247
+ const temp = cstr(`.mirab-sync-${randomBytes(12).toString('hex')}`),
248
+ dest = cstr(name)
249
+ let made = false
250
+ try {
251
+ if (e.k === 'f') {
252
+ const fd = check(
253
+ sys().openat(
254
+ this.fd,
255
+ ptr(temp),
256
+ constants.O_WRONLY |
257
+ constants.O_CREAT |
258
+ constants.O_EXCL |
259
+ constants.O_NOFOLLOW |
260
+ CLOEXEC,
261
+ 0o600,
262
+ ),
263
+ 'openat temp',
264
+ )
265
+ made = true
266
+ try {
267
+ writeFileSync(fd, data!)
268
+ fchmodSync(fd, e.m & 0o777)
269
+ } finally {
270
+ closeSync(fd)
271
+ }
272
+ } else {
273
+ const target = cstr(e.t!)
274
+ check(sys().symlinkat(ptr(target), this.fd, ptr(temp)), 'symlinkat')
275
+ made = true
276
+ }
277
+ if (replaceDirectory) this.remove(name, true)
278
+ check(sys().renameat(this.fd, ptr(temp), this.fd, ptr(dest)), 'renameat')
279
+ made = false
280
+ } finally {
281
+ if (made) sys().unlinkat(this.fd, ptr(temp), 0)
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,159 @@
1
+ // macOS `container` level: OCI containers inside Colima's Linux VM (Lima,
2
+ // vmType vz, virtiofs mounts, Docker runtime). The Mac is separated from jobs
3
+ // by Colima's VM, and jobs from each other by the same hardened container
4
+ // flags and egress policy as on Linux (that policy runs inside Colima's VM).
5
+ // mirab only detects Colima; installing or starting it is the owner's call.
6
+ import { execFileSync } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join, resolve } from 'node:path'
10
+
11
+ export const COLIMA_INSTALL =
12
+ 'brew install colima docker && colima start --vm-type vz --mount-type virtiofs'
13
+
14
+ export interface ColimaMount {
15
+ location: string
16
+ writable: boolean
17
+ }
18
+
19
+ export type ColimaState =
20
+ | {
21
+ running: true
22
+ /** `unix://…/docker.sock`, passed to docker as `--host`. */
23
+ socket: string
24
+ vmType: string
25
+ arch: string
26
+ mountType: string
27
+ mounts: ColimaMount[]
28
+ }
29
+ | { running: false; reason: string; fix: string }
30
+
31
+ export interface ColimaProbe {
32
+ /** stdout of `colima status --json`, or undefined if it failed or is not installed. */
33
+ status(): string | undefined
34
+ installed(): boolean
35
+ /** ~/.colima/default/colima.yaml, if present. */
36
+ config(): string | undefined
37
+ home: string
38
+ exists(path: string): boolean
39
+ }
40
+
41
+ export const systemColimaProbe = (profile = 'default'): ColimaProbe => ({
42
+ installed: () => {
43
+ try {
44
+ execFileSync('colima', ['version'], { stdio: 'ignore', timeout: 5000 })
45
+ return true
46
+ } catch {
47
+ return false
48
+ }
49
+ },
50
+ status: () => {
51
+ try {
52
+ return execFileSync('colima', ['status', '--json', '-p', profile], {
53
+ encoding: 'utf8',
54
+ stdio: ['ignore', 'pipe', 'ignore'],
55
+ timeout: 10_000,
56
+ })
57
+ } catch {
58
+ return undefined
59
+ }
60
+ },
61
+ config: () => {
62
+ try {
63
+ return readFileSync(
64
+ join(homedir(), '.colima', profile, 'colima.yaml'),
65
+ 'utf8',
66
+ )
67
+ } catch {
68
+ return undefined
69
+ }
70
+ },
71
+ home: homedir(),
72
+ exists: existsSync,
73
+ })
74
+
75
+ /** The `mounts:` list of colima.yaml; an empty list means Colima's default ($HOME, writable). */
76
+ export function colimaMounts(yaml: string | undefined, home: string) {
77
+ let raw: unknown
78
+ try {
79
+ raw = yaml ? (globalThis as any).Bun?.YAML?.parse(yaml)?.mounts : undefined
80
+ } catch {
81
+ raw = undefined
82
+ }
83
+ const list = Array.isArray(raw) ? raw : []
84
+ if (!list.length) return [{ location: home, writable: true }]
85
+ return list
86
+ .filter((m) => m && typeof m.location === 'string')
87
+ .map((m) => ({
88
+ location: resolve(m.location.replace(/^~(?=$|\/)/, home)),
89
+ writable: m.writable === true,
90
+ }))
91
+ }
92
+
93
+ export function detectColima(p: ColimaProbe): ColimaState {
94
+ if (!p.installed())
95
+ return {
96
+ running: false,
97
+ reason: 'Colima is not installed',
98
+ fix: `Needs Sami: ${COLIMA_INSTALL}`,
99
+ }
100
+ const out = p.status()
101
+ let s: Record<string, unknown> | undefined
102
+ try {
103
+ s = out ? JSON.parse(out) : undefined
104
+ } catch {
105
+ s = undefined
106
+ }
107
+ if (!s)
108
+ return {
109
+ running: false,
110
+ reason: 'Colima is not running',
111
+ fix: 'colima start --vm-type vz --mount-type virtiofs',
112
+ }
113
+ const socket = String(s.docker_socket ?? '')
114
+ if (s.runtime !== 'docker' || !socket.startsWith('unix://'))
115
+ return {
116
+ running: false,
117
+ reason: `Colima runs the ${String(s.runtime)} runtime; mirab needs docker`,
118
+ fix: 'colima start --runtime docker (a new profile; the runtime of an existing VM cannot change)',
119
+ }
120
+ if (!p.exists(socket.slice('unix://'.length)))
121
+ return {
122
+ running: false,
123
+ reason: `Colima's docker socket ${socket} is missing`,
124
+ fix: 'colima restart',
125
+ }
126
+ const driver = String(s.driver ?? '')
127
+ return {
128
+ running: true,
129
+ socket,
130
+ vmType: /virtualization/i.test(driver)
131
+ ? 'vz'
132
+ : /qemu/i.test(driver)
133
+ ? 'qemu'
134
+ : driver || 'unknown',
135
+ arch: String(s.arch ?? 'unknown'),
136
+ mountType: String(s.mount_type ?? 'unknown'),
137
+ mounts: colimaMounts(p.config(), p.home),
138
+ }
139
+ }
140
+
141
+ /** The mount that makes `path` visible in Colima's VM, if any (and writable when asked). */
142
+ export function colimaMountFor(
143
+ path: string,
144
+ mounts: ColimaMount[],
145
+ writable: boolean,
146
+ ) {
147
+ const abs = resolve(path)
148
+ return mounts.find(
149
+ (m) =>
150
+ (abs === m.location || abs.startsWith(m.location + '/')) &&
151
+ (!writable || m.writable),
152
+ )
153
+ }
154
+
155
+ export function colimaStatusLine(c: ColimaState) {
156
+ return c.running
157
+ ? `colima: running (${c.vmType}, ${c.arch}, ${c.mountType} mounts: ${c.mounts.map((m) => `${m.location}${m.writable ? '' : ' ro'}`).join(', ')})`
158
+ : `colima: unavailable (${c.reason}); ${c.fix}`
159
+ }
@@ -0,0 +1,189 @@
1
+ // `container` level, OCI implementation (Docker or Podman). Same guest agent,
2
+ // same exec contract, the job directory bind-mounted at /workspace. Other
3
+ // container technologies (FreeBSD jails) slot in as another backend name.
4
+ import { spawn, execFileSync } from 'node:child_process'
5
+ import { randomBytes } from 'node:crypto'
6
+ import { userInfo } from 'node:os'
7
+ import {
8
+ guestExec,
9
+ guestPut,
10
+ type Channel,
11
+ type Isolation,
12
+ type Opener,
13
+ } from './session'
14
+
15
+ export interface OciConfig {
16
+ /** `docker` or `podman` (anything with the same CLI). */
17
+ engine: string
18
+ image: string
19
+ /** Static linux guest agent binary, mounted read-only into the container. */
20
+ agent: string
21
+ memory: string
22
+ cpus: string
23
+ pids: number
24
+ egress: 'auto' | 'block' | 'allow'
25
+ /** Engine endpoint (`docker --host`), e.g. Colima's socket on macOS. */
26
+ host?: string
27
+ /** Where the engine runs, for labels: `Colima vz VM`. */
28
+ where?: string
29
+ }
30
+
31
+ /** Global engine flags that pick the endpoint; empty for the local default. */
32
+ const endpoint = (host?: string) => (host ? ['--host', host] : [])
33
+
34
+ export const EGRESS_NETWORK = 'mirab-egress'
35
+
36
+ function egressNetworkReady(engine: string, host?: string) {
37
+ try {
38
+ execFileSync(
39
+ engine,
40
+ [...endpoint(host), 'network', 'inspect', EGRESS_NETWORK],
41
+ {
42
+ stdio: 'ignore',
43
+ },
44
+ )
45
+ return true
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ /** Network the container joins: the filtered bridge, or none. `allow` without setup is an error. */
52
+ export function containerNetwork(
53
+ mode: OciConfig['egress'],
54
+ ready: boolean,
55
+ setup = NETWORK_SETUP,
56
+ ) {
57
+ if (mode === 'block' || (!ready && mode === 'auto')) return 'none'
58
+ if (!ready)
59
+ throw new Error(
60
+ `egress is set to allow but the ${EGRESS_NETWORK} network is not set up. Needs Sami: ${setup}`,
61
+ )
62
+ return EGRESS_NETWORK
63
+ }
64
+
65
+ export const GUEST_WORK = '/workspace'
66
+ export const NETWORK_SETUP = 'sudo scripts/vm-network-setup.sh'
67
+ /** The same script, run inside Colima's VM, where the containers' network lives. */
68
+ export const COLIMA_NETWORK_SETUP =
69
+ 'colima ssh -- sudo sh -s up < scripts/vm-network-setup.sh'
70
+
71
+ export function ociAvailable(engine: string, agent: string, host?: string) {
72
+ try {
73
+ execFileSync(
74
+ engine,
75
+ [...endpoint(host), 'version', '--format', '{{.Server.Version}}'],
76
+ {
77
+ stdio: 'ignore',
78
+ timeout: 5000,
79
+ },
80
+ )
81
+ execFileSync('test', ['-x', agent])
82
+ return true
83
+ } catch {
84
+ return false
85
+ }
86
+ }
87
+
88
+ function execChannel(engine: string, name: string, host?: string): Channel {
89
+ const child = spawn(
90
+ engine,
91
+ [...endpoint(host), 'exec', '-i', name, '/mirab-guest-agent', '--stdio'],
92
+ {
93
+ stdio: ['pipe', 'pipe', 'ignore'],
94
+ },
95
+ )
96
+ let closeCb: (e?: Error) => void = () => {}
97
+ child.on('close', () => closeCb())
98
+ child.on('error', (e) => closeCb(e))
99
+ return {
100
+ write: (d) => void child.stdin!.write(d),
101
+ onData: (cb) => child.stdout!.on('data', cb),
102
+ onClose: (cb) => (closeCb = cb),
103
+ // Closing stdin is EOF to the agent, which then kills the step's group.
104
+ close: () => {
105
+ child.stdin!.end()
106
+ setTimeout(() => child.kill('SIGKILL'), 2000).unref()
107
+ },
108
+ }
109
+ }
110
+
111
+ export function startOci(
112
+ cfg: OciConfig,
113
+ work: string,
114
+ log: (line: string) => void = () => {},
115
+ ): Isolation {
116
+ const name = 'mirab-' + randomBytes(6).toString('hex'),
117
+ me = userInfo()
118
+ const setup = cfg.where ? COLIMA_NETWORK_SETUP : NETWORK_SETUP
119
+ const network = containerNetwork(
120
+ cfg.egress,
121
+ cfg.egress !== 'block' && egressNetworkReady(cfg.engine, cfg.host),
122
+ setup,
123
+ )
124
+ log(
125
+ network === 'none'
126
+ ? `container network: none, egress blocked (enable with: ${setup})`
127
+ : `container network: ${network}, egress allowed, LAN/host/metadata denied`,
128
+ )
129
+ execFileSync(
130
+ cfg.engine,
131
+ [
132
+ ...endpoint(cfg.host),
133
+ 'run',
134
+ '-d',
135
+ '--rm',
136
+ '--name',
137
+ name,
138
+ // Hardening: no capabilities, no privilege gain, immutable root, bounded.
139
+ '--cap-drop',
140
+ 'ALL',
141
+ '--security-opt',
142
+ 'no-new-privileges',
143
+ '--read-only',
144
+ '--tmpfs',
145
+ '/tmp:rw,nosuid,size=512m',
146
+ '--pids-limit',
147
+ String(cfg.pids),
148
+ '--memory',
149
+ cfg.memory,
150
+ '--cpus',
151
+ cfg.cpus,
152
+ // No network unless the filtered mirab-egress bridge exists (set up by
153
+ // scripts/vm-network-setup.sh); never the default bridge.
154
+ '--network',
155
+ network,
156
+ '--user',
157
+ `${me.uid}:${me.gid}`,
158
+ '-v',
159
+ `${cfg.agent}:/mirab-guest-agent:ro`,
160
+ '-v',
161
+ `${work}:${GUEST_WORK}`,
162
+ '-w',
163
+ GUEST_WORK,
164
+ cfg.image,
165
+ 'sleep',
166
+ 'infinity',
167
+ ],
168
+ { stdio: 'ignore' },
169
+ )
170
+ const open: Opener = async () => execChannel(cfg.engine, name, cfg.host)
171
+ const inWork = (p: string) => p === work || p.startsWith(work + '/')
172
+ const guest = (p: string) =>
173
+ inWork(p) ? GUEST_WORK + p.slice(work.length) : p
174
+ return {
175
+ level: 'container',
176
+ backend: 'oci:' + cfg.engine + (cfg.where ? '@colima' : ''),
177
+ label: `container (${cfg.engine}${cfg.where ? ` in ${cfg.where}` : ''})`,
178
+ guestPath: guest,
179
+ put: (p, content, mode) => guestPut(open, guest(p), content, mode),
180
+ exec: (argv, o) => guestExec(open, argv, o),
181
+ close: async () => {
182
+ try {
183
+ execFileSync(cfg.engine, [...endpoint(cfg.host), 'rm', '-f', name], {
184
+ stdio: 'ignore',
185
+ })
186
+ } catch {}
187
+ },
188
+ }
189
+ }