openrod 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1 -2
  2. package/THIRD_PARTY_NOTICES.md +84 -1
  3. package/dist/assets/index-DBqg9X9x.js +553 -0
  4. package/dist/assets/index-HZ0q_FBv.css +1 -0
  5. package/dist/assets/index.esm-BqjC2fhp.js +376 -0
  6. package/dist/assets/index.esm-Cj6nXjOx.js +16 -0
  7. package/dist/assets/index.esm-Z1y-oluO.js +1666 -0
  8. package/dist/assets/terminal-view-D-SQcdM5.js +40 -0
  9. package/dist/index.html +3 -3
  10. package/package.json +8 -1
  11. package/server/announcements.js +22 -0
  12. package/server/api.js +54 -11
  13. package/server/capabilities.js +13 -0
  14. package/server/cli.js +18 -2
  15. package/server/cloud-deployment.js +29 -0
  16. package/server/cloud-proxy.js +19 -9
  17. package/server/cloud-runtime.js +72 -0
  18. package/server/cloud-ssh-proxy.cjs +22 -4
  19. package/server/cloud-transfer.js +1 -1
  20. package/server/compute.js +18 -7
  21. package/server/image-templates.js +17 -0
  22. package/server/ingress.js +3 -0
  23. package/server/local-cloud-native.js +7 -1
  24. package/server/local-cloud.js +3 -3
  25. package/server/machines.js +33 -18
  26. package/server/policy-template-store.js +1 -0
  27. package/server/remote-gateway.js +26 -6
  28. package/server/remote-http.js +1 -1
  29. package/server/resource-import-portable.js +155 -0
  30. package/server/resource-imports.js +412 -0
  31. package/server/sandbox-identity.js +1 -1
  32. package/server/security.js +30 -14
  33. package/server/setup-discovery.js +2 -1
  34. package/server/setup-packages.js +138 -59
  35. package/server/setup-preparation.js +4 -3
  36. package/server/start.js +29 -7
  37. package/server/worker-auth.js +2 -2
  38. package/shared/agent-access.js +26 -4
  39. package/shared/analytics-release.json +5 -0
  40. package/src/lib/activity-inventory.js +1 -1
  41. package/src/lib/analytics.js +334 -0
  42. package/src/lib/api.js +28 -11
  43. package/src/lib/cloud-sign-in-error.js +13 -0
  44. package/src/lib/cloud-transfer.js +1 -1
  45. package/src/lib/combined-activity.js +95 -0
  46. package/src/lib/connection-host.js +6 -0
  47. package/src/lib/egress-inventory.js +63 -0
  48. package/src/lib/local-cloud.js +16 -3
  49. package/src/lib/location-choice.js +7 -0
  50. package/src/lib/location-data-reader.js +25 -0
  51. package/src/lib/locations.js +18 -8
  52. package/src/lib/placement.js +1 -1
  53. package/src/lib/resource-imports.js +63 -0
  54. package/src/lib/resource-sources.js +105 -0
  55. package/src/lib/sandbox-creations.js +14 -9
  56. package/src/lib/sandboxes.js +5 -1
  57. package/src/lib/working-location.js +10 -0
  58. package/dist/assets/index-89TNohi_.css +0 -1
  59. package/dist/assets/index-DoKK2G9s.js +0 -545
  60. package/dist/assets/terminal-view-DzqQnmh2.js +0 -40
package/dist/index.html CHANGED
@@ -5,10 +5,10 @@
5
5
  <link rel="icon" type="image/png" href="/favicon.png" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <meta name="theme-color" content="#ffffff" />
8
- <meta name="description" content="OpenShell control plane - sandboxes, approvals and live activity" />
8
+ <meta name="description" content="OpenRod — sandboxes, templates, and activity." />
9
9
  <title>OpenRod</title>
10
- <script type="module" crossorigin src="/assets/index-DoKK2G9s.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-89TNohi_.css">
10
+ <script type="module" crossorigin src="/assets/index-DBqg9X9x.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-HZ0q_FBv.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openrod",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -71,9 +71,11 @@
71
71
  "@vitejs/plugin-react": "^4.3.4",
72
72
  "@xterm/addon-fit": "^0.11.0",
73
73
  "@xterm/xterm": "^6.0.0",
74
+ "canvas-confetti": "^1.9.4",
74
75
  "class-variance-authority": "^0.7.1",
75
76
  "clsx": "^2.1.1",
76
77
  "cn": "^0.2.5",
78
+ "firebase": "12.19.0",
77
79
  "lucide-react": "^0.577.0",
78
80
  "motion": "^13.2.0",
79
81
  "next-themes": "^0.4.6",
@@ -88,5 +90,10 @@
88
90
  },
89
91
  "engines": {
90
92
  "node": ">=22.13.0"
93
+ },
94
+ "overrides": {
95
+ "@firebase/firestore": {
96
+ "@grpc/grpc-js": "1.14.5"
97
+ }
91
98
  }
92
99
  }
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+ import { stateDirectory } from './paths.js'
5
+
6
+ // Lives outside the npm package and browser origin, so npx upgrades, ports,
7
+ // and browser changes do not replay the announcement for this OS user. While
8
+ // cloud is unavailable it stays unclaimed for the release that offers it.
9
+ export async function claimCloudAnnouncement({ directory = stateDirectory(), user = 'local', available = true } = {}) {
10
+ if (!available) return { show: false }
11
+ const owner = createHash('sha256').update(user).digest('hex')
12
+ const folder = path.join(directory, 'announcements', owner)
13
+ await fs.mkdir(folder, { recursive: true, mode: 0o700 })
14
+ try {
15
+ const file = await fs.open(path.join(folder, 'cloud-v1.seen'), 'wx', 0o600)
16
+ await file.close()
17
+ return { show: true }
18
+ } catch (error) {
19
+ if (error.code === 'EEXIST') return { show: false }
20
+ throw error
21
+ }
22
+ }
package/server/api.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import {createLocalCloudNative} from './local-cloud-native.js'
2
+ import { claimCloudAnnouncement } from './announcements.js'
2
3
  import {cloudSshRoute,cloudSshUpgrade} from './cloud-ssh.js'
3
4
  import {createLocalCloud} from './local-cloud.js'
