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
|
@@ -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
|
+
}
|