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.
- package/README.md +13 -9
- package/dist/assets/index-89TNohi_.css +1 -0
- package/dist/assets/index-DoKK2G9s.js +545 -0
- package/dist/assets/{terminal-view-Bqw3jQjI.js → terminal-view-DzqQnmh2.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/logos/SOURCES.md +0 -14
- package/package.json +1 -1
- package/server/api.js +57 -10
- package/server/cli.js +32 -11
- package/server/gateway-docker.js +499 -0
- package/server/gateway.js +8 -0
- package/server/image-templates.js +36 -12
- package/server/launch-token.js +23 -3
- package/server/openshell-cli.js +1 -1
- package/server/openshell-install.js +189 -0
- package/server/policy.js +3 -5
- package/server/remote-gateway.js +11 -4
- package/shared/openshell-release.js +13 -0
- package/src/lib/api.js +4 -1
- package/src/lib/gateway-docker-store.js +5 -0
- package/src/lib/gateway-docker.js +77 -0
- package/src/lib/sandbox-creations.js +3 -2
- package/src/lib/services.js +1 -12
- package/dist/assets/index-DbxTgFt9.css +0 -1
- package/dist/assets/index-Ddo8D1tE.js +0 -545
- package/dist/logos/anthropic.svg +0 -1
- package/dist/logos/aws-bedrock.svg +0 -1
- package/dist/logos/aws-s3.svg +0 -1
- package/dist/logos/aws.svg +0 -1
- package/dist/logos/deepinfra.svg +0 -1
- package/dist/logos/google-cloud.svg +0 -1
- package/dist/logos/google-vertex-ai.svg +0 -1
- package/dist/logos/nvidia.svg +0 -1
- package/dist/logos/pypi.svg +0 -1
package/server/launch-token.js
CHANGED
|
@@ -13,7 +13,7 @@ export const tokenUrl = (host, port, token) => `http://${host.includes(':') ? `[
|
|
|
13
13
|
const digest = value => createHash('sha256').update(String(value)).digest()
|
|
14
14
|
const isApi = pathname => pathname === '/api' || pathname.startsWith('/api/')
|
|
15
15
|
|
|
16
|
-
export function createTokenGate(token) {
|
|
16
|
+
export function createTokenGate(token, { onReuse = () => {} } = {}) {
|
|
17
17
|
if (typeof token !== 'string' || token.length < 32) throw Error('A launch token of at least 32 characters is required')
|
|
18
18
|
const expected = digest(token)
|
|
19
19
|
const valid = value => typeof value === 'string' && value.length <= 256 && timingSafeEqual(digest(value), expected)
|
|
@@ -21,6 +21,26 @@ export function createTokenGate(token) {
|
|
|
21
21
|
const prefix = `${tokenCookieName(req.socket?.localPort)}=`
|
|
22
22
|
return (req.headers.cookie ?? '').split(';').map(part => part.trim()).filter(part => part.startsWith(prefix)).some(part => valid(part.slice(prefix.length)))
|
|
23
23
|
}
|
|
24
|
+
// A browser OpenRod opens itself gets a one-time code instead of the token:
|
|
25
|
+
// the opener's arguments are visible to other local users, and on Linux a
|
|
26
|
+
// browser it starts keeps them for its whole life. Whoever uses the code
|
|
27
|
+
// first gets the cookie, so a second use is reported: someone else may have
|
|
28
|
+
// been first.
|
|
29
|
+
const codes = new Map(), spent = new Set()
|
|
30
|
+
const launchCode = () => {
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
for (const [code, expires] of codes) if (expires <= now) codes.delete(code)
|
|
33
|
+
const code = createLaunchToken()
|
|
34
|
+
codes.set(code, now + 120_000)
|
|
35
|
+
return code
|
|
36
|
+
}
|
|
37
|
+
const spend = value => {
|
|
38
|
+
if (spent.has(value)) { onReuse(); return false }
|
|
39
|
+
const expires = codes.get(value)
|
|
40
|
+
if (expires === undefined) return false
|
|
41
|
+
codes.delete(value); spent.add(value)
|
|
42
|
+
return expires > Date.now()
|
|
43
|
+
}
|
|
24
44
|
const denied = res => { res.writeHead(401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: 'Open the link printed by `openrod` in your terminal.', code: TOKEN_REQUIRED })) }
|
|
25
45
|
// Handlers downstream also read the raw target, so a path the URL parser rewrites (dot segments, %2e) must never reach them.
|
|
26
46
|
const rewritten = (req, pathname) => req.url.split('?', 1)[0] !== pathname
|
|
@@ -38,7 +58,7 @@ export function createTokenGate(token) {
|
|
|
38
58
|
const location = `${url.pathname}${url.search}`
|
|
39
59
|
res.writeHead(302, {
|
|
40
60
|
Location: location.startsWith('//') ? '/' : location, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer',
|
|
41
|
-
...(valid(supplied) ? { 'Set-Cookie': `${tokenCookie(req.socket.localPort, token)}; Path=/; HttpOnly; SameSite=Strict` } : {}),
|
|
61
|
+
...(valid(supplied) || spend(supplied) ? { 'Set-Cookie': `${tokenCookie(req.socket.localPort, token)}; Path=/; HttpOnly; SameSite=Strict` } : {}),
|
|
42
62
|
}).end()
|
|
43
63
|
return true
|
|
44
64
|
}
|
|
@@ -51,5 +71,5 @@ export function createTokenGate(token) {
|
|
|
51
71
|
socket.end(`HTTP/1.1 ${invalid ? '400 Bad Request' : '401 Unauthorized'}\r\nConnection: close\r\n\r\n`)
|
|
52
72
|
return true
|
|
53
73
|
}
|
|
54
|
-
return { allowed, http, upgrade }
|
|
74
|
+
return { allowed, http, upgrade, launchCode }
|
|
55
75
|
}
|
package/server/openshell-cli.js
CHANGED
|
@@ -9,7 +9,7 @@ const DEFAULT_OUTPUT_LIMIT = 64 * 1024
|
|
|
9
9
|
const SEARCH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')]
|
|
10
10
|
|
|
11
11
|
export const fail = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
12
|
-
const pathDirs = (env = process.env) => (env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
12
|
+
export const pathDirs = (env = process.env) => (env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
13
13
|
|
|
14
14
|
export function findExecutable(binary, dirs = [...pathDirs(), ...SEARCH_DIRS]) {
|
|
15
15
|
if (!binary) return null
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import readline from 'node:readline/promises'
|
|
7
|
+
import { findExecutable, openshellBinary, pathDirs } from './openshell-cli.js'
|
|
8
|
+
import { localGateways } from './gateway.js'
|
|
9
|
+
import { INSTALL_SHA256, INSTALL_URL, OPENSHELL_TAG, OPENSHELL_VERSION, installCommand } from '../shared/openshell-release.js'
|
|
10
|
+
|
|
11
|
+
const SUPPORTED = ['darwin-arm64', 'linux-arm64', 'linux-x64']
|
|
12
|
+
const BREW_DIRS = ['/opt/homebrew/bin', '/usr/local/bin']
|
|
13
|
+
const MAX_SCRIPT_BYTES = 1024 * 1024
|
|
14
|
+
|
|
15
|
+
const addCommand = (platform) => `openshell gateway add https://${platform === 'darwin' ? 'localhost' : '127.0.0.1'}:17670 --local --name openshell`
|
|
16
|
+
const restartCommand = (platform) => platform === 'darwin' ? 'brew services restart nvidia/openshell/openshell' : 'systemctl --user enable openshell-gateway && systemctl --user restart openshell-gateway'
|
|
17
|
+
|
|
18
|
+
function cliVersion(bin) {
|
|
19
|
+
return new Promise((resolve) => execFile(bin, ['--version'], { timeout: 5000 }, (error, stdout) => resolve(error ? null : String(stdout))))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolves to true, false (installed but not answering) or 'missing'.
|
|
23
|
+
function dockerRunning(find, env) {
|
|
24
|
+
const docker = find('docker', [...BREW_DIRS, '/Applications/Docker.app/Contents/Resources/bin'])
|
|
25
|
+
if (!docker) return 'missing'
|
|
26
|
+
return new Promise((resolve) => execFile(docker, ['info'], { timeout: 10_000, env }, (error) => resolve(!error)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolves to the answer, or null on Ctrl-C. Ctrl-D counts as "no".
|
|
30
|
+
async function askTerminal(question) {
|
|
31
|
+
// Drop keys typed before the question, like an extra Enter during npx's download.
|
|
32
|
+
const drop = () => {}
|
|
33
|
+
process.stdin.setRawMode?.(true); process.stdin.on('data', drop)
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
35
|
+
process.stdin.off('data', drop); process.stdin.pause(); process.stdin.setRawMode?.(false)
|
|
36
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
37
|
+
try {
|
|
38
|
+
return await new Promise((resolve) => {
|
|
39
|
+
rl.on('SIGINT', () => { process.stdout.write('\n'); resolve(null) })
|
|
40
|
+
rl.on('close', () => resolve('n'))
|
|
41
|
+
rl.question(question).then(resolve, () => resolve('n'))
|
|
42
|
+
})
|
|
43
|
+
} finally { rl.close() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// stdio is inherited so brew and sudo can talk to the user. Ctrl-C reaches the
|
|
47
|
+
// installer; this process ignores it until the installer exits.
|
|
48
|
+
function runInstaller(command, args, { env }) {
|
|
49
|
+
const ignore = () => {}
|
|
50
|
+
process.on('SIGINT', ignore)
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const child = spawn(command, args, { stdio: 'inherit', env })
|
|
53
|
+
child.once('error', reject)
|
|
54
|
+
child.once('close', (code, signal) => resolve({ code, signal }))
|
|
55
|
+
}).finally(() => process.off('SIGINT', ignore))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function download(fetch, expected) {
|
|
59
|
+
const response = await fetch(INSTALL_URL, { signal: AbortSignal.timeout(30_000), redirect: 'follow' })
|
|
60
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
61
|
+
const chunks = []
|
|
62
|
+
let bytes = 0
|
|
63
|
+
for await (const chunk of response.body ?? []) {
|
|
64
|
+
bytes += chunk.length
|
|
65
|
+
// The pinned file is far smaller, so anything this big cannot match it.
|
|
66
|
+
if (bytes > MAX_SCRIPT_BYTES) throw Object.assign(new Error('checksum mismatch'), { code: 'CHECKSUM' })
|
|
67
|
+
chunks.push(chunk)
|
|
68
|
+
}
|
|
69
|
+
const body = Buffer.concat(chunks)
|
|
70
|
+
if (createHash('sha256').update(body).digest('hex') !== expected) throw Object.assign(new Error('checksum mismatch'), { code: 'CHECKSUM' })
|
|
71
|
+
return body
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const exists = (file) => fs.access(file).then(() => true, () => false)
|
|
75
|
+
|
|
76
|
+
// On macOS OpenRod runs local sandboxes in OpenShell's MicroVM driver. A fresh
|
|
77
|
+
// install would auto-select the Docker driver, which needs Docker Desktop's
|
|
78
|
+
// host networking (off by default). The VM driver needs e2fsprogs, and the
|
|
79
|
+
// gateway service reads its driver from gateway.env.
|
|
80
|
+
async function prepareMac({ brew, home, env, run, has, read, log }) {
|
|
81
|
+
const prefix = path.dirname(path.dirname(brew))
|
|
82
|
+
if (!await has(path.join(prefix, 'opt/e2fsprogs/sbin/mke2fs'))) {
|
|
83
|
+
log.info('Installing e2fsprogs, which OpenShell’s VM driver needs…')
|
|
84
|
+
const result = await run(brew, ['install', 'e2fsprogs'], { env: installerEnv(env, 'darwin') }).catch((error) => ({ error }))
|
|
85
|
+
if (result.signal === 'SIGINT' || result.code === 130) return 'cancelled'
|
|
86
|
+
if (result.error || result.code) log.warn('Could not install e2fsprogs. VM sandboxes need it, so run: brew install e2fsprogs')
|
|
87
|
+
}
|
|
88
|
+
// The service reads $HOME/.config (launchd sets no XDG_CONFIG_HOME), else the
|
|
89
|
+
// Homebrew prefix copy. An existing file is someone's setup and stays as is.
|
|
90
|
+
const file = path.join(home, '.config/openshell/gateway.env')
|
|
91
|
+
const hint = () => log.info(`To run sandboxes in VMs, add OPENSHELL_COMPUTE_DRIVER=vm to ${file}.`)
|
|
92
|
+
const envs = [file, path.join(prefix, 'var/openshell/gateway.env')]
|
|
93
|
+
const tomls = [path.join(home, '.config/openshell/gateway.toml'), path.join(prefix, 'var/openshell/gateway.toml')]
|
|
94
|
+
const kept = (await Promise.all(envs.map(has))).findIndex(Boolean)
|
|
95
|
+
if (kept >= 0) { if (!/^\s*(?:export\s+)?OPENSHELL_COMPUTE_DRIVER=/m.test(await read(envs[kept]))) hint(); return 'kept' }
|
|
96
|
+
for (const toml of tomls) if (/^\s*compute_driver\s*=/m.test(await read(toml))) return 'kept'
|
|
97
|
+
try {
|
|
98
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
99
|
+
await fs.writeFile(file, "# Added by OpenRod: run sandboxes in OpenShell's MicroVM driver.\nOPENSHELL_COMPUTE_DRIVER=vm\n", { mode: 0o600, flag: 'wx' })
|
|
100
|
+
} catch (error) { log.warn(`Could not write ${file} (${error.code ?? error.message}).`); hint(); return 'kept' }
|
|
101
|
+
return 'written'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function installerEnv(env, platform) {
|
|
105
|
+
const next = { ...env, OPENSHELL_VERSION: OPENSHELL_TAG }
|
|
106
|
+
if (platform === 'darwin') {
|
|
107
|
+
next.PATH = [...BREW_DIRS, env.PATH].filter(Boolean).join(path.delimiter)
|
|
108
|
+
next.HOMEBREW_NO_AUTO_UPDATE ??= '1'
|
|
109
|
+
}
|
|
110
|
+
return next
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Offers to install the pinned OpenShell release when its CLI is missing.
|
|
114
|
+
// Never installs over an existing CLI: the installer always reinstalls and
|
|
115
|
+
// restarts the gateway service, which stops running sandboxes.
|
|
116
|
+
export async function offerOpenShellInstall({
|
|
117
|
+
env = process.env, platform = process.platform, arch = process.arch,
|
|
118
|
+
interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !env.CI),
|
|
119
|
+
// Homebrew's own directories go first for brew, as in installerEnv.
|
|
120
|
+
find = (bin, extra = []) => findExecutable(bin, bin === 'brew' ? [...extra, ...pathDirs(env)] : [...pathDirs(env), ...extra]),
|
|
121
|
+
cli = () => openshellBinary(env), version = cliVersion, gateways = localGateways,
|
|
122
|
+
dockerReady = () => dockerRunning(find, env), ask = askTerminal, fetch = globalThis.fetch,
|
|
123
|
+
run = runInstaller, expected = INSTALL_SHA256, log = console, home = os.homedir(), has = exists,
|
|
124
|
+
read = (file) => fs.readFile(file, 'utf8').catch(() => ''), uid = process.getuid?.(),
|
|
125
|
+
} = {}) {
|
|
126
|
+
const command = installCommand(platform)
|
|
127
|
+
const manual = (line) => { log.info(line); log.info(` ${command}`) }
|
|
128
|
+
const bin = cli()
|
|
129
|
+
if (env.OPENSHELL_BIN && !bin) { log.warn(`OPENSHELL_BIN points to a missing file: ${env.OPENSHELL_BIN}`); return 'skipped' }
|
|
130
|
+
if (bin) {
|
|
131
|
+
const found = /\b(\d+\.\d+\.\d+)\b/.exec(await version(bin) ?? '')?.[1]
|
|
132
|
+
if (found && found !== OPENSHELL_VERSION) log.warn(`OpenRod is tested with OpenShell ${OPENSHELL_VERSION}; this computer has ${found}.`)
|
|
133
|
+
if (!gateways().length) { log.info('OpenShell is installed, but no local gateway is registered. Run:'); log.info(` ${addCommand(platform)}`) }
|
|
134
|
+
return 'present'
|
|
135
|
+
}
|
|
136
|
+
if (!SUPPORTED.includes(`${platform}-${arch}`)) { log.info('OpenShell needs Apple Silicon macOS or Linux. SSH hosts still work.'); return 'skipped' }
|
|
137
|
+
if (platform === 'darwin' && uid === 0) { log.info('Run npx openrod without sudo to install OpenShell: Homebrew doesn’t run as root.'); return 'skipped' }
|
|
138
|
+
if (platform === 'darwin' && !find('brew', BREW_DIRS)) { log.info('OpenShell installs with Homebrew. Install it from https://brew.sh, then run npx openrod again.'); return 'skipped' }
|
|
139
|
+
if (!find('curl')) { manual("OpenShell isn't installed, and its installer needs curl. Install curl, then run:"); return 'skipped' }
|
|
140
|
+
if (platform === 'linux' && !find('dpkg') && !find('rpm')) { log.info('OpenShell installs from .deb or .rpm packages, and this Linux has neither. SSH hosts still work.'); return 'skipped' }
|
|
141
|
+
if (!interactive) { manual("OpenShell isn't installed. To install it, run:"); return 'skipped' }
|
|
142
|
+
|
|
143
|
+
log.info("OpenShell isn't installed. OpenRod uses it to run sandboxes on this computer.")
|
|
144
|
+
const docker = await dockerReady()
|
|
145
|
+
// The Linux packages run sandboxes in Docker; on macOS the VM driver only needs it for images OpenRod builds.
|
|
146
|
+
const needs = platform === 'darwin' ? 'OpenRod needs it to build sandbox images.' : "OpenShell's gateway won't start without it."
|
|
147
|
+
if (docker === 'missing') log.info(`Docker isn't installed. ${needs}`)
|
|
148
|
+
else if (!docker) log.info(`Docker isn't running. ${needs}`)
|
|
149
|
+
let answer = await ask(`Install OpenShell ${OPENSHELL_VERSION} now? ${platform === 'darwin' ? 'It uses Homebrew and runs sandboxes in VMs.' : 'It uses sudo and may ask for your password.'} [Y/n] `)
|
|
150
|
+
// Anything but yes or no (a key from another keyboard layout, say) asks again.
|
|
151
|
+
for (let tries = 0; answer !== null && !['', 'y', 'yes', 'n', 'no'].includes(answer.trim().toLowerCase()) && tries < 3; tries++) answer = await ask('Please answer y or n. [Y/n] ')
|
|
152
|
+
if (answer === null) return 'cancelled'
|
|
153
|
+
if (!['', 'y', 'yes'].includes(answer.trim().toLowerCase())) { manual('Skipped. To install it later, run:'); return 'skipped' }
|
|
154
|
+
|
|
155
|
+
log.info(`Installing OpenShell ${OPENSHELL_VERSION}…`)
|
|
156
|
+
let script
|
|
157
|
+
try { script = await download(fetch, expected) } catch (error) {
|
|
158
|
+
if (error.code === 'CHECKSUM') { log.error('The OpenShell installer did not match the pinned release. Nothing was installed.'); return 'failed' }
|
|
159
|
+
log.error(`Could not download the OpenShell installer: ${error.cause?.message ?? error.message}. To install it yourself, run:`)
|
|
160
|
+
log.error(` ${command}`)
|
|
161
|
+
return 'failed'
|
|
162
|
+
}
|
|
163
|
+
if (platform === 'darwin' && await prepareMac({ brew: find('brew', BREW_DIRS), home, env, run, has, read, log }) === 'cancelled') return 'cancelled'
|
|
164
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openrod-openshell-'))
|
|
165
|
+
let result = {}
|
|
166
|
+
try {
|
|
167
|
+
const file = path.join(dir, 'install.sh')
|
|
168
|
+
await fs.writeFile(file, script, { mode: 0o600 })
|
|
169
|
+
result = await run('/bin/sh', [file], { env: installerEnv(env, platform) })
|
|
170
|
+
} catch (error) { log.error(`Could not run the OpenShell installer: ${error.message}`) }
|
|
171
|
+
finally { await fs.rm(dir, { recursive: true, force: true }) }
|
|
172
|
+
if (result.signal === 'SIGINT' || result.code === 130) return 'cancelled'
|
|
173
|
+
|
|
174
|
+
// The installer's exit code is not reliable: it can exit 0 without a gateway
|
|
175
|
+
// and 1 after a slow gateway start. Check what is actually there instead.
|
|
176
|
+
if (cli()) {
|
|
177
|
+
if (gateways().length) {
|
|
178
|
+
log.info(result.code === 0 ? `OpenShell ${OPENSHELL_VERSION} is ready.` : `OpenShell ${OPENSHELL_VERSION} is installed, but its gateway isn't answering yet. ${platform === 'darwin' ? 'Give it a minute' : 'Make sure Docker is running'}, then click Use this computer.`)
|
|
179
|
+
return 'ready'
|
|
180
|
+
}
|
|
181
|
+
log.warn("OpenShell is installed, but its gateway isn't registered. Run:")
|
|
182
|
+
log.warn(` ${restartCommand(platform)}`)
|
|
183
|
+
log.warn(` ${addCommand(platform)}`)
|
|
184
|
+
return 'partial'
|
|
185
|
+
}
|
|
186
|
+
log.error('OpenShell install failed. The reason is above. To try again, run:')
|
|
187
|
+
log.error(` ${command}`)
|
|
188
|
+
return 'failed'
|
|
189
|
+
}
|
package/server/policy.js
CHANGED
|
@@ -315,11 +315,9 @@ function profileView(p) {
|
|
|
315
315
|
// `openshell` CLI, which already knows how to turn profile YAML into the
|
|
316
316
|
// gateway's shape; the console only ever passes one of these fixed URLs.
|
|
317
317
|
export const PROFILE_CATALOG = [
|
|
318
|
-
['claude-code', 'Claude Code', 'agent'], ['codex', 'Codex', 'agent'], ['copilot', 'GitHub Copilot', 'agent'],
|
|
319
|
-
['
|
|
320
|
-
['
|
|
321
|
-
['github', 'GitHub', 'source_control'], ['pypi', 'PyPI', 'data'],
|
|
322
|
-
['aws', 'AWS', 'other'], ['aws-s3', 'AWS S3', 'other'], ['google-cloud', 'Google Cloud', 'other'],
|
|
318
|
+
['claude-code', 'Claude Code', 'agent'], ['codex', 'Codex', 'agent'], ['copilot', 'GitHub Copilot', 'agent'],
|
|
319
|
+
['openai', 'OpenAI', 'inference'], ['openrouter', 'OpenRouter', 'inference'],
|
|
320
|
+
['github', 'GitHub', 'source_control'],
|
|
323
321
|
].map(([id, name, category]) => ({ id, name, category }))
|
|
324
322
|
|
|
325
323
|
async function secrets() {
|
package/server/remote-gateway.js
CHANGED
|
@@ -8,17 +8,17 @@ import { randomUUID } from 'node:crypto'
|
|
|
8
8
|
import { Transform } from 'node:stream'
|
|
9
9
|
import { pipeline } from 'node:stream/promises'
|
|
10
10
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
11
|
-
import { CONFIG_DIR, clearConsoleContext, contextConfigured, contextSelection, defaultContextSelection,
|
|
11
|
+
import { CONFIG_DIR, clearConsoleContext, contextConfigured, contextSelection, defaultContextSelection, localGateways, gatewayWorkspaces, selectConsoleContext, unchosenCliGateway } from './gateway.js'
|
|
12
12
|
import { stateDirectory } from './paths.js'
|
|
13
|
-
import { fail, findExecutable, runCli, sshBinary } from './openshell-cli.js'
|
|
13
|
+
import { fail, findExecutable, openshellBinary, runCli, sshBinary } from './openshell-cli.js'
|
|
14
14
|
import { listSshHosts, probeHost, installDocker as installHostDocker, installRuntime, sshArgs } from './remote-hosts.js'
|
|
15
15
|
import { prepareGatewayState, registerManagedGateway } from './remote-gateway-state.js'
|
|
16
16
|
import { ensureGateway } from './gateway-install.js'
|
|
17
|
+
import { installCommand } from '../shared/openshell-release.js'
|
|
17
18
|
import { startRemoteRuntime } from './remote-runtime.js'
|
|
18
19
|
import { reapOrphans } from './orphan-processes.js'
|
|
19
20
|
|
|
20
21
|
const PACKAGE_LIMIT = 4 * 1024 ** 3
|
|
21
|
-
const localGateways = () => listGateways().filter(target => target.name !== 'aws-eks' && target.supported && !target.remote && ['localhost', '127.0.0.1', '[::1]'].includes(new URL(target.endpoint).hostname))
|
|
22
22
|
|
|
23
23
|
function ownedProcess(executable, args, env, onExit) {
|
|
24
24
|
const child = spawn(executable, args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
@@ -319,9 +319,16 @@ export function createRemoteConnections({ onSelected = () => {}, onDeselected =
|
|
|
319
319
|
async overview() {
|
|
320
320
|
const snapshot = await locationSnapshot()
|
|
321
321
|
const connection = snapshot.remote
|
|
322
|
-
return { hosts: await listSshHosts(), locals: localGateways().map(({ name, endpoint }) => ({ name, endpoint })), active: view(connection), job: view(job), tools: { ssh: Boolean(sshBinary()), gateway: Boolean(findExecutable('openshell-gateway')) } }
|
|
322
|
+
return { hosts: await listSshHosts(), locals: localGateways().map(({ name, endpoint }) => ({ name, endpoint })), active: view(connection), job: view(job), tools: { ssh: Boolean(sshBinary()), gateway: Boolean(findExecutable('openshell-gateway')), openshell: Boolean(openshellBinary()), installCommand: installCommand(process.platform) } }
|
|
323
323
|
},
|
|
324
324
|
changing: () => disconnecting || job?.status === 'working',
|
|
325
|
+
// A fresh console connects to the CLI's local gateway the way "Use this
|
|
326
|
+
// computer" does, but only once it answers; otherwise setup stays up.
|
|
327
|
+
async autoSelect() {
|
|
328
|
+
const name = unchosenCliGateway()
|
|
329
|
+
if (!name || job?.status === 'working' || !localGateways().some(target => target.name === name)) return false
|
|
330
|
+
try { await select(name); return true } catch { return false }
|
|
331
|
+
},
|
|
325
332
|
architecture: () => active?.status === 'connected' ? active.architecture : null,
|
|
326
333
|
begin,
|
|
327
334
|
job: id => view(getJob(id)),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The OpenShell release OpenRod is tested with. Bump all of these together, and
|
|
2
|
+
// also gateway-install.js (VERSION, RELEASES digests), remote-runtime.js (image and
|
|
3
|
+
// version check), remote-hosts.js (chart) and policy.js (provider URL).
|
|
4
|
+
export const OPENSHELL_VERSION = '0.1.2'
|
|
5
|
+
export const OPENSHELL_TAG = `v${OPENSHELL_VERSION}`
|
|
6
|
+
export const INSTALL_URL = `https://raw.githubusercontent.com/NVIDIA/OpenShell/${OPENSHELL_TAG}/install.sh`
|
|
7
|
+
// install.sh at tag v0.1.2 (commit 6648bd0c290efbc41ba131ee9831ee45cd431f94), 45210 bytes.
|
|
8
|
+
export const INSTALL_SHA256 = '5c98a86a4b811c471b212219cb2a62d458244220ffa71ac8e3baf3700b17b871'
|
|
9
|
+
export const INSTALL_COMMAND = `curl -LsSf ${INSTALL_URL} | OPENSHELL_VERSION=${OPENSHELL_TAG} sh`
|
|
10
|
+
// On macOS sandboxes run in the MicroVM driver (e2fsprogs plus gateway.env), set
|
|
11
|
+
// up before the installer so its first gateway start already uses it.
|
|
12
|
+
export const MAC_INSTALL_COMMAND = `brew install e2fsprogs && mkdir -p ~/.config/openshell && { [ -e ~/.config/openshell/gateway.env ] || echo OPENSHELL_COMPUTE_DRIVER=vm > ~/.config/openshell/gateway.env; } && ${INSTALL_COMMAND}`
|
|
13
|
+
export const installCommand = (platform) => platform === 'darwin' ? MAC_INSTALL_COMMAND : INSTALL_COMMAND
|
package/src/lib/api.js
CHANGED
|
@@ -52,7 +52,7 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
|
|
|
52
52
|
currentSignal?.throwIfAborted()
|
|
53
53
|
if (payload.code === LINK_REQUIRED) window.dispatchEvent(new Event(LINK_REQUIRED))
|
|
54
54
|
else if (response.status === 401 || payload.code === 'CLOUD_OWNER_CHANGED') window.dispatchEvent(new Event('openrod-session-expired'))
|
|
55
|
-
if (!response.ok) throw Object.assign(new Error(payload.error ?? `Request failed (${response.status})`), { code: payload.code, sandboxes: payload.sandboxes })
|
|
55
|
+
if (!response.ok) throw Object.assign(new Error(payload.error ?? `Request failed (${response.status})`), { code: payload.code, sandboxes: payload.sandboxes, fix: payload.fix })
|
|
56
56
|
return payload
|
|
57
57
|
}
|
|
58
58
|
return {
|
|
@@ -77,6 +77,9 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
|
|
|
77
77
|
forgetRemote: () => request('/connections/forget', { method: 'POST', body: {} }),
|
|
78
78
|
disconnectRemote: () => request('/connections/disconnect', { method: 'POST', body: {} }),
|
|
79
79
|
syncLocalCatalog: () => request('/local-catalog', { method: 'POST', body: {} }),
|
|
80
|
+
gatewayDocker: (fresh = false) => request(`/gateway-docker${fresh ? '?fresh=1' : ''}`, { scoped: false }),
|
|
81
|
+
connectGatewayDocker: (seen = [], confirm = true) => request('/gateway-docker/connect', { method: 'POST', body: { confirm, seen }, scoped: false }),
|
|
82
|
+
undoGatewayDocker: (seen = []) => request('/gateway-docker/undo', { method: 'POST', body: { confirm: true, seen }, scoped: false }),
|
|
80
83
|
setups: () => request('/setups'),
|
|
81
84
|
discoverSetups: (sources) => request('/setups/scan', { method: 'POST', body: { sources } }),
|
|
82
85
|
reviewSetup: (token, ids) => request('/setups/review', { method: 'POST', body: { token, ids } }),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Follows whether OpenShell's VM driver can see the Docker that OpenRod builds
|
|
2
|
+
// template images with (NVIDIA/OpenShell#4155). The server makes the change;
|
|
3
|
+
// this store polls its status and asks before anything restarts.
|
|
4
|
+
|
|
5
|
+
// The driver's message when it fell through to Docker Hub for a local image.
|
|
6
|
+
export const dockerImageProblem = (p) => /failed to resolve .*image/i.test(p ?? '') && /docker\.io/.test(p ?? '')
|
|
7
|
+
|
|
8
|
+
const restartsNothing = (s) => Array.isArray(s?.sandboxes) && !s.sandboxes.length && !s.stranded?.length && !s.conflict
|
|
9
|
+
const seenKeys = (sandboxes) => (sandboxes ?? []).map((s) => typeof s === 'string' ? s : `${s.workspace}/${s.name}`)
|
|
10
|
+
|
|
11
|
+
export function createGatewayDocker(api, { schedule = setTimeout, cancel = clearTimeout } = {}) {
|
|
12
|
+
let snapshot = { status: null, asking: null, error: null }
|
|
13
|
+
let timer = null, after = null
|
|
14
|
+
const listeners = new Set()
|
|
15
|
+
const set = (patch) => { snapshot = { ...snapshot, ...patch }; listeners.forEach((listener) => listener()) }
|
|
16
|
+
|
|
17
|
+
function poll() {
|
|
18
|
+
cancel(timer); timer = null
|
|
19
|
+
if (listeners.size) timer = schedule(() => { void refresh() }, snapshot.status?.state === 'working' ? 2000 : 15_000)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Runs `then` once the job the user started has finished.
|
|
23
|
+
function follow(status) {
|
|
24
|
+
if (!after || status?.job?.status === 'working') return
|
|
25
|
+
const { id, then } = after
|
|
26
|
+
after = null
|
|
27
|
+
if (status?.job?.id === id && status.job.status === 'done') then?.()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function refresh(fresh = false) {
|
|
31
|
+
try {
|
|
32
|
+
const status = await api.gatewayDocker(fresh)
|
|
33
|
+
set({ status })
|
|
34
|
+
follow(status)
|
|
35
|
+
return status
|
|
36
|
+
} catch { return snapshot.status } finally { poll() }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function run(kind, seen, then, confirm = true) {
|
|
40
|
+
set({ asking: snapshot.asking && { ...snapshot.asking, busy: true }, error: null })
|
|
41
|
+
try {
|
|
42
|
+
const status = await (kind === 'undo' ? api.undoGatewayDocker(seenKeys(seen)) : api.connectGatewayDocker(seenKeys(seen), confirm))
|
|
43
|
+
set({ status, asking: null })
|
|
44
|
+
if (status.state === 'working' && status.job) after = { id: status.job.id, then }
|
|
45
|
+
else if (status.state !== 'mismatch') then?.()
|
|
46
|
+
return status
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.code === 'GATEWAY_DOCKER_MISMATCH' && !(kind === 'undo' && error.fix === 'manual')) {
|
|
49
|
+
// Something changed since the user looked: show them the current list.
|
|
50
|
+
const status = await refresh(true)
|
|
51
|
+
set({ asking: { kind: error.fix === 'manual' ? 'steps' : kind, then, sandboxes: kind === 'undo' ? error.sandboxes ?? null : status?.sandboxes ?? error.sandboxes ?? null, busy: false } })
|
|
52
|
+
} else set({ error: error.message, asking: { ...(snapshot.asking ?? { kind, then, sandboxes: snapshot.status?.sandboxes ?? null }), busy: false } })
|
|
53
|
+
return null
|
|
54
|
+
} finally { poll() }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
subscribe(listener) {
|
|
59
|
+
listeners.add(listener)
|
|
60
|
+
if (listeners.size === 1) void refresh()
|
|
61
|
+
return () => { listeners.delete(listener); if (!listeners.size) { cancel(timer); timer = null } }
|
|
62
|
+
},
|
|
63
|
+
getSnapshot: () => snapshot,
|
|
64
|
+
refresh,
|
|
65
|
+
// `seen` is the sandboxes the user was shown; the server refuses if more would restart.
|
|
66
|
+
connect: (seen = snapshot.asking?.sandboxes) => run('connect', seen, snapshot.asking?.then),
|
|
67
|
+
undo: (seen = snapshot.asking?.sandboxes) => run('undo', seen, snapshot.asking?.then),
|
|
68
|
+
ask({ kind, then } = {}) {
|
|
69
|
+
const s = snapshot.status
|
|
70
|
+
kind ??= s?.fix === 'manual' ? 'steps' : 'connect'
|
|
71
|
+
// A stale or unloaded status can't vouch for the restart: let the server ask.
|
|
72
|
+
if (kind === 'connect' && (s?.state !== 'mismatch' || restartsNothing(s))) return run('connect', [], then, s?.state === 'mismatch')
|
|
73
|
+
set({ asking: { kind, then, sandboxes: kind === 'connect' ? s?.sandboxes ?? null : null, busy: false }, error: null })
|
|
74
|
+
},
|
|
75
|
+
close: () => set({ asking: null, error: null }),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -29,7 +29,7 @@ export function createSandboxCreations() {
|
|
|
29
29
|
update(id, { status: "created", sandbox: { ...sandbox, location: sandbox.location ?? job.location }, message: "" })
|
|
30
30
|
}).catch((error) => {
|
|
31
31
|
if (controller.signal.aborted || error.name === "AbortError") update(id, { status: "cancelled", message: "Creation cancelled." })
|
|
32
|
-
else update(id, { status: "failed", error: error.message, message: "" })
|
|
32
|
+
else update(id, { status: "failed", error: error.message, code: error.code ?? null, fix: error.fix ?? null, message: "" })
|
|
33
33
|
})
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -44,7 +44,8 @@ export function createSandboxCreations() {
|
|
|
44
44
|
run(id)
|
|
45
45
|
return id
|
|
46
46
|
},
|
|
47
|
-
retry(
|
|
47
|
+
// Only a failed job reruns; a late retry (Connect's `then`) must not start a second task.
|
|
48
|
+
retry(id) { if (runs.has(id) && snapshot.find((job) => job.id === id)?.status === "failed") run(id) },
|
|
48
49
|
cancel(id) {
|
|
49
50
|
const job = snapshot.find((item) => item.id === id)
|
|
50
51
|
if (job?.status !== "preparing") return
|
package/src/lib/services.js
CHANGED
|
@@ -4,27 +4,16 @@
|
|
|
4
4
|
export const SERVICE_GROUPS = [
|
|
5
5
|
{ id: "agent", label: "Agents" },
|
|
6
6
|
{ id: "inference", label: "Models" },
|
|
7
|
-
{ id: "code", label: "Code
|
|
8
|
-
{ id: "cloud", label: "Cloud" },
|
|
7
|
+
{ id: "code", label: "Code" },
|
|
9
8
|
]
|
|
10
9
|
|
|
11
10
|
export const SERVICES = [
|
|
12
11
|
{ id: "claude-code", name: "Claude Code", group: "agent", blurb: "Anthropic's coding agent", keyUrl: "https://console.anthropic.com/settings/keys", keyHint: "sk-ant-…" },
|
|
13
12
|
{ id: "codex", name: "Codex", group: "agent", blurb: "Existing Codex OAuth session", helpUrl: "https://developers.openai.com/codex/auth/" },
|
|
14
13
|
{ id: "copilot", name: "Copilot", group: "agent", blurb: "GitHub Copilot CLI" },
|
|
15
|
-
{ id: "cursor", name: "Cursor", group: "agent", blurb: "Cursor bootstrap network access" },
|
|
16
|
-
{ id: "anthropic", name: "Anthropic", group: "inference", blurb: "Claude API", keyUrl: "https://console.anthropic.com/settings/keys", keyHint: "sk-ant-…" },
|
|
17
14
|
{ id: "openai", name: "OpenAI", group: "inference", blurb: "GPT models API", keyUrl: "https://platform.openai.com/api-keys", keyHint: "sk-…" },
|
|
18
|
-
{ id: "nvidia", name: "NVIDIA", group: "inference", blurb: "NIM model endpoints", keyUrl: "https://build.nvidia.com", keyHint: "nvapi-…" },
|
|
19
15
|
{ id: "openrouter", name: "OpenRouter", group: "inference", blurb: "One key, many models", keyUrl: "https://openrouter.ai/keys", keyHint: "sk-or-…" },
|
|
20
|
-
{ id: "deepinfra", name: "DeepInfra", group: "inference", blurb: "Open models, hosted" },
|
|
21
|
-
{ id: "google-vertex-ai", name: "Vertex AI", group: "inference", blurb: "Google Cloud models" },
|
|
22
|
-
{ id: "aws-bedrock", name: "Bedrock", group: "inference", blurb: "Models on AWS" },
|
|
23
16
|
{ id: "github", name: "GitHub", group: "code", blurb: "Repos and API", keyUrl: "https://github.com/settings/personal-access-tokens", keyHint: "github_pat_…" },
|
|
24
|
-
{ id: "pypi", name: "PyPI", group: "code", blurb: "Install Python packages" },
|
|
25
|
-
{ id: "aws", name: "AWS", group: "cloud", blurb: "AWS APIs" },
|
|
26
|
-
{ id: "aws-s3", name: "S3", group: "cloud", blurb: "Buckets and objects" },
|
|
27
|
-
{ id: "google-cloud", name: "Google Cloud", group: "cloud", blurb: "GCP APIs" },
|
|
28
17
|
]
|
|
29
18
|
|
|
30
19
|
export const serviceOf = (type) => SERVICES.find((s) => s.id === type) ?? null
|