4
5
  import { cloudOrigin, CLOUD_SOON } from './cloud-origin.js'
5
6
  import { setupTargetsFor, validateSetupTargets } from '../shared/setup-targets.js'
6
7
  import { localTransfer, importTransfer, exportTransfer } from './cloud-transfer.js'
8
+ import { resourceImportRoute } from './resource-imports.js'
9
+ import { consoleCapabilities } from './capabilities.js'
7
10
  import path from 'node:path'
8
11
  import { Readable } from 'node:stream'
9
12
  import { scopedStateDirectory, stateDirectory } from './paths.js'
@@ -39,7 +42,7 @@ import { filesRoute, planSeed, receiveUpload, serveDownload, startSeed } from '.
39
42
  // header alone is not proof of a local caller when Vite is bound to a LAN
40
43
  // address, so check the socket, the Host and the browser's own origin claims.
41
44
  export { isLocalApiRequest } from './security.js'
42
- import { createSecurity, releaseConfig, assertCloudOperation, requestPath } from './security.js'
45
+ import { createSecurity, releaseConfig, assertCloudOperation, requestPath, identityContext } from './security.js'
43
46
  import { createLaunchToken, createTokenGate, tokenUrl } from './launch-token.js'
44
47
 
45
48
  // A mutation must also carry a JSON body and a custom header, which a
@@ -54,14 +57,20 @@ function isMutation(req, security) {
54
57
  const NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
55
58
  const IMAGE = /^[\w./:@-]{1,256}$/
56
59
 
57
- async function body(req, limit = 65536) {
58
- let raw = ''
60
+ export async function readRequestBody(req, limit = 65536) {
61
+ const chunks = []
62
+ let bytes = 0
59
63
  for await (const chunk of req) {
60
- raw += chunk
61
- if (Buffer.byteLength(raw) > limit) throw Object.assign(new Error('Request too large'), { status: 413 })
64
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
65
+ bytes += buffer.length
66
+ if (bytes > limit) throw Object.assign(new Error('Request too large'), { status: 413 })
67
+ chunks.push(buffer)
62
68
  }
63
- return raw ? JSON.parse(raw) : {}
69
+ if (!bytes) return {}
70
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) }
71
+ catch { throw Object.assign(new Error('Invalid JSON request'), { status: 400 }) }
64
72
  }
73
+ const body = readRequestBody
65
74
 
