openrod 0.1.1 → 0.1.2-rc.2

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.
@@ -0,0 +1,499 @@
1
+ import fs from 'node:fs/promises'
2
+ import { constants } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ // OpenShell's VM driver looks for local sandbox images only at DOCKER_HOST, or
8
+ // at /var/run/docker.sock when it is unset (NVIDIA/OpenShell#4155). Docker
9
+ // Desktop leaves that socket off by default, so the driver can't see the images
10
+ // OpenRod builds. This finds the driver, compares its engine with the build
11
+ // engine and, for the Homebrew service only, writes DOCKER_HOST to the
12
+ // gateway.env the service wrapper sources, then restarts the service.
13
+ // Everything is injected: nothing here imports the project, so tests never
14
+ // read the real OpenShell config.
15
+
16
+ export const FIXED_IN = null // first OpenShell release with NVIDIA/OpenShell#4155 fixed
17
+ export const LABEL = 'sh.brew.openshell'
18
+ export const MARK = "# openrod-managed: lets OpenShell's VM driver use your Docker images. Delete this line and the next to undo."
19
+ export const LIVE = ['ready', 'provisioning', 'starting', 'stopping', 'unknown', 'unspecified']
20
+ export const isVm = (drivers = []) => drivers.some((d) => d.name === 'vm' || d.capabilities?.driverName === 'openshell-driver-vm')
21
+
22
+ const fail = (message, status = 400) => Object.assign(new Error(message), { status })
23
+ const shQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
24
+ const ASSIGN = /^\s*(?:export\s+)?DOCKER_HOST=(.*)$/
25
+ const isMark = (line) => line.startsWith('# openrod-managed')
26
+ const unquote = (v) => (v = v.trim(), /^'[^']*'$|^"[^"]*"$/.test(v) ? v.slice(1, -1) : v)
27
+ const bare = (line) => line?.replace(/\r$/, '') ?? ''
28
+ const argv0 = (command) => path.basename(command.trim().split(/\s+/)[0] ?? '')
29
+ const WRAPPER = ['xdg_gateway_env="${xdg_config_home}/openshell/gateway.env"', 'if [ -f "${xdg_gateway_env}" ]; then', 'elif [ -f "${prefix_gateway_env}" ]; then']
30
+
31
+ export function parseProcesses(listing) {
32
+ return String(listing ?? '').split('\n').flatMap((line) => {
33
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
34
+ return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] }] : []
35
+ })
36
+ }
37
+
38
+ // The driver serves the gateway on --bind-socket; its --internal-run-vm
39
+ // children are per-sandbox launchers and never match.
40
+ export function findDriver(processes, { listener = null } = {}) {
41
+ const gateways = new Map(processes.filter((p) => argv0(p.command) === 'openshell-gateway').map((p) => [p.pid, p]))
42
+ const drivers = processes.filter((p) => argv0(p.command) === 'openshell-driver-vm' && /\s--bind-socket(?:[\s=]|$)/.test(p.command) && gateways.has(p.ppid))
43
+ const mine = listener ? drivers.filter((d) => d.ppid === listener) : drivers
44
+ if (mine.length === 1) return { gateway: gateways.get(mine[0].ppid), driver: mine[0] }
45
+ return drivers.length > 1 ? { ambiguous: true } : null
46
+ }
47
+
48
+ // `ps eww` prints the command then KEY=value pairs with no quoting; a value
49
+ // runs until the next ` KEY=` so values with spaces survive.
50
+ export function envValue(output, key) {
51
+ let value
52
+ for (const m of String(output ?? '').matchAll(new RegExp(`(?:^|\\s)${key}=(.*?)(?=\\s[A-Za-z_][A-Za-z0-9_]*=|\\s*$)`, 'g'))) value = m[1]
53
+ return value
54
+ }
55
+
56
+ export function procEnviron(buffer) {
57
+ const env = {}
58
+ for (const entry of String(buffer ?? '').split('\0')) {
59
+ const i = entry.indexOf('=')
60
+ if (i > 0) env[entry.slice(0, i)] = entry.slice(i + 1)
61
+ }
62
+ return env
63
+ }
64
+
65
+ // bollard 0.20.2: only a unix:// DOCKER_HOST is used for a local socket.
66
+ export const driverSocket = (h) => h?.startsWith('unix://') ? h.slice(7) : '/var/run/docker.sock'
67
+ export const brewPrefix = (command) => /^(.+)\/opt\/openshell\/bin\/openshell-gateway(\s|$)/.exec(command ?? '')?.[1] ?? null
68
+
69
+ export function wrapperPrefixFile(script) {
70
+ const lines = new Set(String(script ?? '').split('\n').map((l) => l.trim()))
71
+ if (!WRAPPER.every((l) => lines.has(l))) return null
72
+ return /^prefix_gateway_env="([^"$`\\]+)"$/m.exec(script)?.[1] ?? null
73
+ }
74
+
75
+ export const launchdPid = (out) => Number(/^\s*pid = (\d+)$/m.exec(out)?.[1]) || null
76
+ export const listensOn = (lsof, port) => lsof.split('\n').some((l) => l.startsWith('n') && l.endsWith(`:${port}`))
77
+
78
+ // Same order as the wrapper. Creating the XDG file when only the prefix file
79
+ // exists would hide the prefix file's settings.
80
+ export const chooseEnvFile = ({ xdgFile, prefixFile, xdgIsFile, prefixIsFile }) => xdgIsFile || !prefixFile || !prefixIsFile ? xdgFile : prefixFile
81
+
82
+ export function readManaged(text) {
83
+ const lines = String(text ?? '').split('\n').map(bare), foreign = []
84
+ let managed = null, effective = null
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i], next = ASSIGN.exec(lines[i + 1] ?? '')
87
+ if (isMark(line) && next) { managed = effective = unquote(next[1]); i++; continue }
88
+ if (line.trimStart().startsWith('#') || !/\bDOCKER_HOST\b/.test(line)) continue
89
+ foreign.push(line)
90
+ const m = ASSIGN.exec(line)
91
+ if (m) effective = unquote(m[1])
92
+ else if (/^\s*unset\b/.test(line)) effective = null
93
+ }
94
+ return { managed, foreign, effective }
95
+ }
96
+
97
+ // The block always goes last: in sh the last assignment wins, so it overrides a
98
+ // foreign line while leaving that line byte-for-byte in place.
99
+ export function writeManaged(text, value) {
100
+ const lines = String(text ?? '').split('\n'), kept = []
101
+ for (let i = 0; i < lines.length; i++) {
102
+ if (!isMark(lines[i])) kept.push(lines[i])
103
+ else if (ASSIGN.test(bare(lines[i + 1]))) i++
104
+ }
105
+ const rest = kept.join('\n')
106
+ if (value == null) return rest
107
+ return `${rest}${rest && !rest.endsWith('\n') ? '\n' : ''}${MARK}\nDOCKER_HOST=${quote(value)}\n`
108
+ }
109
+
110
+ export function quote(value) {
111
+ if (typeof value !== 'string' || !value || /['\0\r\n]/.test(value)) throw fail('The Docker socket path has characters OpenRod won’t write.', 409)
112
+ return /^[A-Za-z0-9_/.:@%+,=-]+$/.test(value) ? value : `'${value}'`
113
+ }
114
+
115
+ const semver = (v) => /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? '').trim())?.slice(1).map(Number)
116
+ export const newer = (a, b) => {
117
+ const x = semver(a), y = semver(b)
118
+ if (!x || !y) return false
119
+ const i = x.findIndex((n, k) => n !== y[k])
120
+ return i >= 0 && x[i] > y[i]
121
+ }
122
+
123
+ export function decideFix(f) {
124
+ const manual = (blocked) => ({ fix: 'manual', blocked })
125
+ if (f.platform !== 'darwin') return manual('Add one setting to OpenShell and restart it.')
126
+ if (!f.service) return manual('OpenShell isn’t running as a Homebrew service.')
127
+ if (!f.plistOk || !f.prefixOk || !f.brew) return manual('This OpenShell setup is different from what OpenRod expects.')
128
+ if (!f.fileOk) return manual(`OpenRod can’t write ${f.file}.`)
129
+ if (!f.quotable) return manual('The Docker socket path has characters OpenRod won’t write.')
130
+ if (f.conflict || !f.sandboxes || f.sandboxes.length || f.stranded?.length || f.declined || f.failedBefore || f.busy) return { fix: 'confirm', blocked: null }
131
+ return { fix: 'auto', blocked: null }
132
+ }
133
+
134
+ const changed = () => fail('OpenShell’s settings file changed while OpenRod was editing it. Try again.', 409)
135
+ const readOrEmpty = (file) => fs.readFile(file, 'utf8').catch((e) => { if (e.code === 'ENOENT') return ''; throw e })
136
+ const realOrSelf = (file) => fs.realpath(file).catch((e) => { if (e.code === 'ENOENT') return file; throw e })
137
+ const dangling = (file) => fs.lstat(file).then((s) => s.isSymbolicLink(), () => false)
138
+
139
+ // Follows a symlink to the real file, keeps its mode, and only replaces it when
140
+ // it still holds what OpenRod read. A missing file reads as ''.
141
+ export async function writeEnvFile(file, next, expected, validate = null) {
142
+ const real = await realOrSelf(file)
143
+ // Renaming over a link OpenRod can't follow would replace it with a plain file.
144
+ if (real === file && await dangling(file)) throw fail(`OpenRod can’t write ${file}.`, 409)
145
+ let mode = 0o600, exists = true
146
+ try {
147
+ const st = await fs.stat(real)
148
+ if (!st.isFile()) throw fail(`OpenRod can’t write ${real}.`, 409)
149
+ mode = st.mode & 0o777
150
+ } catch (e) {
151
+ if (e.code !== 'ENOENT') throw e
152
+ exists = false
153
+ await fs.mkdir(path.dirname(real), { recursive: true, mode: 0o700 })
154
+ }
155
+ if (await readOrEmpty(real) !== expected) throw changed()
156
+ if (exists) try { await fs.copyFile(real, `${real}.openrod.bak`, constants.COPYFILE_EXCL); await fs.chmod(`${real}.openrod.bak`, mode) } catch (e) { if (e.code !== 'EEXIST') throw e }
157
+ const temp = `${real}.openrod-${randomUUID()}.tmp`
158
+ try {
159
+ await fs.writeFile(temp, next, { mode, flag: 'wx' })
160
+ await fs.chmod(temp, mode)
161
+ if (validate) await validate(temp)
162
+ if (await readOrEmpty(real) !== expected) throw changed()
163
+ await fs.rename(temp, real)
164
+ } finally { await fs.rm(temp, { force: true }) }
165
+ return real
166
+ }
167
+
168
+ // Sources the file the way the wrapper does (set -eu; set -a) with launchd's
169
+ // bare environment, so a line that would crash-loop the service never lands.
170
+ export async function validateEnvFile(run, file, { home, expect } = {}) {
171
+ const r = await run('/bin/sh', ['-c', 'set -eu; set -a; . "$1"; set +a; printf %s "${DOCKER_HOST-}"', 'openrod-check', file], { env: { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' }, timeoutMs: 5000 })
172
+ if (r.code !== 0 || (expect !== undefined && r.stdout !== expect)) throw Object.assign(fail('OpenShell’s settings file has a line OpenRod can’t check. Use the steps instead.', 409), { unsafe: true })
173
+ }
174
+
175
+ async function removeEnvFile(file, expected) {
176
+ const real = await realOrSelf(file)
177
+ if (await readOrEmpty(real) !== expected) throw changed()
178
+ await fs.rm(real, { force: true })
179
+ return real
180
+ }
181
+
182
+ async function writable(file) {
183
+ let real = file
184
+ try { real = await fs.realpath(file); if (!(await fs.stat(real)).isFile()) return false } catch (e) { if (e.code !== 'ENOENT' || await dangling(file)) return false }
185
+ for (let dir = path.dirname(real); ; dir = path.dirname(dir)) {
186
+ try { await fs.access(dir, constants.W_OK); return true } catch (e) { if (e.code !== 'ENOENT' || dir === path.dirname(dir)) return false }
187
+ }
188
+ }
189
+
190
+ const portOf = (endpoint) => { try { const u = new URL(endpoint); return u.port || (u.protocol === 'https:' ? '443' : '80') } catch { return null } }
191
+ const mismatch = (message, s, fix = s.fix) => Object.assign(fail(message, 409), { code: 'GATEWAY_DOCKER_MISMATCH', fix, sandboxes: s.sandboxes ?? null })
192
+
193
+ export function createGatewayDocker({
194
+ platform = process.platform, home = os.userInfo().homedir, uid = process.getuid?.() ?? null,
195
+ target, client, forget = () => {}, sandboxes, localEngine, docker, run, busy = () => null,
196
+ stateFile, onJob = () => {}, now = Date.now, wait = (ms) => new Promise((r) => setTimeout(r, ms)), logger = console,
197
+ readProc = (pid, name) => fs.readFile(`/proc/${pid}/${name}`),
198
+ } = {}) {
199
+ const exec = (file, args, options) => run(file, args, { timeoutMs: 5000, ...options }).catch((e) => ({ code: null, stdout: '', stderr: e.message }))
200
+ const iso = () => new Date(now()).toISOString()
201
+ const tilde = (p) => p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p
202
+ const isFile = (p) => p ? fs.stat(p).then((s) => s.isFile(), () => false) : false
203
+ const keyOf = (build) => build.engineId ?? build.endpoint
204
+ const failed = new Set(), unsafe = new Set()
205
+ let state = null, loading = null, cache = null, probing = null, job = null, working = null
206
+
207
+ const load = () => loading ??= fs.readFile(stateFile, 'utf8').then(JSON.parse).catch(() => ({})).then((v) => {
208
+ v = v && typeof v === 'object' ? v : {}
209
+ state = { undo: v.undo ?? null, declined: Array.isArray(v.declined) ? v.declined : [], lastChange: v.lastChange ?? null }
210
+ })
211
+ async function save() {
212
+ if (!stateFile) return
213
+ try {
214
+ await fs.mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 })
215
+ const temp = `${stateFile}.${randomUUID()}.tmp`
216
+ try { await fs.writeFile(temp, `${JSON.stringify({ version: 1, ...state }, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); await fs.rename(temp, stateFile) } finally { await fs.rm(temp, { force: true }) }
217
+ } catch (e) { logger.warn?.(`OpenRod couldn’t save the Docker connection state: ${e.message}`) }
218
+ }
219
+
220
+ const blank = (patch = {}) => ({ state: 'n/a', reason: null, gateway: null, build: null, driver: null, pending: false, conflict: null, sandboxes: null, stranded: [], fix: null, blocked: null, steps: null, alternative: null, ...patch, checkedAt: iso() })
221
+ function view(value) {
222
+ const change = state?.lastChange
223
+ return {
224
+ ...value, ...(job?.status === 'working' ? { state: 'working' } : {}), job: job && { ...job },
225
+ lastChange: change ? { ...change, undoable: Boolean(state.undo) && change.kind === 'connect' } : null,
226
+ }
227
+ }
228
+
229
+ async function engineAt(socket) {
230
+ try {
231
+ await fs.stat(socket)
232
+ return JSON.parse(await docker(['info', '--format', '{{json .}}'], { engine: { endpoint: `unix://${socket}` }, timeout: 10_000 }))?.ID || null
233
+ } catch { return null }
234
+ }
235
+
236
+ // full: also work out the fix when the engines match (undo needs it).
237
+ async function probe(full) {
238
+ const out = blank(), done = (patch, facts = null) => ({ value: { ...out, ...patch }, facts })
239
+ if (platform !== 'darwin' && platform !== 'linux') return done({})
240
+ const t = await target()
241
+ if (!t || t.remote) return done({})
242
+ out.gateway = t.name
243
+ let info, health
244
+ try { const c = await client(t.name); [info, health] = await Promise.all([c.raw.getGatewayInfo({}), c.health()]) } catch { return done({ state: 'unknown', reason: 'OpenShell isn’t reachable.' }) }
245
+ if (!isVm(info?.computeDrivers) || (FIXED_IN && !newer(FIXED_IN, health?.version))) return done({})
246
+ let build
247
+ try { const e = await localEngine(); build = { endpoint: e.endpoint, engineId: e.engineId || null, desktop: Boolean(e.desktop) } } catch (e) { return done({ state: 'no-docker', reason: e.message }) }
248
+ out.build = build
249
+ const port = portOf(t.endpoint)
250
+
251
+ const processes = parseProcesses((await exec('ps', ['-ww', '-x', '-o', 'pid=,ppid=,command='], { outputLimit: 4 << 20 })).stdout)
252
+ let found = findDriver(processes)
253
+ if (found?.ambiguous && port) {
254
+ const lsof = await exec('lsof', ['-nP', '-a', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'])
255
+ found = findDriver(processes, { listener: Number(/^p(\d+)$/m.exec(lsof.stdout)?.[1]) || null })
256
+ }
257
+ if (!found || found.ambiguous) {
258
+ const reason = 'OpenRod can’t find the OpenShell driver on this computer.'
259
+ return done({ state: 'unknown', reason, fix: 'manual', blocked: reason })
260
+ }
261
+ const { gateway, driver } = found
262
+
263
+ // Only these keys are kept from the driver's environment.
264
+ let env = null
265
+ if (platform === 'darwin') {
266
+ const r = await exec('ps', ['eww', '-o', 'command=', '-p', String(driver.pid)], { outputLimit: 1 << 20 })
267
+ const text = r.code === 0 ? r.stdout : ''
268
+ env = { DOCKER_HOST: envValue(text, 'DOCKER_HOST'), XDG_CONFIG_HOME: envValue(text, 'XDG_CONFIG_HOME'), HOME: envValue(text, 'HOME'), PATH: envValue(text, 'PATH') !== undefined }
269
+ } else {
270
+ try { const e = procEnviron(await readProc(driver.pid, 'environ')); env = { DOCKER_HOST: e.DOCKER_HOST, XDG_CONFIG_HOME: e.XDG_CONFIG_HOME, HOME: e.HOME, PATH: 'PATH' in e } } catch { /* unreadable */ }
271
+ }
272
+ if (env && env.HOME === undefined && !env.PATH) env = null
273
+
274
+ const prefix = brewPrefix(gateway.command)
275
+ const xdgFile = path.join(env?.XDG_CONFIG_HOME || path.join(env?.HOME || home, '.config'), 'openshell', 'gateway.env')
276
+ let prefixFile = null
277
+ if (platform === 'darwin' && prefix) prefixFile = wrapperPrefixFile(await fs.readFile(path.join(prefix, 'opt/openshell/libexec/openshell-gateway-homebrew-service'), 'utf8').catch(() => ''))
278
+ const prefixOk = Boolean(prefixFile)
279
+ if (platform === 'darwin') prefixFile ??= path.join(prefix ?? '/opt/homebrew', 'var/openshell/gateway.env')
280
+ const file = chooseEnvFile({ xdgFile, prefixFile, xdgIsFile: await isFile(xdgFile), prefixIsFile: await isFile(prefixFile) })
281
+ let text = '', existed = false, readable = true
282
+ try { text = await fs.readFile(file, 'utf8'); existed = true } catch (e) { readable = e.code === 'ENOENT' }
283
+ const m = readManaged(text)
284
+
285
+ const dockerHost = env ? env.DOCKER_HOST ?? null : m.effective
286
+ const socket = driverSocket(dockerHost)
287
+ const drv = { dockerHost, socket, source: env ? 'process' : m.effective != null ? 'file' : 'default', engineId: await engineAt(socket) }
288
+ const [a, b] = await Promise.all([socket, build.endpoint.slice(7)].map((p) => fs.realpath(p).catch(() => null)))
289
+ const same = Boolean((build.engineId && drv.engineId && build.engineId === drv.engineId) || (a && a === b))
290
+ const facts = { name: t.name, gateway, file, text, existed, build, env: Boolean(env), driverHost: dockerHost, managed: m.managed, pending: m.managed === build.endpoint && m.effective === build.endpoint, brew: null, decision: null }
291
+ if (same && !full) return done({ state: 'ok', driver: drv }, facts)
292
+
293
+ const conflict = m.foreign.length ? { dockerHost: m.effective, source: 'file' }
294
+ : env?.DOCKER_HOST && env.DOCKER_HOST !== m.managed && env.DOCKER_HOST !== build.endpoint ? { dockerHost: env.DOCKER_HOST, source: 'process' } : null
295
+ let live = null
296
+ try { live = (await sandboxes(t.name)).filter((s) => LIVE.includes(s.phase)) } catch { /* confirm instead */ }
297
+ const listed = live && live.map(({ name, workspace }) => ({ name, workspace }))
298
+ // A restart onto the build engine can't bring these back: their images live elsewhere.
299
+ const stranded = []
300
+ if (!same) for (const image of new Set((live ?? []).map((s) => s.image).filter((i) => i?.startsWith('openshell-template/')))) {
301
+ try { await docker(['image', 'inspect', '--format', '{{.Id}}', image], { engine: { endpoint: build.endpoint }, timeout: 10_000 }) }
302
+ catch { stranded.push(...live.filter((s) => s.image === image).map(({ name, workspace }) => ({ name, workspace, image }))) }
303
+ }
304
+
305
+ let service = false, plistOk = false
306
+ if (platform === 'darwin') {
307
+ const lc = await exec('/bin/launchctl', ['print', `gui/${uid}/${LABEL}`])
308
+ const svcPid = lc.code === 0 ? launchdPid(lc.stdout) : null
309
+ if (svcPid && svcPid === gateway.pid && port) service = listensOn((await exec('lsof', ['-nP', '-a', '-p', String(svcPid), '-iTCP', '-sTCP:LISTEN', '-Fn'])).stdout, port)
310
+ const plist = await exec('plutil', ['-extract', 'EnvironmentVariables', 'raw', '-o', '-', path.join(home, 'Library/LaunchAgents', `${LABEL}.plist`)])
311
+ plistOk = typeof plist.code === 'number' && plist.code !== 0
312
+ if (prefix) facts.brew = await fs.access(path.join(prefix, 'bin/brew'), constants.X_OK).then(() => path.join(prefix, 'bin/brew'), () => null)
313
+ }
314
+
315
+ let line, quotable = true
316
+ try { line = `DOCKER_HOST=${quote(build.endpoint)}` } catch { quotable = false; line = `DOCKER_HOST=${shQuote(build.endpoint)}` }
317
+ // brew services restart would load a second gateway when this one isn't the service,
318
+ // and rebuilds a hand-edited plist; kickstart restarts the loaded job as it is.
319
+ const restart = platform === 'darwin' ? (!service ? null : plistOk ? 'brew services restart openshell' : `launchctl kickstart -k gui/${uid}/${LABEL}`)
320
+ : String(await readProc(gateway.pid, 'cgroup').catch(() => '')).includes('openshell-gateway.service') ? 'systemctl --user restart openshell-gateway' : null
321
+ const steps = { file: tilde(file), line, append: `mkdir -p ${shQuote(path.dirname(file))} && printf '\\n%s\\n' ${shQuote(line)} >> ${shQuote(file)}`, restart }
322
+ const alternative = platform === 'darwin' && build.endpoint === `unix://${home}/.docker/run/docker.sock` ? 'docker-desktop-socket' : null
323
+ const decision = decideFix({
324
+ platform, service, plistOk, prefixOk, brew: facts.brew, fileOk: readable && !unsafe.has(file) && await writable(file), file: tilde(file), quotable,
325
+ conflict, sandboxes: listed, stranded, declined: state.declined.includes(keyOf(build)), failedBefore: failed.has(`${build.engineId}|${file}`), busy: Boolean(busy()),
326
+ })
327
+ facts.decision = { ...decision, sandboxes: listed }
328
+ if (same) return done({ state: 'ok', driver: drv }, facts)
329
+ return done({ state: 'mismatch', driver: drv, pending: facts.pending, conflict, sandboxes: listed, stranded, ...decision, steps, alternative }, facts)
330
+ }
331
+
332
+ const remember = (r) => { cache = { at: now(), ...r }; return r }
333
+ const safely = (full) => load().then(() => probe(full)).catch((e) => {
334
+ logger.warn?.(`OpenRod couldn’t check OpenShell’s Docker setting: ${e.message}`)
335
+ return { value: blank({ state: 'unknown', reason: 'OpenRod couldn’t check OpenShell’s Docker setting.' }), facts: null }
336
+ })
337
+ const refresh = ({ full = false } = {}) => full ? safely(true).then(remember) : probing ??= safely(false).then(remember).finally(() => { probing = null })
338
+
339
+ async function status({ fresh = false } = {}) {
340
+ await load()
341
+ if (job?.status === 'working') return view(cache?.value ?? blank())
342
+ if (!fresh && cache && now() - cache.at < 30_000) return view(cache.value)
343
+ return view((await refresh()).value)
344
+ }
345
+
346
+ async function restartService(brew) {
347
+ const r = await run(brew, ['services', 'restart', 'openshell'], { timeoutMs: 120_000, env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_ENV_HINTS: '1', NO_COLOR: '1' } }).catch((e) => ({ code: null, stdout: '', stderr: e.message }))
348
+ if (r.code === 0 && !r.timedOut) return null
349
+ return (r.stderr || r.stdout || '').trim().split('\n').at(-1) || (r.timedOut ? 'it timed out.' : `exit ${r.code}.`)
350
+ }
351
+ async function healthy(name, ms = 60_000) {
352
+ const end = now() + ms
353
+ forget(name)
354
+ for (;;) {
355
+ try { if ((await (await client(name)).health())?.status !== 'unhealthy') return true } catch { forget(name) }
356
+ if (now() >= end) return false
357
+ await wait(1000)
358
+ }
359
+ }
360
+ const put = (file, text, expected, { created = false, check = null } = {}) => created && !text.trim() ? removeEnvFile(file, expected) : writeEnvFile(file, text, expected, check)
361
+
362
+ // Write (when the text changes), restart the service, wait for health.
363
+ // Failures put the file back as the rollback table in the spec says.
364
+ async function change(j, f, { next, created = false, expect, writeStage }) {
365
+ let real = f.file, wrote = false
366
+ const restore = async () => {
367
+ if (!wrote) return
368
+ try { await put(real, f.text, next, { created: !f.existed }) } catch (e) { logger.warn?.(`OpenRod couldn’t put back ${real}: ${e.message}`) }
369
+ }
370
+ if (next !== f.text) {
371
+ j.stage = writeStage
372
+ real = await put(f.file, next, f.text, { created, check: (temp) => validateEnvFile(run, temp, { home, expect }) })
373
+ wrote = true
374
+ // A later rewrite of a file OpenRod created still removes it on undo. The
375
+ // first write saved the unresolved path: the file wasn't there to resolve.
376
+ if (j.kind === 'connect') { state.undo = { file: real, created: !f.existed || Boolean(state.undo?.created && [real, f.file].includes(state.undo.file)) }; await save() }
377
+ }
378
+ j.stage = 'Restarting OpenShell…'
379
+ const lc = await exec('/bin/launchctl', ['print', `gui/${uid}/${LABEL}`])
380
+ if (lc.code !== 0 || launchdPid(lc.stdout) !== f.gateway.pid) { await restore(); throw fail('OpenShell changed while OpenRod was working on it. Try again.', 409) }
381
+ const problem = await restartService(f.brew)
382
+ if (problem) {
383
+ await restore()
384
+ if (!(await healthy(f.name, 0) && (await refresh()).value.driver?.dockerHost === f.driverHost)) { await restartService(f.brew); await healthy(f.name) }
385
+ throw fail(`OpenShell didn’t restart: ${problem}`, 502)
386
+ }
387
+ j.stage = 'Waiting for OpenShell…'
388
+ if (!(await healthy(f.name))) {
389
+ await restore(); await restartService(f.brew); await healthy(f.name)
390
+ throw fail('OpenShell didn’t come back within a minute. OpenRod put its settings back.', 504)
391
+ }
392
+ j.stage = 'Checking Docker…'
393
+ // The driver can show up a moment after the gateway answers health.
394
+ let after = (await refresh()).value
395
+ for (let i = 0; i < 10 && after.state === 'unknown'; i++) { await wait(1000); after = (await refresh()).value }
396
+ return { after, restore }
397
+ }
398
+
399
+ async function connectJob(j, f) {
400
+ const endpoint = f.build.endpoint
401
+ // change() skips the write when the block is already in place and last.
402
+ const next = writeManaged(f.text, endpoint)
403
+ if (next === f.text) state.undo ??= { file: f.file, created: false }
404
+ const { after, restore } = await change(j, f, { next, expect: endpoint, writeStage: 'Saving OpenShell settings…' })
405
+ const picked = after.driver?.source === 'process' ? after.driver.dockerHost === endpoint : after.state === 'ok'
406
+ if (!picked) { await restore(); throw fail('OpenShell restarted but didn’t pick up the Docker setting. Use the steps instead.', 502) }
407
+ if (after.state !== 'ok') throw fail(`OpenShell still can’t reach Docker at ${endpoint}. Check that Docker is running.`, 502)
408
+ state.lastChange = { id: j.id, kind: 'connect', auto: j.auto, at: iso() }
409
+ state.declined = state.declined.filter((k) => k !== keyOf(f.build))
410
+ await save()
411
+ }
412
+
413
+ async function undoJob(j, f) {
414
+ const { after } = await change(j, f, { next: writeManaged(f.text, null), created: Boolean(state.undo?.created), writeStage: 'Undoing the Docker change…' })
415
+ if (after.driver?.source === 'process' && after.driver.dockerHost === f.managed) throw fail('OpenShell restarted but still uses the Docker setting. Edit the file yourself to undo.', 502)
416
+ const key = keyOf(f.build)
417
+ state.declined = [...state.declined.filter((k) => k !== key), key]
418
+ state.lastChange = { id: j.id, kind: 'undo', auto: false, at: iso() }
419
+ state.undo = null
420
+ await save()
421
+ }
422
+
423
+ // Checks busy() in the same tick that creates the job, so a template save
424
+ // can't start in between.
425
+ function start(kind, auto, f) {
426
+ if (job?.status === 'working') return working
427
+ const reason = busy()
428
+ if (reason) throw fail(reason === 'saving' ? 'A template is being saved. Try again in a moment.' : 'Wait for the connection change to finish.', 409)
429
+ const j = job = { id: randomUUID(), kind, auto, status: 'working', stage: null, error: null, startedAt: iso(), finishedAt: null }
430
+ working = (kind === 'connect' ? connectJob : undoJob)(j, f)
431
+ .then(() => { j.status = 'done' }, (e) => {
432
+ j.status = 'failed'; j.error = e.message
433
+ if (e.unsafe) unsafe.add(f.file)
434
+ if (auto) failed.add(`${f.build.engineId}|${f.file}`)
435
+ logger.warn?.(`OpenRod couldn’t ${kind === 'connect' ? 'connect OpenShell to Docker' : 'undo the Docker change'}: ${e.message}`)
436
+ })
437
+ // The verify probe ran before the outcome above was recorded.
438
+ .then(() => { j.finishedAt = iso(); j.stage = null; cache = null })
439
+ .then(() => status())
440
+ onJob(working)
441
+ return working
442
+ }
443
+
444
+ function gate(s, { confirm, seen, auto = false }, restarts) {
445
+ if (s.fix === 'manual') throw mismatch(s.blocked, s)
446
+ const saw = new Set(Array.isArray(seen) ? seen : [])
447
+ if ((auto && s.fix !== 'auto') || (s.fix === 'confirm' && (!confirm || (s.sandboxes ?? []).some((x) => !saw.has(`${x.workspace}/${x.name}`))))) throw mismatch(restarts, s, 'confirm')
448
+ }
449
+
450
+ async function connect({ confirm = false, seen = [], auto = false } = {}) {
451
+ await load()
452
+ if (job?.status === 'working') return status()
453
+ const r = await refresh()
454
+ if (r.value.state !== 'mismatch') return view(r.value)
455
+ gate(r.value, { confirm, seen, auto }, 'Connecting restarts OpenShell and running sandboxes.')
456
+ start('connect', auto, r.facts)
457
+ return status()
458
+ }
459
+
460
+ async function undo({ confirm = false, seen = [] } = {}) {
461
+ await load()
462
+ if (job?.status === 'working') return status()
463
+ const r = await refresh({ full: true }), f = r.facts
464
+ if (!f?.decision) throw fail(r.value.reason ?? 'OpenShell isn’t reachable.', 409)
465
+ if (f.managed == null) throw fail('OpenShell’s settings changed after OpenRod edited them. Edit the file yourself to undo.', 409)
466
+ gate(f.decision, { confirm, seen }, 'Undoing restarts OpenShell and running sandboxes.')
467
+ start('undo', false, f)
468
+ return status()
469
+ }
470
+
471
+ async function ensure() {
472
+ try {
473
+ await load()
474
+ if (job?.status === 'working') return await working
475
+ const r = await refresh()
476
+ if (r.value.state === 'mismatch' && r.value.fix === 'auto') return await start('connect', true, r.facts)
477
+ return view(r.value)
478
+ } catch (e) { logger.warn?.(`OpenRod couldn’t check OpenShell’s Docker setting: ${e.message}`); return null }
479
+ }
480
+
481
+ async function settle() { while (job?.status === 'working') await working?.catch(() => {}) }
482
+
483
+ // Fails open on unknown and no-docker: only a known mismatch blocks a launch.
484
+ async function assertLaunch({ built = false, localOnly = false } = {}) {
485
+ await settle()
486
+ await load()
487
+ let r = cache && now() - cache.at < 30_000 ? cache : null
488
+ if (!r || r.value.state === 'mismatch') r = await refresh()
489
+ if (r.value.state !== 'mismatch') return
490
+ if (!built && !(typeof localOnly === 'function' ? await localOnly() : localOnly)) return
491
+ if (r.value.fix === 'auto') {
492
+ try { await start('connect', true, r.facts); r = await refresh() } catch { /* busy: ask the user instead */ }
493
+ if (r.value.state !== 'mismatch') return
494
+ }
495
+ throw mismatch('OpenShell can’t use your Docker images yet. Connect it to Docker, then try again.', r.value)
496
+ }
497
+
498
+ return { status, ensure, settle, assertLaunch, connect, undo }
499
+ }
package/server/gateway.js CHANGED
@@ -32,6 +32,9 @@ let selected = {
32
32
  let configured = Boolean(process.env.OPENSHELL_GATEWAY || saved.gateway)
33
33
  let selectionSource = process.env.OPENSHELL_GATEWAY ? 'environment' : saved.gateway ? 'saved' : cliGateway ? 'cli' : 'default'
34
34
  export const contextConfigured = () => configured
35
+ // A console nobody has pointed anywhere yet: no saved or cleared choice and no
36
+ // OPENSHELL_GATEWAY. Its candidate is the gateway the OpenShell CLI is set to.
37
+ export const unchosenCliGateway = () => !configured && !process.env.OPENSHELL_GATEWAY && cliGateway && !fs.existsSync(CONTEXT_FILE) ? cliGateway : null
35
38
 
36
39
  const requestContext = new AsyncLocalStorage()
37
40
  export const contextSelection = () => ({ ...(requestContext.getStore() ?? selected) })
@@ -108,6 +111,9 @@ export function listGateways({ configDir = CONFIG_DIR } = {}) {
108
111
  .sort((a, b) => a.name.localeCompare(b.name))
109
112
  }
110
113
 
114
+ // mTLS gateways registered on this computer, the ones "Use this computer" can pick.
115
+ export const localGateways = (options) => listGateways(options).filter(target => target.name !== 'aws-eks' && target.supported && !target.remote && ['localhost', '127.0.0.1', '[::1]'].includes(new URL(target.endpoint).hostname))
116
+
111
117
  export const workspaceScope = (workspace = workspaceName()) => ({ selection: { case: 'workspace', value: workspace } })
112
118
  export const workspaceName = () => contextSelection().workspace
113
119
 
@@ -132,6 +138,8 @@ async function connectRegisteredGateway(name) {
132
138
  throw error
133
139
  }
134
140
  }
