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.
@@ -1,4 +1,4 @@
1
- import{u as Jr,a as Zr,r as Te,d as Qr,s as as,b as en,j as U,p as hs,T as tn,L as sn,B as ls,R as rn,D as nn,c as on,C as an,e as hn,f as ln,t as cn}from"./index-Ddo8D1tE.js";/**
1
+ import{u as Jr,a as Zr,r as Te,d as Qr,s as as,b as en,j as U,p as hs,T as tn,L as sn,B as ls,R as rn,D as nn,c as on,C as an,e as hn,f as ln,t as cn}from"./index-DoKK2G9s.js";/**
2
2
  * Copyright (c) 2014-2024 The xterm.js authors. All rights reserved.
3
3
  * @license MIT
4
4
  *
package/dist/index.html CHANGED
@@ -7,8 +7,8 @@
7
7
  <meta name="theme-color" content="#ffffff" />
8
8
  <meta name="description" content="OpenShell control plane - sandboxes, approvals and live activity" />
9
9
  <title>OpenRod</title>
10
- <script type="module" crossorigin src="/assets/index-Ddo8D1tE.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DbxTgFt9.css">
10
+ <script type="module" crossorigin src="/assets/index-DoKK2G9s.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-89TNohi_.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -6,7 +6,6 @@ Every SVG in this directory is bundled locally so the console never contacts a l
6
6
  | --- | --- | --- |
7
7
  | Lobe Icons (`@lobehub/icons-static-svg` 1.95.1) | MIT, Copyright (c) 2023 LobeHub | https://github.com/lobehub/lobe-icons |
8
8
  | GitHub Octicons (`@primer/octicons` 19.38.0) | MIT, Copyright (c) 2026 GitHub Inc. | https://github.com/primer/octicons |
9
- | Simple Icons (`simple-icons`) | CC0-1.0 | https://github.com/simple-icons/simple-icons |
10
9
  | Devicon (`devicon` 2.17.0) | MIT, Copyright (c) 2015 konpa | https://github.com/devicons/devicon |
11
10
  | OpenRod authors | Apache-2.0 (this project) | - |
12
11
 
@@ -14,22 +13,13 @@ Every SVG in this directory is bundled locally so the console never contacts a l
14
13
 
15
14
  | File | Source | Upstream icon |
16
15
  | --- | --- | --- |
17
- | `anthropic.svg` | Simple Icons | `anthropic` |
18
- | `aws.svg` | Lobe Icons | `aws-color` |
19
- | `aws-bedrock.svg` | Lobe Icons | `bedrock-color` |
20
- | `aws-s3.svg` | OpenRod authors | neutral storage icon |
21
16
  | `claude-code.svg` | Lobe Icons | `claudecode-color` |
22
17
  | `codex.svg` | Lobe Icons | `codex-color` |
23
18
  | `copilot.svg` | GitHub Octicons | `copilot-24` |
24
19
  | `cursor.svg` | Lobe Icons | `cursor` |
25
- | `deepinfra.svg` | Lobe Icons | `deepinfra-color` |
26
20
  | `github.svg` | GitHub Octicons | `mark-github-24` |
27
- | `google-cloud.svg` | Simple Icons | `googlecloud` |
28
- | `google-vertex-ai.svg` | Lobe Icons | `vertexai-color` |
29
- | `nvidia.svg` | Lobe Icons | `nvidia-color` |
30
21
  | `openai.svg` | Lobe Icons | `openai` |
31
22
  | `openrouter.svg` | Lobe Icons | `openrouter-color` |
32
- | `pypi.svg` | Simple Icons | `pypi` |
33
23
  | `vscode.svg` | Devicon | `vscode` |
34
24
  | `agents/aider.svg` | OpenRod authors | neutral terminal icon |
35
25
  | `agents/antigravity.svg` | Lobe Icons | `antigravity-color` |
@@ -127,7 +117,3 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
127
117
  IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
128
118
  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
129
119
  ```
130
-
131
- ### Simple Icons
132
-
133
- Simple Icons are released under CC0-1.0 (https://creativecommons.org/publicdomain/zero/1.0/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openrod",
3
- "version": "0.1.1",
3
+ "version": "0.1.2-rc.2",
4
4
  "description": "A local console for OpenShell gateways, sandboxes, and activity.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/OpenRod/OpenRod#readme",
package/server/api.js CHANGED
@@ -6,7 +6,7 @@ import { setupTargetsFor, validateSetupTargets } from '../shared/setup-targets.j
6
6
  import { localTransfer, importTransfer, exportTransfer } from './cloud-transfer.js'
7
7
  import path from 'node:path'
8
8
  import { Readable } from 'node:stream'
9
- import { scopedStateDirectory } from './paths.js'
9
+ import { scopedStateDirectory, stateDirectory } from './paths.js'
10
10
  import { createActivityStore } from './activity-store.js'
11
11
  import { createActivityDelivery } from './activity-delivery.js'
12
12
  import { exportEvent } from '../src/lib/activity-export.js'
@@ -15,14 +15,16 @@ import { randomUUID } from 'node:crypto'
15
15
  import { imageTemplateLabels, nameSandboxImages } from '../src/lib/sandbox-images.js'
16
16
  import { PROJECT_LABEL, templateSession, isSession, sessionLaunch, persistentTerminalPolicy, persistentGateway, PERSISTENT_TERMINAL_LABEL } from '../src/lib/sandbox-session.js'
17
17
  import { sandboxIdentityLabels } from './sandbox-identity.js'
18
- import { consoleContext, contextConfigured, contextKey, contextSelection, gateway, iso, logView, policyView, providerView, runWithContext, sandboxView, selectConsoleContext } from './gateway.js'
18
+ import { consoleContext, contextConfigured, contextKey, contextSelection, forgetGatewayConnection, gateway, gatewayWorkspaces, iso, listGateways, logView, policyView, providerView, runWithContext, sandboxView, selectConsoleContext, workspaceScope } from './gateway.js'
19
19
  import { createRemoteConnections } from './remote-gateway.js'
20
20
  import { createSshHostStore } from './ssh-hosts.js'
21
21
  import { createLocationInventory } from './location-inventory.js'
22
22
  import { policyRoute } from './policy.js'
23
23
  import { orgRoute, createInGroups, enforcePolicyOnly, startOrgSweeper, assignGroup } from './org.js'
24
24
  import { expose, ingressRoute, startSweeper } from './ingress.js'
25
- import { imageTemplateRoute, imageTemplateForLaunch, listImageTemplates } from './image-templates.js'
25
+ import { imageTemplateRoute, imageTemplateForLaunch, listImageTemplates, localEngine, runDocker, savingTemplate, setGatewayDocker } from './image-templates.js'
26
+ import { createGatewayDocker } from './gateway-docker.js'
27
+ import { runCli } from './openshell-cli.js'
26
28
  import { editorRoute } from './editor.js'
27
29
  import { terminalRoute, terminalUpgrade } from './terminal.js'
28
30
  import { assertPackagesPrepared } from '../shared/setup-launch.js'
@@ -135,10 +137,11 @@ async function sandboxDetail(name) {
135
137
  // ---- writes -----------------------------------------------------------------
136
138
 
137
139
  export async function createSandbox(input, { sessionOverride = false } = {}) {
138
- const { client, target, workspace } = await gateway()
139
140
  // An image template is an OpenShell sandbox template: the gateway supplies
140
141
  // its image and environment; the console adds how the sandbox starts.
141
142
  const saved = input.imageTemplate ? await imageTemplateForLaunch(String(input.imageTemplate)) : null
143
+ // After the template check, which can restart the local gateway.
144
+ const { client, target, workspace } = await gateway()
142
145
  const imageLabels = imageTemplateLabels(saved)
143
146
  // A built template's agents get their sign-in and model destinations from
144
147
  // the reviewed table in shared/agent-access.js, never from the recipe.
@@ -347,7 +350,9 @@ export function openshellApi(security = createSecurity(releaseConfig()), token =
347
350
 
348
351
  // Both Vite and the installed CLI use this exact HTTP/WebSocket lifecycle.
349
352
  export function createOpenShellApi({ httpServer, logger = console, security = createSecurity(releaseConfig()), token = createLaunchToken() } = {}) {
350
- const gate = security.config.mode === 'local' ? createTokenGate(token) : null
353
+ const gate = security.config.mode === 'local' ? createTokenGate(token, {
354
+ onReuse: () => logger.warn('The link OpenRod opened in your browser was used a second time. If that wasn’t you, another program may have opened the console first: stop OpenRod with Ctrl+C and start it again.'),
355
+ }) : null
351
356
  const runtimes = new Map(), streams = new Set(), sockets = new Set(), pending = new Set(), responses = new Set()
352
357
  const localCloud = security.config.mode === 'local' ? createLocalCloud({ native: createLocalCloudNative() }) : null
353
358
  const cloudOff = security.config.mode === 'local' && !cloudOrigin()
@@ -384,6 +389,8 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
384
389
  active = next
385
390
  if (httpServer?.listening) runWithContext(next.context, () => next.hub.start())
386
391
  }
392
+ // Read-only warm-up; edits wait for a build or launch that needs them.
393
+ void gatewayDocker?.status({ fresh: true }).catch(() => {})
387
394
  return next
388
395
  }
389
396
  const sshHosts = security.config.mode === 'local' ? createSshHostStore() : null
@@ -394,12 +401,43 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
394
401
  },
395
402
  logger,
396
403
  }) : null
404
+ // The local gateway's VM driver must use the Docker that builds templates.
405
+ const gatewayDocker = security.config.mode === 'local' ? createGatewayDocker({
406
+ target: async () => {
407
+ const local = (await remoteConnections.locationSnapshot()).returnContext
408
+ return local ? listGateways().find((entry) => entry.name === local.gateway) ?? null : null
409
+ },
410
+ client: async (name) => (await gateway({ gateway: name, workspace: 'default' })).client,
411
+ forget: forgetGatewayConnection,
412
+ sandboxes: async (name) => {
413
+ const { client } = await gateway({ gateway: name, workspace: 'default' })
414
+ const all = []
415
+ for (const { name: workspace } of await gatewayWorkspaces(name)) {
416
+ let pageToken = ''
417
+ do {
418
+ const page = await client.raw.listSandboxes({ workspaceScope: workspaceScope(workspace), pageSize: 1000, pageToken })
419
+ all.push(...page.sandboxes.map(sandboxView).map((s) => ({ name: s.name, workspace: s.workspace || workspace, phase: s.phase, image: s.image })))
420
+ pageToken = page.nextPageToken
421
+ } while (pageToken)
422
+ }
423
+ return all
424
+ },
425
+ localEngine: () => localEngine(), docker: runDocker, run: runCli,
426
+ busy: () => remoteConnections.changing() ? 'connection' : savingTemplate() ? 'saving' : null,
427
+ stateFile: path.join(stateDirectory(), 'gateway-docker.json'),
428
+ onJob: (job) => { pending.add(job); job.then(() => pending.delete(job), () => pending.delete(job)) },
429
+ logger,
430
+ }) : null
431
+ setGatewayDocker(gatewayDocker)
397
432
  const inventory = createLocationInventory({
398
433
  connections: remoteConnections, listSandboxes, listTemplates: listImageTemplates, logger,
399
434
  defaultLabel: security.config.mode === 'local' ? 'Local' : 'Cloud',
400
435
  })
401
- const start = async () => {
402
- if (closed || (security.config.mode === 'local' && !contextConfigured())) return
436
+ let ready = Promise.resolve()
437
+ const start = () => { ready = begin(); return ready }
438
+ const begin = async () => {
439
+ if (closed) return
440
+ if (security.config.mode === 'local' && !contextConfigured()) { await remoteConnections?.autoSelect(); return }
403
441
  if (remoteConnections && /^console-ssh-[a-f0-9]{24}$/.test(initialContext.gateway)) {
404
442
  const remote = (await remoteConnections.locationSnapshot()).remote
405
443
  if (remote?.gateway !== initialContext.gateway || remote.status !== 'connected') return
@@ -467,7 +505,7 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
467
505
  const parts = url.pathname.slice('/api/os'.length).split('/').filter(Boolean)
468
506
  assertCloudOperation(parts)
469
507
  if (cloudOff && ['cloud-export', 'cloud-import', 'cloud-transfer'].includes(parts[0])) return send(res, 409, { error: CLOUD_SOON })
470
- if (security.config.mode !== 'local' && (parts[0] === 'connections' || (parts[0] === 'context' && req.method !== 'GET') || (parts[0] === 'sandboxes' && ['ssh', 'ssh-open', 'ssh-config'].includes(parts[2])))) throw fail('Host-local actions are unavailable in OpenRod Cloud.', 403)
508
+ if (security.config.mode !== 'local' && (parts[0] === 'connections' || parts[0] === 'gateway-docker' || (parts[0] === 'context' && req.method !== 'GET') || (parts[0] === 'sandboxes' && ['ssh', 'ssh-open', 'ssh-config'].includes(parts[2])))) throw fail('Host-local actions are unavailable in OpenRod Cloud.', 403)
471
509
  const requestedContext = req.headers['x-openshell-context'] ?? url.searchParams.get('context')
472
510
  let owner = contextSelection()
473
511
  const explicitLocation = req.headers['x-openshell-location'] === '1' || url.searchParams.get('location') === '1'
@@ -510,6 +548,15 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
510
548
  }
511
549
  return send(res, 404, { error: 'Not found' })
512
550
  }
551
+ if (parts[0] === 'gateway-docker') {
552
+ if (!gatewayDocker) return send(res, 404, { error: 'Not found' })
553
+ if (req.method === 'GET' && parts.length === 1) return send(res, 200, await gatewayDocker.status({ fresh: url.searchParams.get('fresh') === '1' }))
554
+ if (req.method !== 'POST' || parts.length !== 2 || !['connect', 'undo'].includes(parts[1])) return send(res, 404, { error: 'Not found' })
555
+ if (!isMutation(req, security)) return send(res, 403, { error: 'Request rejected' })
556
+ const input = await body(req)
557
+ const seen = Array.isArray(input.seen) ? input.seen.slice(0, 500).map(String) : []
558
+ return send(res, 200, await gatewayDocker[parts[1]]({ confirm: input.confirm === true, seen }))
559
+ }
513
560
  if (req.method === 'GET' && parts.length === 1) {
514
561
  if (parts[0] === 'context') {
515
562
  const remote = remoteConnections ? (await remoteConnections.locationSnapshot()).remote : null
@@ -631,7 +678,7 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
631
678
  } catch (error) {
632
679
  // Gateway errors carry a readable message; nothing here includes credentials.
633
680
  if (res.headersSent) res.destroy(error)
634
- else send(res, error.status ?? 502, { error: error.rawMessage ?? error.message ?? 'Gateway request failed', ...(error.code === 'TEMPLATE_IN_USE' ? { code: error.code, sandboxes: error.sandboxes } : {}) })
681
+ else send(res, error.status ?? 502, { error: error.rawMessage ?? error.message ?? 'Gateway request failed', ...(error.code === 'TEMPLATE_IN_USE' || error.code === 'GATEWAY_DOCKER_MISMATCH' ? { code: error.code, sandboxes: error.sandboxes, fix: error.fix } : {}) })
635
682
  }
636
683
  })
637
684
  pending.add(operation)
@@ -644,5 +691,5 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
644
691
  const authenticate = () => security.middleware(req, res, () => route(req, res, next))
645
692
  return localCloud ? localCloud.middleware(req, res, authenticate) : authenticate()
646
693
  }
647
- return { middleware, close, token }
694
+ return { middleware, close, token, launchCode: gate?.launchCode, ready: () => ready }
648
695
  }
package/server/cli.js CHANGED
@@ -9,21 +9,25 @@ import { parseArgs } from 'node:util'
9
9
 
10
10
  const HELP = `Usage: openrod [--host 127.0.0.1] [--port 4600] [--open | --no-open]
11
11
 
12
- Serve OpenRod locally for local sandboxes or configured SSH hosts.
13
- Open the printed link: it carries a secret that changes on every start.
12
+ Serve OpenRod locally for local sandboxes or configured SSH hosts and
13
+ open it in your default browser. The link carries a secret that changes
14
+ on every start. Over SSH, in CI, without a display, or when the output is
15
+ not a terminal, the link is only printed.
14
16
 
15
17
  --host <host> Loopback only: 127.0.0.1, localhost, or ::1
16
18
  --port <port> HTTP port from 1 to 65535 (default: 4600)
17
- --open Open the console in your default browser
18
- --no-open Do not open a browser (default)
19
+ --open Always open the console in your default browser
20
+ --no-open Only print the link
19
21
  --help, -h Show this help
20
22
  --version, -v Show the installed version
21
23
 
22
24
  Mutable data: OPENSHELL_CONSOLE_DATA_DIR, or
23
25
  $XDG_STATE_HOME/openshell-console (~/.local/state/openshell-console).
24
26
  Requires Node.js >=22.13.0. SSH hosts need local OpenSSH and OpenSSL,
25
- plus a running Docker Engine on the remote Linux host. A missing local
26
- gateway executable is downloaded and checksum-verified on supported platforms.
27
+ plus a running Docker Engine on the remote Linux host.
28
+ If OpenShell is missing, openrod offers to install the pinned 0.1.2
29
+ release (macOS needs Homebrew). Connecting an SSH host downloads a missing
30
+ openshell-gateway executable, checksum-verified, on supported platforms.
27
31
  `
28
32
 
29
33
  export function nodeSupported(version = process.versions.node) {
@@ -51,7 +55,15 @@ export function parseOptions(args = process.argv.slice(2)) {
51
55
  if (!['127.0.0.1', 'localhost', '::1'].includes(values.host)) throw new Error('--host must be a loopback address: 127.0.0.1, localhost, or ::1.')
52
56
  if (!/^\d+$/.test(values.port) || Number(values.port) < 1 || Number(values.port) > 65535) throw new Error('--port must be an integer from 1 to 65535.')
53
57
  if (values.open && values['no-open']) throw new Error('Choose either --open or --no-open, not both.')
54
- return { host: values.host, port: Number(values.port), open: Boolean(values.open), help: Boolean(values.help), version: Boolean(values.version) }
58
+ return { host: values.host, port: Number(values.port), open: values.open ? true : values['no-open'] ? false : undefined, help: Boolean(values.help), version: Boolean(values.version) }
59
+ }
60
+
61
+ // Opening the page hands over the launch link without copying it, so it is the
62
+ // default. A session with no screen to open on only prints the link.
63
+ export function autoOpen({ env = process.env, platform = process.platform, tty = process.stdout.isTTY } = {}) {
64
+ const set = (name) => Boolean(env[name]) && !['0', 'false'].includes(env[name].toLowerCase())
65
+ if (!tty || set('CI') || env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return false
66
+ return ['darwin', 'win32'].includes(platform) || Boolean(env.DISPLAY || env.WAYLAND_DISPLAY)
55
67
  }
56
68
 
57
69
  const TYPES = {
@@ -104,8 +116,8 @@ function openBrowser(url, logger) {
104
116
  const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open'
105
117
  const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
106
118
  const child = spawn(command, args, { stdio: 'ignore', detached: true })
107
- child.once('error', (error) => logger.warn(`Could not open a browser: ${error.message}. Open ${url} manually.`))
108
- child.once('exit', (code) => { if (code) logger.warn(`Browser opener exited with status ${code}. Open ${url} manually.`) })
119
+ child.once('error', (error) => logger.warn(`Couldn’t open a browser (${error.message}). Open the link above.`))
120
+ child.once('exit', (code) => { if (code) logger.warn(`Couldn’t open a browser (exit ${code}). Open the link above.`) })
109
121
  child.unref()
110
122
  }
111
123
 
@@ -159,7 +171,11 @@ export async function startConsole(options = parseOptions([]), logger = console)
159
171
  // The link carries this launch's secret; it changes on every restart.
160
172
  const url = tokenUrl(host, port, token)
161
173
  logger.info(`OpenRod console (open this link): ${url}`)
162
- if (options.open) openBrowser(url, logger)
174
+ if (options.open) {
175
+ // A fresh console first connects to its local gateway, so the page opens on it.
176
+ await Promise.race([api.ready?.(), new Promise((resolve) => setTimeout(resolve, 5000).unref())])
177
+ openBrowser(tokenUrl(host, port, api.launchCode?.() ?? token), logger)
178
+ }
163
179
  return { server, url, close }
164
180
  }
165
181
 
@@ -169,7 +185,12 @@ async function main() {
169
185
  if (options.version) { console.log(JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8')).version); return }
170
186
  if (!nodeSupported()) throw new Error(`OpenRod needs Node.js 22.13 or newer, and this is ${process.versions.node}. Install a current release from https://nodejs.org, then run it again.`)
171
187
  quietSqliteWarning()
172
- const runtime = await startConsole(options)
188
+ // Before the console starts, so the browser opens on a usable local gateway.
189
+ try {
190
+ const { offerOpenShellInstall } = await import('./openshell-install.js')
191
+ if (await offerOpenShellInstall() === 'cancelled') { process.exitCode = 130; return }
192
+ } catch (error) { console.warn(`Could not check for OpenShell: ${error.message}`) }
193
+ const runtime = await startConsole({ ...options, open: options.open ?? autoOpen() })
173
194
  let stopping = false
174
195
  const shutdown = () => {
175
196
  if (stopping) return