66
75
  function send(res, status, payload) {
67
76
  res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
@@ -146,7 +155,8 @@ export async function createSandbox(input, { sessionOverride = false } = {}) {
146
155
  // A built template's agents get their sign-in and model destinations from
147
156
  // the reviewed table in shared/agent-access.js, never from the recipe.
148
157
  let agentRules = []
149
- if (saved?.managed) { try { agentRules = agentAccessRules(saved.recipe) } catch (error) { throw fail(error.message) } }
158
+ if (input.connectors?.length && !saved?.managed) throw fail('Connectors need a template built in OpenRod.')
159
+ if (saved?.managed) { try { agentRules = agentAccessRules(saved.recipe, { connectors: input.connectors ?? [] }) } catch (error) { throw fail(error.message) } }
150
160
  if (saved) {
151
161
  const start = sessionOverride && input.session !== undefined ? String(input.session) : saved.recipe.command.trim()
152
162
  let selectedSession
@@ -354,7 +364,7 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
354
364
  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
365
  }) : null
356
366
  const runtimes = new Map(), streams = new Set(), sockets = new Set(), pending = new Set(), responses = new Set()
357
- const localCloud = security.config.mode === 'local' ? createLocalCloud({ native: createLocalCloudNative() }) : null
367
+ const localCloud = security.config.mode === 'local' ? createLocalCloud({ native: createLocalCloudNative({ token }) }) : null
358
368
  const cloudOff = security.config.mode === 'local' && !cloudOrigin()
359
369
  const initialContext = contextSelection()
360
370
  const initialKey = contextKey(initialContext)
@@ -512,6 +522,12 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
512
522
  if (explicitLocation && security.config.mode === 'local') owner = await inventory.resolve(requestedContext)
513
523
  else if (requestedContext != null && requestedContext !== contextKey() && !(req.method === 'GET' && parts[0] === 'connections')) return send(res, 409, { error: 'Console context changed. Reload before continuing.' })
514
524
  return await runWithContext(owner, async () => {
525
+ if (req.method === 'GET' && parts.length === 1 && parts[0] === 'capabilities') return send(res, 200, consoleCapabilities(security.config.mode))
526
+ if (parts.length === 2 && parts[0] === 'announcements' && parts[1] === 'cloud') {
527
+ if (!isMutation(req, security)) return send(res, 403, { error: 'Request rejected' })
528
+ await body(req)
529
+ return send(res, 200, await claimCloudAnnouncement({ user: identityContext.getStore()?.uid ?? 'local', available: !cloudOff }))
530
+ }
515
531
  // Connection discovery is available before any gateway is selected.
516
532
  // Job reads remain available after that job changes the context.
517
533
  if (parts[0] === 'connections') {
@@ -529,11 +545,18 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
529
545
  const input = await body(req)
530
546
  if (parts.length === 2 && parts[1] === 'connect') return send(res, 200, remoteConnections.begin(input))
531
547
  if (parts.length === 3 && parts[1] === 'hosts' && parts[2] === 'scan') return send(res, 200, await sshHosts.scan(input))
532
- if (parts.length === 2 && parts[1] === 'hosts') return send(res, 201, await sshHosts.add(input.token))
548
+ if (parts.length === 2 && parts[1] === 'hosts') {
549
+ const saved = await sshHosts.add(input.token)
550
+ await remoteConnections.unforgetHost(saved.alias)
551
+ return send(res, 201, saved)
552
+ }
553
+ if (parts.length === 3 && parts[1] === 'hosts' && parts[2] === 'restore') return send(res, 200, await remoteConnections.restoreHost(input.host))
533
554
  if (parts.length === 3 && parts[1] === 'hosts' && parts[2] === 'remove') {
534
555
  const current = (await remoteConnections.overview()).active
535
556
  if (current?.host === input.alias && ['connected', 'connecting'].includes(current.status)) throw fail('Disconnect from this machine before removing it.', 409)
536
- return send(res, 200, await sshHosts.remove(input.alias))
557
+ const removed = await sshHosts.remove(input.alias)
558
+ await remoteConnections.unforgetHost(removed.alias)
559
+ return send(res, 200, removed)
537
560
  }
538
561
  if (parts.length === 2 && parts[1] === 'disconnect') return send(res, 200, await remoteConnections.disconnect())
539
562
  if (parts.length === 2 && parts[1] === 'forget') {
@@ -596,7 +619,23 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
596
619
  if (remote?.gateway === contextSelection().gateway) await inventory.resolve(contextKey())
597
620
  }
598
621
  const { store, delivery, hub } = runtimeFor(contextSelection())
622
+ const importOptions = {
623
+ sourceKind: security.config.mode === 'local' ? undefined : 'cloud',
624
+ activityStore: store,
625
+ onEvent: ({ action, job }) => store.ingest({
626
+ id: randomUUID(), sandbox: '', at: new Date().toISOString(), kind: 'event', category: 'EVENT',
627
+ source: 'openrod-import', action, severity: job.status === 'failed' || job.status === 'partial' ? 'WARN' : 'INFO',
628
+ outcome: job.status === 'completed' ? 'success' : job.status === 'failed' ? 'failure' : undefined,
629
+ actor: identityContext.getStore()?.email ?? 'Local operator', correlationId: job.id,
630
+ message: `${action}: ${job.counts.completed}/${job.counts.total} resources completed`,
631
+ original: { jobId: job.id, source: job.source, destination: job.destination, counts: job.counts },
632
+ }, contextKey()),
633
+ }
599
634
  if (req.method === 'GET') {
635
+ if (parts[0] === 'resource-imports') {
636
+ const result = await resourceImportRoute('GET', parts, undefined, importOptions)
637
+ return send(res, result === undefined ? 404 : 200, result ?? { error: 'Not found' })
638
+ }
600
639
  if (parts[0] === 'stream') {
601
640
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' })
602
641
  res.write(': connected\n\n')
@@ -651,8 +690,12 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
651
690
  }
652
691
  if (!isMutation(req, security)) return send(res, 403, { error: 'Request rejected' })
653
692
  if (parts[0] === 'cloud-import' && ['worker', 'local'].includes(security.config.mode)) return send(res, 200, await importTransfer(req, { createSandbox: input => createSandbox(input, { sessionOverride: true }) }))
654
- const input = await body(req, ['image-templates', 'activity'].includes(parts[0]) ? 512 * 1024 : parts[0] === 'setups' ? 128 * 1024 : 65536)
693
+ const input = await body(req, parts[0] === 'resource-imports' ? 8 * 1024 * 1024 : ['image-templates', 'activity'].includes(parts[0]) ? 512 * 1024 : parts[0] === 'setups' ? 128 * 1024 : 65536)
655
694
  assertCloudOperation(parts, input)
695
+ if (parts[0] === 'resource-imports') {
696
+ const result = await resourceImportRoute('POST', parts, input, importOptions)
697
+ return send(res, result === undefined ? 404 : 200, result ?? { error: 'Not found' })
698
+ }
656
699
  if (parts[0] === 'cloud-transfer' && security.config.mode === 'local') return send(res, 200, await localTransfer(input))
657
700
  if (parts[0] === 'activity' && parts.length === 2) {
658
701
  if (parts[1] === 'delete-preview') return send(res, 200, store.previewDeletion(input))
@@ -0,0 +1,13 @@
1
+ import { CLOUD_TRANSFER_LIMIT, CLOUD_TRANSFER_BODY_LIMIT } from './cloud-transfer.js'
2
+
3
+ // Explicit support, rather than environment names, drives available UI actions.
4
+ export function consoleCapabilities(mode = 'local') {
5
+ const cloud = mode !== 'local'
6
+ return {
7
+ version: 1,
8
+ mode,
9
+ resources: Object.fromEntries(['sandboxes', 'templates', 'setups', 'network', 'groups', 'secrets', 'activity'].map(type => [type, { read: true, manage: true }])),
10
+ features: { terminal: true, files: true, templateBuilds: true, localFolders: !cloud, publicIngress: !cloud, resourceImports: true },
11
+ workspaceTransfer: { supported: true, maxBytes: CLOUD_TRANSFER_LIMIT, maxFiles: 2000, maxRequestBytes: CLOUD_TRANSFER_BODY_LIMIT, preservesSource: true, credentials: false, runningProcesses: false },
12
+ }
13
+ }
package/server/cli.js CHANGED
@@ -121,7 +121,21 @@ function openBrowser(url, logger) {
121
121
  child.unref()
122
122
  }
123
123
 
124
+ // The API authenticates these upgrades. The CLI must not destroy a cloud
125
+ // relay socket after that asynchronous handler has accepted responsibility.
126
+ export function rejectUnsupportedUpgrade(req, socket) {
127
+ const pathname = (req.url ?? '').split('?', 1)[0]
128
+ if (!['/api/os/terminal', '/api/os/ssh', '/api/remote/os/terminal', '/api/remote/os/ssh'].includes(pathname)) socket.destroy()
129
+ }
130
+
131
+ // The openrod command is the loopback console guarded by its launch token.
132
+ // Cloud and worker deployments start server/start.js instead.
133
+ function assertLocalMode() {
134
+ if ((process.env.OPENROD_MODE ?? 'local') !== 'local') throw new Error('openrod runs the local console. Unset OPENROD_MODE, or start cloud and worker deployments with server/start.js.')
135
+ }
136
+
124
137
  export async function startConsole(options = parseOptions([]), logger = console) {
138
+ assertLocalMode()
125
139
  const { host, port } = parseOptions(['--host', options.host ?? '127.0.0.1', '--port', String(options.port ?? 4600)])
126
140
  let directory
127
141
  try {
@@ -129,6 +143,7 @@ export async function startConsole(options = parseOptions([]), logger = console)
129
143
  await fs.access(path.join(directory, 'index.html'))
130
144
  } catch { throw new Error('Built frontend is missing. Run npm run build from the source checkout before starting or packaging the console.') }
131
145
  const { createOpenShellApi, isLocalApiRequest } = await import('./api.js')
146
+ const { createSecurity } = await import('./security.js')
132
147
  const { createLaunchToken, tokenUrl } = await import('./launch-token.js')
133
148
  const token = createLaunchToken()
134
149
  const serveStatic = staticMiddleware(directory)
@@ -153,9 +168,9 @@ export async function startConsole(options = parseOptions([]), logger = console)
153
168
  server.once('error', error)
154
169
  server.listen(port, host === 'localhost' ? '127.0.0.1' : host, () => { server.off('error', error); resolve() })
155
170
  })
156
- try { api = createOpenShellApi({ httpServer: server, logger, token }) }
171
+ try { api = createOpenShellApi({ httpServer: server, logger, token, security: createSecurity({ mode: 'local' }) }) }
157
172
  catch (error) { server.close(); throw error }
158
- server.on('upgrade', (req, socket) => { if ((req.url ?? '').split('?', 1)[0] !== '/api/os/terminal') socket.destroy() })
173
+ server.on('upgrade', rejectUnsupportedUpgrade)
159
174
  let closing
160
175
  const close = () => {
161
176
  if (closing) return closing
@@ -184,6 +199,7 @@ async function main() {
184
199
  if (options.help) { process.stdout.write(HELP); return }
185
200
  if (options.version) { console.log(JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8')).version); return }
186
201
  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.`)
202
+ assertLocalMode()
187
203
  quietSqliteWarning()
188
204
  // Before the console starts, so the browser opens on a usable local gateway.
189
205
  try {
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Infrastructure identities can outlive product naming. Configure an existing
4
+ // fleet explicitly rather than renaming records or allocating replacement VMs.
5
+ export function workerPrefix(value = 'openrod-user') {
6
+ if (!/^[a-z][a-z0-9-]{0,30}$/.test(value)) throw Error('Invalid OPENROD_WORKER_PREFIX')
7
+ return value
8
+ }
9
+ export function workerOwnerHash(uid) {
10
+ if (typeof uid !== 'string' || !uid || uid.length > 128) throw Object.assign(Error('Invalid account'), { status: 400 })
11
+ return createHash('sha256').update(uid).digest('hex').slice(0, 24)
12
+ }
13
+ // A fleet provisioned before OpenRod signs requests with its own header names.
14
+ // Its operator supplies their prefix; the source names only OpenRod's headers.
15
+ export function legacyHeaderPrefix(value) {
16
+ if (!/^x-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value ?? '') || value === 'x-openrod') throw Error('OPENROD_WORKER_PROTOCOL=legacy requires OPENROD_LEGACY_HEADER_PREFIX, the earlier header prefix such as x-acme')
17
+ return value
18
+ }
19
+ export function workerProtocol(value = 'current', env = process.env) {
20
+ if (!['current', 'legacy'].includes(value)) throw Error('OPENROD_WORKER_PROTOCOL must be current or legacy')
21
+ if (value === 'legacy') legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)
22
+ return value
23
+ }
24
+ export const workerAuthHeader = (protocol, env = process.env) => protocol === 'legacy' ? `${legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)}-worker-auth` : 'x-openrod-worker-auth'
25
+ export const workerNameHeader = (protocol, env = process.env) => protocol === 'legacy' ? `${legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)}-worker` : 'x-openrod-worker'
26
+ export function ownerLabel(value = 'openrod_owner') {
27
+ if (!/^[a-z][a-z0-9_]{0,62}$/.test(value)) throw Error('Invalid OPENROD_WORKER_OWNER_LABEL')
28
+ return value
29
+ }
@@ -4,20 +4,22 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
4
4
  import { identityContext, requestPath } from './security.js'
5
5
  import {readJson,safeWorkspaceTarget} from './remote-http.js'
6
6
  import { signWorkerRequest } from './worker-auth.js'
7
+ import { workerPrefix, workerAuthHeader, workerNameHeader } from './cloud-deployment.js'
7
8
  const fail=(message,status=503)=>Object.assign(Error(message),{status})
8
9
  const json=(res,status,value)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(value))}
9
10
  const hash=value=>createHash('sha256').update(value).digest('hex')
10
- export function createHandoffs(db,now=()=>Date.now()) {
11
+ export function createHandoffs(db,now=()=>Date.now(),prefix='openrod-user') {
12
+ const ticketPattern=new RegExp(`^${workerPrefix(prefix)}-[a-f0-9]{24}\\.[a-f0-9]{64}$`)
11
13
  return {
12
14
  async issue(identity,name){const token=`${name}.${randomBytes(32).toString('hex')}`;await db.collection('handoffs').doc(name).set({uid:identity.uid,hash:hash(token),expires:Math.min(identity.expires,now()+300000),used:false});return token},
13
15
  async consume(token){
14
- if(typeof token!=='string'||!/^openrod-user-[a-f0-9]{24}\.[a-f0-9]{64}$/.test(token))throw fail('Invalid cloud transfer ticket',401)
16
+ if(typeof token!=='string'||!ticketPattern.test(token))throw fail('Invalid cloud transfer ticket',401)
15
17
  return db.runTransaction(async tx=>{const ref=db.collection('handoffs').doc(token.split('.')[0]),s=await tx.get(ref),r=s.exists?s.data():null;if(!r||r.used||r.expires<=now()||r.hash!==hash(token))throw fail('Cloud transfer ticket expired or already used',401);tx.update(ref,{used:true});return r.uid})
16
18
  },
17
19
  }
18
20
  }
19
21
  export function proxyWorker(req,res,record,identity,config,{target=req.url,maxBytes=36*1024*1024}={}) {
20
- const headers={host:config.host,origin:config.origin,'x-openshell-console':'1','x-openrod-worker-auth':signWorkerRequest(record.key,identity,{method:req.method,url:target})}
22
+ const headers={host:config.host,origin:config.origin,'x-openshell-console':'1',[workerAuthHeader(record.workerProtocol??config.workerProtocol)]:signWorkerRequest(record.key,identity,{method:req.method,url:target})}
21
23
  for(const key of ['content-type','content-length','accept','range','x-openshell-context','x-openshell-location'])if(req.headers[key])headers[key]=req.headers[key]
22
24
  return new Promise(resolve=>{
23
25
  const upstream=http.request({hostname:record.address,port:record.port??4600,path:target,method:req.method,headers},response=>{
@@ -32,7 +34,7 @@ export function proxyWorker(req,res,record,identity,config,{target=req.url,maxBy
32
34
  })
33
35
  }
34
36
  export function proxyWorkerSocket(req,socket,head,record,identity,config,{target=req.url}={}) {
35
- const upstream=http.request({hostname:record.address,port:record.port??4600,path:target,method:'GET',headers:{host:config.host,origin:config.origin,upgrade:'websocket',connection:'Upgrade','sec-websocket-key':req.headers['sec-websocket-key'],'sec-websocket-version':req.headers['sec-websocket-version'],'x-openrod-worker-auth':signWorkerRequest(record.key,identity,{method:'GET',url:target})}})
37
+ const upstream=http.request({hostname:record.address,port:record.port??4600,path:target,method:'GET',headers:{host:config.host,origin:config.origin,upgrade:'websocket',connection:'Upgrade','sec-websocket-key':req.headers['sec-websocket-key'],'sec-websocket-version':req.headers['sec-websocket-version'],[workerAuthHeader(record.workerProtocol??config.workerProtocol)]:signWorkerRequest(record.key,identity,{method:'GET',url:target})}})
36
38
  upstream.once('upgrade',(response,remote,remoteHead)=>{
37
39
  socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(response.headers).map(([k,v])=>`${k}: ${v}`).join('\r\n')}\r\n\r\n`)
38
40
  if(remoteHead.length)socket.write(remoteHead);if(head.length)remote.write(head)
@@ -47,7 +49,7 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
47
49
  const pathname=requestPath(req)
48
50
  if(pathname==='/internal/worker-artifact') {
49
51
  if(req.method!=='GET')throw fail('Not found',404)
50
- const record=await machines.store.byName(req.headers['x-openrod-worker'])
52
+ const record=await machines.store.byName(req.headers['x-openrod-worker']??(security.config.workerProtocol==='legacy'?req.headers[workerNameHeader('legacy')]:undefined))
51
53
  const supplied=req.headers.authorization?.replace(/^Bearer /,'')??''
52
54
  if(!record||supplied.length!==record.key.length||!timingSafeEqual(Buffer.from(supplied),Buffer.from(record.key))||!artifact)throw fail('Not found',404)
53
55
  res.writeHead(200,{'Content-Type':'application/gzip','Cache-Control':'no-store'});const file=fs.createReadStream(artifact);file.on('error',()=>res.destroy());res.once('close',()=>file.destroy());file.pipe(res);return
@@ -69,11 +71,15 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
69
71
  return await proxyWorker(req,res,record,identity,security.config,{target:'/api/os/inventory'})
70
72
  }
71
73
  if(pathname==='/api/cloud/local-connect/machine'&&req.method==='GET')return json(res,200,await machines.status(identity))
74
+ if(pathname==='/api/cloud/local-connect/machine'&&req.method==='POST'){
75
+ if(req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
76
+ await readJson(req);return json(res,200,await machines.prepare(identity))
77
+ }
72
78
  if(pathname.startsWith('/api/cloud/local-connect/os/')&&['GET','POST'].includes(req.method)){
73
79
  const target=safeWorkspaceTarget(req.url,'/api/cloud/local-connect/os')
74
80
  if(req.method!=='GET'&&req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
75
81
  watchConnection(token,res,identity)
76
- return await proxyWorker(req,res,await machines.target(identity),identity,security.config,{target})
82
+ return await proxyWorker(req,res,await machines.target(identity,{provision:req.method!=='GET'}),identity,security.config,{target})
77
83
  }
78
84
  throw fail('Not found',404)
79
85
  }
@@ -93,11 +99,15 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
93
99
  const identity=identityContext.getStore(),pathname=requestPath(req)
94
100
  if(connections&&pathname==='/api/cloud/local-connect/authorize'&&req.method==='POST'){const body=await readJson(req);return json(res,200,{code:await connections.authorize(identity,body,security.sessionHash(req))})}
95
101
  if(pathname==='/api/cloud/machine'&&req.method==='GET')return json(res,200,await machines.status(identity))
102
+ if(pathname==='/api/cloud/machine'&&req.method==='POST'){
103
+ if(req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
104
+ await readJson(req);return json(res,200,await machines.prepare(identity))
105
+ }
96
106
  if(pathname==='/api/cloud/handoff'&&req.method==='POST'){
97
107
  if(req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
98
108
  const record=await machines.target(identity);return json(res,200,{ticket:await handoffs.issue(identity,record.name)})
99
109
  }
100
- if(pathname.startsWith('/api/os/'))return await proxyWorker(req,res,await machines.target(identity),identity,security.config)
110
+ if(pathname.startsWith('/api/os/'))return await proxyWorker(req,res,await machines.target(identity,{provision:req.method!=='GET'}),identity,security.config)
101
111
  next()
102
112
  }catch(error){json(res,error.status??503,{error:error.status?error.message:'Cloud service unavailable. Try again shortly.'})}
103
113
  }
@@ -113,10 +123,10 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
113
123
  const target=safeWorkspaceTarget(req.url,'/api/cloud/local-connect/os')
114
124
  if(!['/api/os/terminal','/api/os/ssh'].includes(target.split('?')[0]))throw fail('Not found',404)
115
125
  const token=req.headers.authorization?.replace(/^Bearer /,''),identity=await connections.authenticate(token)
116
- watchConnection(token,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity),identity,security.config,{target});return
126
+ watchConnection(token,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity,{provision:false}),identity,security.config,{target});return
117
127
  }
118
128
  if(pathname!=='/api/os/terminal')return
119
- const identity=await security.authenticate(req);security.watch(req,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity),identity,security.config)
129
+ const identity=await security.authenticate(req);security.watch(req,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity,{provision:false}),identity,security.config)
120
130
  }catch{socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')}
121
131
  }}