141
+ // A restarted gateway gets a fresh connection on the next call.
142
+ export const forgetGatewayConnection = (name) => { connections.delete(name) }
135
143
 
136
144
  async function listWorkspaces(client) {
137
145
  const workspaces = []
@@ -32,6 +32,10 @@ async function locked(name, task) {
32
32
  const checkName = (name) => { if (!NAME_PATTERN.test(name || '')) throw fail('Unknown image template.', 404); return name }
33
33
  const missing = (e) => e?.code === 'not_found'
34
34
  export { run as runDocker }
35
+ export const savingTemplate = () => [...jobs.values()].some((j) => j.saving && running(j))
36
+ // Connects the local gateway's VM driver to the build engine (gateway-docker.js); set by api.js in local mode.
37
+ let gatewayDocker = null
38
+ export const setGatewayDocker = (value) => { gatewayDocker = value }
35
39
 
36
40
  function run(args, { job, engine, timeout = 30_000, spawnProcess = spawn } = {}) {
37
41
  return new Promise((resolve, reject) => {
@@ -77,7 +81,7 @@ export async function localEngine({ execute = run } = {}) {
77
81
  const info = JSON.parse(await execute(['info', '--format', '{{json .}}'], { engine: { endpoint } }))
78
82
  if (info.ServerErrors?.length) throw fail(`Docker is unavailable. Start Docker Desktop and retry. ${info.ServerErrors.join(' ')}`, 503)
79
83
  if (info.OSType !== 'linux') throw fail('Switch local Docker to Linux containers, then retry.')
80
- return { endpoint, architecture: info.Architecture === 'aarch64' ? 'arm64' : info.Architecture === 'x86_64' ? 'amd64' : info.Architecture, engineId: info.ID }
84
+ return { endpoint, architecture: info.Architecture === 'aarch64' ? 'arm64' : info.Architecture === 'x86_64' ? 'amd64' : info.Architecture, engineId: info.ID, desktop: info.OperatingSystem === 'Docker Desktop' }
81
85
  } catch (e) {
82
86
  if (e.code === 'ENOENT') throw fail('Docker isn’t installed. Install Docker Desktop (macOS: brew install --cask docker-desktop) or Docker Engine (Linux), start it, then try again.')
83
87
  if (/failed to connect to the docker API|Cannot connect to the Docker daemon|connection refused/i.test(e.message)) throw fail('Docker isn’t running. Start Docker Desktop (or the Docker service), then try again.', 503)
@@ -129,8 +133,10 @@ export function templateView(t) {
129
133
  }
130
134
  const jobView = (job) => ({ name: job.name, image: null, recipe: job.recipe, status: job.status, logs: job.logs, error: job.error, startedAt: job.startedAt })
131
135
 
132
- export async function listImageTemplates() {
133
- const { client, workspace } = await gateway()
136
+ export async function listImageTemplates({ connect = gateway, check = gatewayDocker, resolve = resolveGateway } = {}) {
137
+ // Wait out a local gateway restart (gateway-docker.js), as the save does.
138
+ if (check && !resolve().remote) await check.settle()
139
+ const { client, workspace } = await connect()
134
140
  const templates = (await client.sandboxTemplates.listAll({ workspace })).map(templateView)
135
141
  const byName = new Map(templates.map((t) => [t.name, t]))
136
142
  // A rebuild in progress (or one that failed) shows on the template it replaces.
@@ -139,13 +145,21 @@ export async function listImageTemplates() {
139
145
  }
140
146
 
141
147
  // What New sandbox needs: the template name plus how the sandbox starts.
142
- export async function imageTemplateForLaunch(name) {
143
- const { client, target, workspace } = await gateway()
148
+ export async function imageTemplateForLaunch(name, { connect = gateway, engineFor = deploymentEngine, check = gatewayDocker, execute = run } = {}) {
149
+ const { client, target, workspace } = await connect()
144
150
  let template
145
151
  try { template = templateView(await client.sandboxTemplates.get(checkName(name), { workspace })) } catch (e) { if (missing(e)) throw fail('Image template not found.', 404); throw e }
146
- if (template.image?.startsWith(BUILT_PREFIX)) {
147
- const engine = await deploymentEngine(target)
148
- try { await inspect(template.image, engine) } catch { throw fail('This template’s image is unavailable or incompatible on the selected compute. Rebuild the template before launching.') }
152
+ const built = Boolean(template.image?.startsWith(BUILT_PREFIX))
153
+ if (built) {
154
+ const engine = await engineFor(target)
155
+ try { await inspect(template.image, engine, { execute }) } catch { throw fail('This template’s image is unavailable or incompatible on the selected compute. Rebuild the template before launching.') }
156
+ }
157
+ // The local VM driver must reach the engine holding built or never-pushed images.
158
+ if (!target.remote && check && template.image) {
159
+ const localOnly = async () => {
160
+ try { return !JSON.parse(await execute(['image', 'inspect', template.image], { engine: await engineFor(target) }))[0]?.RepoDigests?.length } catch { return false }
161
+ }
162
+ await check.assertLaunch({ built, localOnly })
149
163
  }
150
164
  return template
151
165
  }
@@ -191,9 +205,15 @@ async function start(input) {
191
205
  const buildEngine = target.remote && (recipe.source === 'build' || setups.length) ? await localEngine() : engine
192
206
  const job = { name, scope: contextKey(), recipe, status: 'building', logs: '', error: null, cancelled: false, child: null, startedAt: new Date().toISOString() }
193
207
  jobs.set(key, job)
208
+ // A long build hides the gateway restart that lets its VM driver see local images.
209
+ if (!target.remote) void gatewayDocker?.ensure()
210
+ const hooks = target.remote ? {} : {
211
+ gatewayReady: () => gatewayDocker?.settle(),
212
+ refreshClient: async () => (await gateway({ gateway: target.name, workspace })).client,
213
+ }
194
214
  void (async () => {
195
215
  try {
196
- await publishImageTemplate({ recipe, setups, client, workspace, previous, target, engine, buildEngine, job })
216
+ await publishImageTemplate({ recipe, setups, client, workspace, previous, target, engine, buildEngine, job, ...hooks })
197
217
  jobs.delete(key)
198
218
  // Other workspaces or gateways may still reference the previous Docker image.
199
219
  // Keep published images; removing a record must not remove a shared image.
@@ -204,7 +224,7 @@ async function start(input) {
204
224
  return jobView(job)
205
225
  }
206
226
 
207
- export async function publishImageTemplate({ recipe, setups = [], client, workspace, previous, target, engine, buildEngine = engine, job }, { execute = run } = {}) {
227
+ export async function publishImageTemplate({ recipe, setups = [], client, workspace, previous, target, engine, buildEngine = engine, job, gatewayReady, refreshClient }, { execute = run } = {}) {
208
228
  let temp
209
229
  let baseTag
210
230
  const generated = recipe.source === 'build' || setups.length > 0
@@ -268,9 +288,13 @@ export async function publishImageTemplate({ recipe, setups = [], client, worksp
268
288
  // References are resolved on the deployment engine, not on local Docker.
269
289
  await ensureImage(image, engine, { execute, job })
270
290
  }
271
- active()
291
+ // Saving deletes and recreates the template, so it waits out a gateway restart.
292
+ active(); if (gatewayReady) { await gatewayReady(); active() }
272
293
  job.saving = true
273
- try { await saveTemplate(client, workspace, recipe, image, previous) } catch (e) {
294
+ try {
295
+ if (refreshClient) client = await refreshClient()
296
+ await saveTemplate(client, workspace, recipe, image, previous)
297
+ } catch (e) {
274
298
  // Only this build's unpublished local tag is disposable. Remote images
275
299
  // may be shared with another gateway or workspace and are retained.
276
300
  if (generated) await execute(['image', 'rm', '--', image], { engine: buildEngine }).catch(() => {})