122
132
  }
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import { createRequire } from 'node:module'
5
+ import { createCompute, workerDeploymentConfig } from './compute.js'
6
+ import { createMachineStore, createMachineManager, workerReady } from './machines.js'
7
+ import { createCloudConnections } from './cloud-connections.js'
8
+ import { createHandoffs } from './cloud-proxy.js'
9
+ import { workerPrefix, ownerLabel } from './cloud-deployment.js'
10
+
11
+ // Keep the Admin SDK outside the local npm package and worker installation.
12
+ // Only the explicitly selected cloud control plane ever loads it.
13
+ async function loadCloudAdmin() {
14
+ const require = createRequire(new URL('../../deploy/gcp/runtime/package.json', import.meta.url))
15
+ try {
16
+ return { ...require('firebase-admin/app'), ...require('firebase-admin/auth'), ...require('firebase-admin/firestore') }
17
+ } catch (error) {
18
+ if (error.code === 'MODULE_NOT_FOUND') throw Error('Cloud runtime dependencies are missing. Run npm ci --prefix deploy/gcp/runtime from the repository root.')
19
+ throw error
20
+ }
21
+ }
22
+
23
+ export function cloudRuntimeConfig(config, env = process.env) {
24
+ if (config.mode !== 'cloud') throw Error('Cloud runtime requires cloud mode')
25
+ const maxMachines = Number(env.OPENROD_MAX_MACHINES ?? 10)
26
+ if (!Number.isInteger(maxMachines) || maxMachines < 1 || maxMachines > 100) throw Error('OPENROD_MAX_MACHINES must be between 1 and 100')
27
+ const databaseId = env.OPENROD_FIRESTORE_DATABASE ?? 'openrod-cloud'
28
+ if (!/^[a-z][a-z0-9-]{2,61}[a-z0-9]$/.test(databaseId)) throw Error('Invalid OPENROD_FIRESTORE_DATABASE')
29
+ const artifact = env.OPENROD_WORKER_ARTIFACT
30
+ if (!artifact || !path.isAbsolute(artifact)) throw Error('OPENROD_WORKER_ARTIFACT must identify the reviewed worker artifact by absolute path')
31
+ const prefix = workerPrefix(env.OPENROD_WORKER_PREFIX), label = ownerLabel(env.OPENROD_WORKER_OWNER_LABEL)
32
+ if (env.OPENROD_PROVISIONING_ENABLED !== undefined && !['true', 'false'].includes(env.OPENROD_PROVISIONING_ENABLED)) throw Error('OPENROD_PROVISIONING_ENABLED must be true or false')
33
+ return { maxMachines, databaseId, artifact, prefix, ownerLabel: label, allowProvisioning: env.OPENROD_PROVISIONING_ENABLED !== 'false', deployment: workerDeploymentConfig(config, env) }
34
+ }
35
+
36
+ export async function verifyWorkerArtifact(filename, expectedHash) {
37
+ if (!fs.statSync(filename).isFile()) throw Error('Worker artifact must be a regular file')
38
+ const digest = createHash('sha256')
39
+ for await (const chunk of fs.createReadStream(filename)) digest.update(chunk)
40
+ if (digest.digest('hex') !== expectedHash) throw Error('Worker artifact checksum does not match OPENROD_WORKER_ARTIFACT_SHA256')
41
+ }
42
+
43
+ export async function createCloudRuntime(config, { env = process.env, revocations, loadAdmin = loadCloudAdmin, computeFactory = createCompute } = {}) {
44
+ const settings = cloudRuntimeConfig(config, env)
45
+ await verifyWorkerArtifact(settings.artifact, settings.deployment.artifactHash)
46
+ const admin = await loadAdmin()
47
+ const credential = admin.applicationDefault()
48
+ const app = admin.initializeApp({ projectId: config.firebase.projectId, credential }, `openrod-${randomUUID()}`)
49
+ let db, closed = false
50
+ const close = async () => {
51
+ if (closed) return
52
+ closed = true
53
+ try { await db?.terminate() } finally { await admin.deleteApp(app) }
54
+ }
55
+ try {
56
+ const auth = admin.getAuth(app)
57
+ db = admin.getFirestore(app, settings.databaseId)
58
+ // Fail startup before advertising readiness if ADC or registry IAM is broken.
59
+ // This is read-only and never reserves a fleet slot or creates a VM.
60
+ await credential.getAccessToken()
61
+ await db.collection('control').doc('fleet').get()
62
+ const compute = await computeFactory(config, credential, { env })
63
+ const store = createMachineStore(db, { maxMachines: settings.maxMachines, prefix: settings.prefix })
64
+ const machines = createMachineManager(store, compute, { ownerLabel: settings.ownerLabel, allowProvisioning: settings.allowProvisioning, ready: (record, address) => workerReady(record, address, { host: config.host, protocol: record.workerProtocol ?? config.workerProtocol }) })
65
+ const handoffs = createHandoffs(db, Date.now, settings.prefix)
66
+ const connections = createCloudConnections(db, auth, { org: config.org, isSessionRevoked: hash => revocations?.hasDigest(hash) ?? false })
67
+ return { auth, machines, handoffs, connections, artifact: settings.artifact, close, async ready() { await db.collection('control').doc('fleet').get(); return true } }
68
+ } catch (error) {
69
+ await close().catch(() => {})
70
+ throw error
71
+ }
72
+ }
@@ -2,6 +2,7 @@
2
2
  'use strict'
3
3
  // OpenSSH invokes this helper locally. Grants remain inside OpenRod's local server;
4
4
  // this process obtains only a single-use, account-scoped SSH stream ticket.
5
+ const fs = require('node:fs/promises')
5
6
  const { WebSocket, createWebSocketStream } = require('ws')
6
7
 
7
8
  function validate(options) {
@@ -18,24 +19,41 @@ function parseProxyArgs(args) {
18
19
  const options = {}
19
20
  for (let i = 0; i < args.length; i += 2) {
20
21
  const flag = args[i]
21
- if (!['--origin', '--sandbox', '--owner', '--context'].includes(flag) || !args[i + 1] || Object.hasOwn(options, flag.slice(2))) throw Error('Invalid OpenRod SSH proxy arguments')
22
+ if (!['--origin', '--sandbox', '--owner', '--context', '--auth-file'].includes(flag) || !args[i + 1] || Object.hasOwn(options, flag.slice(2))) throw Error('Invalid OpenRod SSH proxy arguments')
22
23
  options[flag.slice(2)] = args[i + 1]
23
24
  }
24
25
  return validate(options)
25
26
  }
26
27
  async function runProxy(options, { input = process.stdin, output = process.stdout, fetchRequest = fetch, signal } = {}) {
27
28
  validate(options)
29
+ let cookie
30
+ if (options['auth-file']) {
31
+ try {
32
+ const file = await fs.open(options['auth-file'], require('node:fs').constants.O_RDONLY | require('node:fs').constants.O_NOFOLLOW)
33
+ try {
34
+ const stat = await file.stat()
35
+ if (!stat.isFile() || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid()) || stat.size > 4096) throw Error('Invalid credential file')
36
+ const auth = JSON.parse(await file.readFile('utf8'))
37
+ if (auth.origin !== options.origin || typeof auth.cookie !== 'string' || !/^openrod_token_[0-9]+=[A-Za-z0-9_-]{32,256}$/.test(auth.cookie)) throw Error('Invalid credential')
38
+ cookie = auth.cookie
39
+ } finally { await file.close() }
40
+ } catch { throw Error('Local OpenRod SSH credentials are unavailable. Open the sandbox from OpenRod again.') }
41
+ }
28
42
  const response = await fetchRequest(`${options.origin}/api/remote/os/sandboxes/${options.sandbox}/ssh-ticket`, {
29
- method: 'POST', headers: { 'x-openrod-local-owner': options.owner, Origin: options.origin, 'content-type': 'application/json', 'x-openshell-console': '1' },
43
+ method: 'POST', headers: { ...(cookie ? { Cookie: cookie } : {}), 'x-openrod-local-owner': options.owner, Origin: options.origin, 'content-type': 'application/json', 'x-openshell-console': '1' },
30
44
  body: JSON.stringify({ owner: options.owner, ...(options.context ? { context: options.context } : {}) }), redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000),
31
45
  })
32
- if (!response.ok) throw Error(`Local OpenRod SSH authorization failed (${response.status}). Reconnect to OpenRod Cloud.`)
46
+ if (!response.ok) {
47
+ const error = await response.json().catch(() => ({}))
48
+ if (error.code === 'CONSOLE_TOKEN_REQUIRED') throw Error('Local OpenRod console authorization expired. Open the sandbox from OpenRod again.')
49
+ throw Error(`Local OpenRod SSH authorization failed (${response.status}). Reconnect to OpenRod Cloud.`)
50
+ }
33
51
  const body = await response.json()
34
52
  if (typeof body.ticket !== 'string' || !/^[A-Za-z0-9-]{1,100}$/.test(body.ticket)) throw Error('Invalid SSH ticket from local OpenRod')
35
53
  const url = new URL('/api/remote/os/ssh', options.origin); url.protocol = 'ws:'; url.searchParams.set('ticket', body.ticket); url.searchParams.set('owner', options.owner)
36
54
  if (options.context) url.searchParams.set('context', options.context)
37
55
  return new Promise((resolve, reject) => {
38
- const ws = new WebSocket(url, { origin: options.origin, handshakeTimeout: 30000, maxPayload: 1024 * 1024 })
56
+ const ws = new WebSocket(url, { origin: options.origin, headers: cookie ? { Cookie: cookie } : {}, handshakeTimeout: 30000, maxPayload: 1024 * 1024 })
39
57
  let stream, settled = false
40
58
  const abort = () => { ws.terminate(); finish(Error('OpenRod SSH connection canceled')) }
41
59
  const finish = error => {
@@ -135,7 +135,7 @@ export async function exportTransfer(input, options = {}) {
135
135
  export async function localTransfer(input, options = {}) {
136
136
  const origin = options.origin ?? cloudOrigin()
137
137
  if (!origin) throw cloudUnavailable()
138
- if (typeof input?.ticket !== 'string' || !/^openrod-user-[a-f0-9]{24}\.[a-f0-9]{64}$/.test(input.ticket)) throw fail('Invalid cloud transfer request.')
138
+ if (typeof input?.ticket !== 'string' || !/^[a-z][a-z0-9-]{0,62}\.[a-f0-9]{64}$/.test(input.ticket)) throw fail('Invalid cloud transfer request.')
139
139
  const { bundle, warning } = await exportTransfer(input, options)
140
140
  if (input.destinationGroups !== undefined) {
141
141
  try { bundle.destinationGroups = groupIds(input.destinationGroups) } catch (error) { throw fail(error.message) }
package/server/compute.js CHANGED
@@ -1,21 +1,32 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- export async function createCompute(config, credential) {
4
- const project=config.firebase.projectId,zone=process.env.OPENROD_WORKER_ZONE??'us-east1-b',subnet=process.env.OPENROD_WORKER_SUBNET
5
- const artifactOrigin=process.env.OPENROD_WORKER_ARTIFACT_ORIGIN,artifactHash=process.env.OPENROD_WORKER_ARTIFACT_SHA256
3
+ import { legacyHeaderPrefix, ownerLabel, workerOwnerHash } from './cloud-deployment.js'
4
+ export function workerDeploymentConfig(config, env=process.env) {
5
+ const project=config.firebase.projectId,zone=env.OPENROD_WORKER_ZONE??'us-east1-b',subnet=env.OPENROD_WORKER_SUBNET
6
+ const artifactOrigin=env.OPENROD_WORKER_ARTIFACT_ORIGIN,artifactHash=env.OPENROD_WORKER_ARTIFACT_SHA256
6
7
  if(!/^[a-z0-9-]+$/.test(zone)||!subnet?.startsWith(`projects/${project}/regions/`)||!/^http:\/\/10\.80\.0\.\d{1,3}:8080$/.test(artifactOrigin??'')||!/^[a-f0-9]{64}$/.test(artifactHash??''))throw Error('Worker deployment configuration is incomplete')
8
+ const url=new URL(artifactOrigin)
9
+ if(Number(url.hostname.split('.').at(-1))>254||Number(url.hostname.split('.').at(-1))<1)throw Error('Invalid worker artifact address')
10
+ const region=zone.replace(/-[a-z]$/,'')
11
+ if(!new RegExp(`^projects/${project}/regions/${region}/subnetworks/[a-z][a-z0-9-]{0,62}$`).test(subnet))throw Error('Worker subnet must belong to the configured project and zone region')
12
+ const networkTag=env.OPENROD_WORKER_NETWORK_TAG??'openrod-user-worker',label=ownerLabel(env.OPENROD_WORKER_OWNER_LABEL)
13
+ if(!/^[a-z][a-z0-9-]{0,62}$/.test(networkTag))throw Error('Invalid OPENROD_WORKER_NETWORK_TAG')
14
+ return {project,zone,subnet,artifactOrigin,artifactHash,networkTag,ownerLabel:label}
15
+ }
16
+ export async function createCompute(config, credential, {env=process.env, requestFetch=fetch}={}) {
17
+ const {project,zone,subnet,artifactOrigin,artifactHash,networkTag,ownerLabel}=workerDeploymentConfig(config,env)
7
18
  const source=fs.readFileSync(path.resolve(import.meta.dirname,'../../deploy/gcp/worker-startup.sh'),'utf8')
8
19
  const base=`https://compute.googleapis.com/compute/v1/projects/${project}/zones/${zone}`
9
20
  async function request(method,url,body) {
10
21
  const token=await credential.getAccessToken()
11
- const response=await fetch(url,{method,headers:{Authorization:`Bearer ${token.access_token}`,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)})
12
- if(response.status===404)return null
22
+ const response=await requestFetch(url,{method,headers:{Authorization:`Bearer ${token.access_token}`,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)})
23
+ if(response.status===404&&method==='GET')return null
13
24
  const value=await response.json()
14
25
  if(!response.ok || value.error){const e=Error(value.error?.message??value.error?.errors?.[0]?.message??'Compute request failed');e.code=response.status;throw e}
15
26
  return value
16
27
  }
17
28
  return {get:name=>request('GET',`${base}/instances/${name}`),async create(record){
18
- const settings=Buffer.from(JSON.stringify({key:record.key,uid:record.uid,origin:config.origin,artifactOrigin,artifactHash,name:record.name})).toString('base64')
29
+ const settings=Buffer.from(JSON.stringify({key:record.key,uid:record.uid,origin:config.origin,artifactOrigin,artifactHash,name:record.name,workerProtocol:config.workerProtocol??'current',...(config.workerProtocol==='legacy'?{legacyHeaderPrefix:legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)}:{})})).toString('base64')
19
30
  const script=Buffer.from(source.replace('__OPENROD_WORKER_SETTINGS__',settings)).toString('base64')
20
31
  const startup=`#!/usr/bin/env bash
21
32
  set -euo pipefail
@@ -41,7 +52,7 @@ UNIT
41
52
  systemctl daemon-reload
42
53
  systemctl enable --now openrod-worker-bootstrap
43
54
  `
44
- const body={name:record.name,machineType:`zones/${zone}/machineTypes/e2-standard-2`,labels:{application:'openrod',managed_by:'openrod-console',openrod_owner:record.name.slice(9)},tags:{items:['openrod-user-worker']},serviceAccounts:[],canIpForward:false,
55
+ const body={name:record.name,machineType:`zones/${zone}/machineTypes/e2-standard-2`,labels:{application:'openrod',managed_by:'openrod-console',[ownerLabel]:workerOwnerHash(record.uid)},tags:{items:[networkTag]},serviceAccounts:[],canIpForward:false,
45
56
  disks:[{boot:true,autoDelete:true,initializeParams:{sourceImage:'projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64',diskSizeGb:'30',diskType:`zones/${zone}/diskTypes/pd-balanced`}},{boot:false,autoDelete:false,deviceName:'openrod-state',initializeParams:{diskName:`${record.name}-state`,diskSizeGb:'100',diskType:`zones/${zone}/diskTypes/pd-balanced`}}],
46
57
  networkInterfaces:[{subnetwork:subnet,stackType:'IPV4_ONLY'}],metadata:{items:[{key:'enable-oslogin',value:'TRUE'},{key:'block-project-ssh-keys',value:'TRUE'},{key:'disable-legacy-endpoints',value:'TRUE'},{key:'startup-script',value:startup}]},shieldedInstanceConfig:{enableSecureBoot:true,enableVtpm:true,enableIntegrityMonitoring:true}}
47
58
  try{return await request('POST',`${base}/instances?requestId=${record.requestId}`,body)}catch(error){if(error.code!==409)throw error;return request('GET',`${base}/instances/${record.name}`)}
@@ -91,6 +91,23 @@ export async function localEngine({ execute = run } = {}) {
91
91
  async function deploymentEngine(target) {
92
92
  return target.remote ? remoteImageEngine(target, { execute: run }) : localEngine()
93
93
  }
94
+ // Cache fast path only when the gateway's actual Docker engine is established.
95
+ // Inspect the pinned image; never run a container or pull an image for this probe.
96
+ export async function preparationImageRuntime(target, reference, { execute = run, guard = gatewayDocker } = {}) {
97
+ if (!target) return null
98
+ let engine
99
+ if (target.remote) engine = await remoteImageEngine(target, { execute })
100
+ else {
101
+ const status = await guard?.status({ fresh: true })
102
+ if (status?.state !== 'ok' || status.gateway !== target.name || !status.driver?.engineId || status.driver.engineId !== status.build?.engineId) return null
103
+ engine = await localEngine({ execute })
104
+ if (engine.engineId !== status.driver.engineId) return null
105
+ }
106
+ const info = await inspect(reference, engine, { execute })
107
+ const node = info.Config?.Env?.find(value => value.startsWith('NODE_VERSION='))?.slice('NODE_VERSION='.length)
108
+ if (!/^22\.\d+\.\d+$/.test(node ?? '') || !['amd64', 'arm64'].includes(info.Architecture)) return null
109
+ return { node, arch: info.Architecture === 'amd64' ? 'x64' : 'arm64', platform: 'linux', libc: 'glibc' }
110
+ }
94
111
  async function inspect(reference, engine, { execute = run, job, architecture = engine.architecture } = {}) {
95
112
  const image = JSON.parse(await execute(['image', 'inspect', reference], { engine, job }))[0]
96
113
  if (image.Os !== 'linux') throw fail('Choose a Linux container image.')