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
@@ -85,7 +85,7 @@ export async function checkPreparationRegistry(client, name, signal, { workspace
85
85
  }
86
86
 
87
87
  const INSTALL = `const fs=require('fs'),cp=require('child_process'),path=require('path');
88
- const p=JSON.parse(fs.readFileSync(0,'utf8')); const root='/sandbox/package';fs.mkdirSync(root,{recursive:true});
88
+ const p=JSON.parse(fs.readFileSync(0,'utf8')); const root=p.root||'/sandbox/package';fs.mkdirSync(root,{recursive:true});
89
89
  fs.writeFileSync(root+'/package.json',JSON.stringify({private:true,dependencies:{[p.name]:p.version}}));
90
90
  const options={cwd:root,env:{PATH:'/usr/local/bin:/usr/bin:/bin',HOME:'/sandbox',NODE_EXTRA_CA_CERTS:process.env.NODE_EXTRA_CA_CERTS||'',npm_config_cache:'/sandbox/cache',npm_config_registry:'https://registry.npmjs.org/',npm_config_fetch_retries:'1',npm_config_fetch_retry_mintimeout:'1000',npm_config_fetch_timeout:'30000',npm_config_update_notifier:'false'},encoding:'utf8',timeout:120000,killSignal:'SIGKILL',maxBuffer:2000000};
91
91
  for(const args of [['install','--package-lock-only','--ignore-scripts','--no-audit','--no-fund'],['ci','--ignore-scripts','--no-audit','--no-fund']]){
@@ -100,13 +100,13 @@ console.log(JSON.stringify({bin:bin.slice(root.length+1),bytes:size,node:process
100
100
 
101
101
  // npm already retries each download once. Re-running the entire installation
102
102
  // multiplies an outage across every selected MCP and discards useful diagnostics.
103
- export async function installPackage(client, name, pinned, signal, { workspace = workspaceName() } = {}) {
103
+ export async function installPackage(client, name, pinned, signal, { workspace = workspaceName(), root = '/sandbox/package' } = {}) {
104
104
  const deadline = AbortSignal.timeout(250000)
105
105
  const bounded = signal ? AbortSignal.any([signal, deadline]) : deadline
106
106
  let result
107
107
  try {
108
108
  result = await client.sandbox.exec(name, ['/usr/local/bin/node', '-e', INSTALL], {
109
- workspace, noLoginShell: true, stdin: Buffer.from(JSON.stringify(pinned)), timeoutSecs: 250, signal: bounded,
109
+ workspace, noLoginShell: true, stdin: Buffer.from(JSON.stringify({...pinned, root})), timeoutSecs: 250, signal: bounded,
110
110
  })
111
111
  } catch (error) {
112
112
  signal?.throwIfAborted()
@@ -126,9 +126,8 @@ export async function installPackage(client, name, pinned, signal, { workspace =
126
126
  return metadata
127
127
  }
128
128
 
129
- // The gateway may move to a different architecture, including behind the same
130
- // endpoint. Probe this builder before reusing anything; npm and MCP checks are
131
- // skipped only for an identical pinned image and actual runtime.
129
+ // The gateway may move to a different architecture, even at the same endpoint.
130
+ // Reuse requires the pinned image and a currently established target runtime.
132
131
  const cacheIndex = (pinned, runtime, dir) => path.join(dir, 'index', createHash('sha256').update(JSON.stringify({ v: 2, name: pinned.name, version: pinned.version, integrity: pinned.integrity, args: pinned.args, image: BUILDER_IMAGE, runtime })).digest('hex') + '.json')
133
132
  async function builderRuntime(client, name, signal, workspace) {
134
133
  const script = "console.log(JSON.stringify({node:process.versions.node,arch:process.arch,platform:process.platform,libc:process.report.getReport().header.glibcVersionRuntime?'glibc':null}))"
@@ -141,7 +140,7 @@ async function builderRuntime(client, name, signal, workspace) {
141
140
  async function cachedArtifact(index, pinned, runtime, dir) {
142
141
  try {
143
142
  const artifact = JSON.parse(await fs.readFile(index, 'utf8'))
144
- if (!/^[a-f0-9]{64}$/.test(artifact?.digest || '') || typeof artifact.bin !== 'string' || artifact.name !== pinned.name || artifact.version !== pinned.version || artifact.integrity !== pinned.integrity || artifact.verification?.status !== 'connected' || Object.entries(runtime).some(([key, value]) => artifact[key] !== value)) throw fail('Invalid prepared package index.')
143
+ if (!/^[a-f0-9]{64}$/.test(artifact?.digest || '') || typeof artifact.bin !== 'string' || artifact.name !== pinned.name || artifact.version !== pinned.version || artifact.integrity !== pinned.integrity || artifact.verification?.status !== 'connected' || JSON.stringify(artifact.args) !== JSON.stringify(pinned.args) || artifact.bin.startsWith('/') || artifact.bin.split('/').includes('..') || Object.entries(runtime).some(([key, value]) => artifact[key] !== value)) throw fail('Invalid prepared package index.')
145
144
  await artifactFile(artifact, dir)
146
145
  return { ...artifact, requested: pinned.requested }
147
146
  } catch { await fs.rm(index, { force: true }).catch(() => {}); return null }
@@ -151,59 +150,139 @@ async function saveCached(index, artifact) {
151
150
  try { await fs.mkdir(path.dirname(index), { recursive: true, mode: 0o700 }); await fs.writeFile(tmp, JSON.stringify(artifact), { mode: 0o600, flag: 'wx' }); await fs.rename(tmp, index) } catch { await fs.rm(tmp, { force: true }).catch(() => {}) }
152
151
  }
153
152
 
154
- export function buildPackage(plan, options = {}) {
155
- return runWithContext(contextSelection(), () => buildInContext(plan, options))
156
- }
157
-
158
- async function buildInContext(plan, { signal, progress = async () => {}, dir = artifactDirectory(), connect = gateway, fetcher = fetch, settle = 12000 } = {}) {
159
- const org = await readOrg()
160
- if (blockedBy(org, ['registry.npmjs.org']) || blockedByPolicy(await listPolicies(), {name:'',group:null}, ['registry.npmjs.org'], hostMatches)) throw fail('Organization policy blocks the npm registry.', 403)
161
- await progress('Looking up the package on npm')
162
- const pinned = await resolvePackage(plan, fetcher, signal)
163
- const { client, workspace, workspaceScope } = await connect()
164
- const name = 'sp-' + randomUUID().slice(0, 12)
165
- const base = await planSandbox({ name, systemBaseline: true })
166
- base.policy.networkPolicies = {}
167
- base.policy.networkPolicies.setup_registry = { name:'setup_registry', binaries:[{path:'/usr/local/bin/node'}],endpoints:[{host:'registry.npmjs.org',port:443,protocol:'rest',access:1,enforcement:1,allowEncodedSlash:true}] }
168
- let created = false, temporary
169
- try {
170
- await progress('Starting a temporary sandbox')
171
- await client.sandbox.create({ name, workspace, image:BUILDER_IMAGE, command:['/bin/sleep','infinity'],providers:[],policy:base.policy,labels:{'openshell.console/setup-builder':'true'} });created=true
172
- await waitReady(client,name,signal,{workspace})
173
- const effective=await client.raw.getSandboxConfig({name,workspaceScope})
174
- if(effective.policySource===2)throw fail('Gateway global policy overrides isolated preparation. Ask the administrator for a dedicated preparation gateway.',403)
175
- await progress('Checking the package runtime')
176
- const runtime = await builderRuntime(client, name, signal, workspace)
177
- const index = cacheIndex(pinned, runtime, dir), cached = await cachedArtifact(index, pinned, runtime, dir)
153
+ // A session belongs to one preparation job and never executes package code in
154
+ // its shared downloader. Each MCP initialization gets a fresh, offline sandbox.
155
+ export function createPackagePreparation(options = {}) {
156
+ const context = contextSelection()
157
+ let downloader, closed = false, busy = false
158
+ const signal = options.signal
159
+ const progress = options.progress ?? (async () => {})
160
+ const dir = options.dir ?? artifactDirectory()
161
+ const connect = options.connect ?? gateway
162
+ const readRuntime = options.readRuntime ?? (async target => {
163
+ const { preparationImageRuntime } = await import('./image-templates.js')
164
+ return preparationImageRuntime(target, BUILDER_IMAGE)
165
+ })
166
+ async function createBox(current, registry) {
178
167
  signal?.throwIfAborted()
179
- if (cached) { await progress('Using the package prepared earlier'); signal?.throwIfAborted(); return cached }
180
- // The supervisor's first provider-environment poll replaces synthetic DNS
181
- // mappings. Start npm after that initial refresh so its DNS cache is fresh.
182
- await progress('Waiting for the sandbox network to be ready')
183
- await new Promise((resolve,reject)=>{const done=()=>{signal?.removeEventListener('abort',stop);resolve()};const timer=setTimeout(done,settle);const stop=()=>{clearTimeout(timer);reject(signal.reason)};if(signal?.aborted)stop();else signal?.addEventListener('abort',stop,{once:true})})
184
- await progress('Checking access to the npm registry')
185
- await checkPreparationRegistry(client, name, signal, { workspace })
186
- await progress(`Downloading ${pinned.name}@${pinned.version} (install scripts off)`)
187
- const metadata = await installPackage(client, name, pinned, signal, { workspace })
188
- await progress('Saving the package')
189
- await fs.mkdir(dir,{recursive:true,mode:0o700})
190
- temporary=path.join(dir,randomUUID()+'.tmp')
191
- const handle=await fs.open(temporary,'wx',0o600);const digest=createHash('sha256');let bytes=0,exit=null
192
- try{for await(const event of client.sandbox.execStream(name,['tar','-czf','-','-C','/sandbox/package','.'],{workspace,noLoginShell:true,timeoutSecs:120,signal})){
193
- if(event.stream==='stdout'){bytes+=event.data.length;if(bytes>100*1024*1024)throw fail('Compressed package exceeds 100 MB.');digest.update(event.data);await handle.write(event.data)}
194
- if(event.type==='exit')exit=event.exitCode
195
- }}finally{await handle.close()}
196
- if(exit!==0)throw fail('Could not export the prepared package.')
197
- const id=digest.digest('hex');await fs.rename(temporary,path.join(dir,id+'.tar.gz'));temporary=null
198
- await progress('Checking that the MCP starts')
199
- const verifier=await fs.readFile(path.join(import.meta.dirname,'setup-verifier.cjs'),'utf8')
200
- const check=await client.sandbox.exec(name,['/usr/local/bin/node','-e',verifier],{workspace,noLoginShell:true,stdin:Buffer.from(JSON.stringify({config:{command:'/usr/local/bin/node',args:['/sandbox/package/'+metadata.bin,...pinned.args]}})),timeoutSecs:35,signal})
201
- let verification={status:'unverified',reason:'Initialization check did not finish.'}
202
- try{verification=JSON.parse(check.stdout.toString())}catch{}
203
- const artifact={ ...pinned,...metadata,digest:id,compressedBytes:bytes,verification,preparedAt:new Date().toISOString() }
204
- if(verification?.status==='connected')await saveCached(index,artifact)
205
- return artifact
206
- }finally{if(temporary)await fs.rm(temporary,{force:true});if(created)await client.sandbox.delete(name,{workspace}).catch(()=>{})}
168
+ const name = (registry ? 'sp-' : 'sc-') + randomUUID().slice(0, 12)
169
+ const base = await planSandbox({ name, systemBaseline: true })
170
+ base.policy.networkPolicies = registry ? { setup_registry: { name:'setup_registry', binaries:[{path:'/usr/local/bin/node'}],endpoints:[{host:'registry.npmjs.org',port:443,protocol:'rest',access:1,enforcement:1,allowEncodedSlash:true}] } } : {}
171
+ const {client, workspace, workspaceScope} = current
172
+ await client.sandbox.create({name,workspace,image:BUILDER_IMAGE,command:['/bin/sleep','infinity'],providers:[],policy:base.policy,labels:{[registry?'openshell.console/setup-builder':'openshell.console/setup-check']:'true'}})
173
+ const box = {...current, name}
174
+ try {
175
+ await waitReady(client,name,signal,{workspace})
176
+ const effective=await client.raw.getSandboxConfig({name,workspaceScope})
177
+ if(effective.policySource===2)throw fail('Gateway global policy overrides isolated preparation. Ask the administrator for a dedicated preparation gateway.',403)
178
+ box.runtime = await builderRuntime(client,name,signal,workspace)
179
+ signal?.throwIfAborted()
180
+ return box
181
+ } catch(error) { await dispose(box); throw error }
182
+ }
183
+ async function dispose(box) {
184
+ if (box) await box.client.sandbox.delete(box.name,{workspace:box.workspace}).catch(()=>{})
185
+ }
186
+ async function getDownloader(current, step) {
187
+ if (downloader) return downloader
188
+ await step('Starting the shared package download sandbox')
189
+ downloader = await createBox(current,true)
190
+ return downloader
191
+ }
192
+ async function readyNetwork(step) {
193
+ if(downloader.networkReady)return
194
+ await step('Waiting for the sandbox network to be ready')
195
+ await new Promise((resolve,reject)=>{const done=()=>{signal?.removeEventListener('abort',stop);resolve()};const timer=setTimeout(done,options.settle ?? 12000);const stop=()=>{clearTimeout(timer);reject(signal.reason)};if(signal?.aborted)stop();else signal?.addEventListener('abort',stop,{once:true})})
196
+ await checkPreparationRegistry(downloader.client,downloader.name,signal,{workspace:downloader.workspace})
197
+ downloader.networkReady=true
198
+ }
199
+ async function verify(current, artifact, runtime, step) {
200
+ await step('Checking the MCP in an isolated sandbox')
201
+ const box = await createBox(current,false)
202
+ const {client,name,workspace} = box
203
+ try {
204
+ if (JSON.stringify(box.runtime) !== JSON.stringify(runtime)) throw fail('Preparation runtime changed. Retry preparation.',409)
205
+ const {data} = await artifactFile(artifact,dir)
206
+ const file='/sandbox/check-package.tar.gz'
207
+ for(let offset=0;offset<data.length;offset+=512*1024) {
208
+ signal?.throwIfAborted()
209
+ const result=await client.sandbox.exec(name,['/usr/local/bin/node','-e',"const fs=require('fs');const p=process.argv[1],offset=Number(process.argv[2]);if(offset===0)fs.writeFileSync(p,Buffer.alloc(0),{flag:'wx',mode:0o600});if(fs.statSync(p).size!==offset)throw Error('Invalid package upload');fs.appendFileSync(p,fs.readFileSync(0))",file,String(offset)],{workspace,noLoginShell:true,stdin:data.subarray(offset,offset+512*1024),timeoutSecs:30,signal})
210
+ if(result.exitCode!==0)throw fail('Could not stage the MCP check package.')
211
+ }
212
+ const extracted=await client.sandbox.exec(name,['/usr/local/bin/node','-e',"const fs=require('fs'),cp=require('child_process');const file=process.argv[1];if(require('crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex')!==process.argv[2])throw Error('Package integrity mismatch');fs.mkdirSync('/sandbox/package');cp.execFileSync('tar',['-xzf',file,'-C','/sandbox/package']);fs.unlinkSync(file)",file,artifact.digest],{workspace,noLoginShell:true,timeoutSecs:120,signal})
213
+ if(extracted.exitCode!==0)throw fail('Could not extract the MCP check package.')
214
+ const verifier=await fs.readFile(path.join(import.meta.dirname,'setup-verifier.cjs'),'utf8')
215
+ const result=await client.sandbox.exec(name,['/usr/local/bin/node','-e',verifier],{workspace,noLoginShell:true,stdin:Buffer.from(JSON.stringify({config:{command:'/usr/local/bin/node',args:['/sandbox/package/'+artifact.bin,...artifact.args]}})),timeoutSecs:35,signal})
216
+ signal?.throwIfAborted()
217
+ try { if(result.exitCode===0)return JSON.parse(result.stdout.toString()) } catch {}
218
+ return {status:'unverified',reason:'Initialization check did not finish.'}
219
+ } finally { await dispose(box) }
220
+ }
221
+ async function build(plan, {progress: step = progress} = {}) {
222
+ if(closed || busy)throw fail('Package preparation session is closed or busy.',409)
223
+ busy=true
224
+ let temporary
225
+ try {
226
+ signal?.throwIfAborted()
227
+ const org=await readOrg()
228
+ if(blockedBy(org,['registry.npmjs.org']) || blockedByPolicy(await listPolicies(),{name:'',group:null},['registry.npmjs.org'],hostMatches))throw fail('Organization policy blocks the npm registry.',403)
229
+ await step('Looking up the package on npm')
230
+ const pinned=await resolvePackage(plan,options.fetcher ?? fetch,signal)
231
+ const current=await connect()
232
+ if(downloader && (downloader.client!==current.client || downloader.workspace!==current.workspace)) {
233
+ await dispose(downloader);downloader=null
234
+ }
235
+ // Read-only image metadata is an optional fast path. Unknown targets must
236
+ // fall back to a real builder probe, never the console host architecture.
237
+ const known=downloader?.runtime ?? await readRuntime(current.target).catch(()=>null)
238
+ if(known) {
239
+ const cached=await cachedArtifact(cacheIndex(pinned,known,dir),pinned,known,dir)
240
+ signal?.throwIfAborted()
241
+ if(cached){await step('Using the package prepared earlier');signal?.throwIfAborted();return cached}
242
+ }
243
+ const box=await getDownloader(current,step)
244
+ const {client,name,workspace,runtime}=box
245
+ const index=cacheIndex(pinned,runtime,dir)
246
+ const cached=await cachedArtifact(index,pinned,runtime,dir)
247
+ signal?.throwIfAborted()
248
+ if(cached){await step('Using the package prepared earlier');signal?.throwIfAborted();return cached}
249
+ await readyNetwork(step)
250
+ const root='/sandbox/packages/'+randomUUID()
251
+ await step(`Downloading ${pinned.name}@${pinned.version} (install scripts off)`)
252
+ const metadata=await installPackage(client,name,pinned,signal,{workspace,root})
253
+ if(Object.entries(runtime).some(([key,value])=>metadata[key]!==value))throw fail('Package builder runtime changed. Retry preparation.',409)
254
+ await step('Saving the package')
255
+ await fs.mkdir(dir,{recursive:true,mode:0o700})
256
+ temporary=path.join(dir,randomUUID()+'.tmp')
257
+ const handle=await fs.open(temporary,'wx',0o600),digest=createHash('sha256')
258
+ let bytes=0,exit=null
259
+ try {for await(const event of client.sandbox.execStream(name,['tar','-czf','-','-C',root,'.'],{workspace,noLoginShell:true,timeoutSecs:120,signal})){
260
+ if(event.stream==='stdout'){bytes+=event.data.length;if(bytes>100*1024*1024)throw fail('Compressed package exceeds 100 MB.');digest.update(event.data);await handle.write(event.data)}
261
+ if(event.type==='exit')exit=event.exitCode
262
+ }} finally {await handle.close()}
263
+ if(exit!==0)throw fail('Could not export the prepared package.')
264
+ const removed=await client.sandbox.exec(name,['/usr/local/bin/node','-e',"require('fs').rmSync(process.argv[1],{recursive:true,force:true})",root],{workspace,noLoginShell:true,timeoutSecs:30,signal})
265
+ if(removed.exitCode!==0)throw fail('Could not clean the package download directory.')
266
+ const id=digest.digest('hex');await fs.rename(temporary,path.join(dir,id+'.tar.gz'));temporary=null
267
+ const artifact={...pinned,...metadata,digest:id,compressedBytes:bytes,preparedAt:new Date().toISOString()}
268
+ artifact.verification=await verify(current,artifact,runtime,step)
269
+ if(artifact.verification?.status==='connected')await saveCached(index,artifact)
270
+ return artifact
271
+ } catch(error) {
272
+ // A cancelled/failed operation must not leave a partially initialized
273
+ // downloader available to another item in this job.
274
+ await dispose(downloader);downloader=null
275
+ throw error
276
+ } finally {busy=false;if(temporary)await fs.rm(temporary,{force:true})}
277
+ }
278
+ return {
279
+ build:(plan,overrides)=>runWithContext(context,()=>build(plan,overrides)),
280
+ close:()=>runWithContext(context,async()=>{closed=true;await dispose(downloader);downloader=null}),
281
+ }
282
+ }
283
+ export async function buildPackage(plan, options = {}) {
284
+ const session=createPackagePreparation(options)
285
+ try {return await session.build(plan)} finally {await session.close()}
207
286
  }
208
287
 
209
288
  export async function artifactFile(artifact, dir = artifactDirectory()) {
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { randomUUID } from 'node:crypto'
4
- import { buildPackage, packageLocation, packageRuntimeRequirements } from './setup-packages.js'
4
+ import { createPackagePreparation, packageLocation, packageRuntimeRequirements } from './setup-packages.js'
5
5
  import { discoverSignIn } from './setup-auth-discovery.js'
6
6
  import { checkRemote } from './setup-remote-check.js'
7
7
  import { connectCredentials } from './setup-credentials.js'
@@ -101,6 +101,7 @@ export async function prepareImport(store,input,{source,packagesOnly=false}={}){
101
101
  const job={id,status:'running',createdAt:new Date().toISOString(),message:'Preparing selected tools',items:items.map(publicItem)}
102
102
  state.jobs.set(id,job);state.controllers.set(id,controller);await persist(job)
103
103
  const context=contextSelection()
104
+ const packages=createPackagePreparation({signal:controller.signal})
104
105
  void runWithContext(context,async()=>{
105
106
  const prepared=[]
106
107
  let startupFailure=null
@@ -111,7 +112,7 @@ export async function prepareImport(store,input,{source,packagesOnly=false}={}){
111
112
  try{
112
113
  if(!item.preparationBlocked&&!item.disabled&&(!packagesOnly||canPrepareAtLaunch(item))){
113
114
  if(item.package&&!item.artifact){
114
- const artifact=await buildPackage(item.package,{signal:controller.signal,progress:step})
115
+ const artifact=await packages.build(item.package,{progress:step})
115
116
  item={...item,artifact,config:{command:'node',args:[`${packageLocation(artifact)}/${artifact.bin}`,...artifact.args],...(item.environment?{env:item.environment}:{})},issues:item.issues.filter(i=>!isPackagePending(i))}
116
117
  }
117
118
  if(item.credentialFields?.length&&(choices[item.id]?.useSourceSecrets||choices[item.id]?.provider||Object.values(choices[item.id]?.secrets||{}).some(Boolean))){
@@ -138,7 +139,7 @@ export async function prepareImport(store,input,{source,packagesOnly=false}={}){
138
139
  else{job.status='complete';job.message='Review the prepared items. Items needing attention remain inactive.';job.review=store.stage(prepared,preview.credentials)}
139
140
  await persist(job)
140
141
  // A finished job keeps only its public view; the staged review or snapshot holds the prepared items.
141
- }).catch((e)=>runWithContext(context,async()=>{job.status='failed';job.message=e?.status?e.message:'Preparation failed. Retry the import.';delete job.current;try{await persist(job)}catch{}})).finally(()=>{state.controllers.delete(id);delete job.preparedItems})
142
+ }).catch((e)=>runWithContext(context,async()=>{job.status='failed';job.message=e?.status?e.message:'Preparation failed. Retry the import.';delete job.current;try{await persist(job)}catch{}})).finally(async()=>{await packages.close();state.controllers.delete(id);delete job.preparedItems})
142
143
  return publicJob(job)
143
144
  })
144
145
  }
package/server/start.js CHANGED
@@ -6,13 +6,26 @@ import fs from 'node:fs'
6
6
  import connect from 'connect'
7
7
  import serveStatic from 'serve-static'
8
8
  import { createSessionRevocations } from './session-revocations.js'
9
+ import { stateDirectory } from './paths.js'
9
10
  import { openshellApi } from './api.js'
10
- import { CLOUD_UNRELEASED, createSecurity, releaseConfig, requestPath } from './security.js'
11
+ import { createSecurity, releaseConfig, requestPath } from './security.js'
11
12
 
12
- export async function createConsoleServer({ config = releaseConfig(), auth, machines, handoffs, connections, dist = path.resolve(import.meta.dirname, '../dist') } = {}) {
13
- if (config.mode === 'cloud' && !(auth && machines && handoffs && connections)) throw Error(CLOUD_UNRELEASED)
13
+ export async function createConsoleServer({ config = releaseConfig(), auth, machines, handoffs, connections, runtimeFactory, dist = path.resolve(import.meta.dirname, '../dist'), sessionFile = process.env.OPENROD_SESSION_FILE ?? path.join(stateDirectory(), 'cloud-sessions.sqlite') } = {}) {
14
14
  if (!fs.existsSync(path.join(dist, 'index.html'))) throw Error('Build the UI with npm run build before starting the server')
15
- const revocations = config.mode === 'cloud' ? createSessionRevocations(path.resolve(import.meta.dirname, '../.state/sessions.sqlite')) : undefined
15
+ const revocations = config.mode === 'cloud' ? createSessionRevocations(sessionFile) : undefined
16
+ let runtime
17
+ if (config.mode === 'cloud' && !(auth && machines && handoffs && connections)) {
18
+ try {
19
+ const createRuntime = runtimeFactory ?? (await import('./cloud-runtime.js')).createCloudRuntime
20
+ runtime = await createRuntime(config, { revocations })
21
+ ;({ auth, machines, handoffs, connections } = runtime)
22
+ if (!(auth && machines && handoffs && connections)) throw Error('Cloud runtime initialization is incomplete')
23
+ } catch (error) {
24
+ revocations.close()
25
+ await runtime?.close?.()
26
+ throw error
27
+ }
28
+ }
16
29
  const security = createSecurity(config, auth, revocations)
17
30
  const app = connect()
18
31
  const server = http.createServer(app)
@@ -28,13 +41,21 @@ export async function createConsoleServer({ config = releaseConfig(), auth, mach
28
41
  next()
29
42
  })
30
43
  if (config.mode === 'cloud') {
31
- const routes = cloudRouter(security, machines, handoffs, auth, {connections})
44
+ const routes = cloudRouter(security, machines, handoffs, auth, { connections, ...(runtime?.artifact ? { artifact: runtime.artifact } : {}) })
32
45
  app.use(routes.publicRoutes)
33
46
  app.use(security.middleware)
34
47
  app.use(routes.protectedRoutes)
35
48
  server.on('upgrade', routes.upgrade)
36
49
  } else openshellApi(security).configureServer({ middlewares: app, httpServer: server, config: { logger: { info: console.info } } })
37
50
  app.use('/healthz', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ready') })
51
+ app.use('/readyz', async (req, res) => {
52
+ let deadline
53
+ try {
54
+ await Promise.race([runtime?.ready?.(), new Promise((_, reject) => { deadline = setTimeout(() => reject(Error('Readiness timed out')), 5000) })])
55
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ ready: true, mode: config.mode }))
56
+ } catch { res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ ready: false, mode: config.mode })) }
57
+ finally { clearTimeout(deadline) }
58
+ })
38
59
  const upgradePaths = config.mode === 'cloud'
39
60
  ? ['/api/os/terminal', '/api/cloud/local-connect/os/terminal', '/api/cloud/local-connect/os/ssh']
40
61
  : config.mode === 'local' ? ['/api/os/terminal', '/api/os/ssh', '/api/remote/os/terminal', '/api/remote/os/ssh'] : ['/api/os/terminal', '/api/os/ssh']
@@ -44,7 +65,7 @@ export async function createConsoleServer({ config = releaseConfig(), auth, mach
44
65
  })
45
66
  app.use(serveStatic(dist, { index: 'index.html', dotfiles: 'deny', maxAge: 0 }))
46
67
  app.use((req, res) => { res.writeHead(404); res.end('Not found') })
47
- server.once('close', () => revocations?.close())
68
+ server.once('close', () => { revocations?.close(); void runtime?.close?.().catch(error => console.error('Cloud runtime shutdown failed:', error.message)) })
48
69
  server.requestTimeout = 35 * 60000
49
70
  server.headersTimeout = 15000
50
71
  return server
@@ -54,7 +75,8 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
54
75
  const server = await createConsoleServer()
55
76
  const port = Number(process.env.OPENROD_PORT ?? 4600)
56
77
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw Error('OPENROD_PORT must be between 1024 and 65535')
57
- const address = process.env.OPENROD_MODE === 'worker' ? '0.0.0.0' : '127.0.0.1'
78
+ const address = process.env.OPENROD_MODE === 'worker' ? process.env.OPENROD_WORKER_BIND ?? '0.0.0.0' : '127.0.0.1'
79
+ if (!['127.0.0.1', '0.0.0.0'].includes(address)) throw Error('OPENROD_WORKER_BIND must be 127.0.0.1 or 0.0.0.0')
58
80
  // Local mode prints only the tokened link (from the API plugin); a bare URL would just hit the token wall.
59
81
  const mode = process.env.OPENROD_MODE ?? 'local'
60
82
  server.listen(port, address, () => { if (mode !== 'local') console.info(`OpenRod ${mode} console listening on ${address}:${port}`) })
@@ -4,8 +4,8 @@ export function signWorkerRequest(key,identity,req,now=Date.now()) {
4
4
  const payload=Buffer.from(JSON.stringify({...identity,method:req.method,target:req.url,validUntil:now+30000})).toString('base64url')
5
5
  return `${payload}.${createHmac('sha256',key).update(payload).digest('base64url')}`
6
6
  }
7
- export function verifyWorkerRequest(key,owner,req,now=Date.now()) {
8
- const token=req.headers['x-openrod-worker-auth']
7
+ export function verifyWorkerRequest(key,owner,req,now=Date.now(),header='x-openrod-worker-auth') {
8
+ const token=req.headers[header]
9
9
  if(typeof token!=='string'||token.length>5000)throw denied()
10
10
  const [payload,mac,extra]=token.split('.')
11
11
  if(!payload||!mac||extra)throw denied()
@@ -27,6 +27,9 @@ export const AGENT_ACCESS = {
27
27
  binaries: ['/usr/bin/claude', '/usr/local/bin/claude'],
28
28
  endpoints: ['api.anthropic.com', 'platform.claude.com', 'claude.ai'].map(host => endpoint(host)),
29
29
  authentication: 'Attach a Claude credential or sign in with your subscription after connecting.',
30
+ // Claude Code lists claude.ai connectors on api.anthropic.com and calls
31
+ // them through Anthropic's proxy, which holds each connector's sign-in.
32
+ connectors: { name: 'claude.ai connectors', hosts: ['mcp-proxy.anthropic.com'] },
30
33
  },
31
34
  opencode: {
32
35
  name: 'OpenCode',
@@ -39,6 +42,10 @@ export const AGENT_ACCESS = {
39
42
  name: 'Codex',
40
43
  binaries: ['/usr/bin/codex', '/usr/local/bin/codex', '/usr/local/lib/node_modules/@openai/**/codex', '/usr/lib/node_modules/@openai/**/codex'],
41
44
  endpoints: ['api.openai.com', 'auth.openai.com', 'chatgpt.com', 'ab.chatgpt.com'].map(host => endpoint(host)),
45
+ // Codex lists and calls ChatGPT connectors (apps) and hosted plugins on
46
+ // chatgpt.com, which it also needs for sign-in, so turning them off closes
47
+ // these paths instead.
48
+ connectors: { name: 'ChatGPT connectors and plugins', paths: { 'chatgpt.com': ['/backend-api/ps/mcp', '/backend-api/ps/mcp/**', '/backend-api/connectors/**'] } },
42
49
  // Codex's "Sign in with ChatGPT" redirects the host browser to a callback
43
50
  // server inside the sandbox, which the browser cannot reach.
44
51
  authentication: 'Attach a Codex credential, or run codex after connecting and choose “Sign in with Device Code” (ChatGPT plan) or “Provide your own API key”. “Sign in with ChatGPT” can’t complete from a sandbox.',
@@ -85,19 +92,34 @@ export const TOOL_ACCESS = {
85
92
  },
86
93
  }
87
94
 
88
- export function agentAccessFor(recipe) {
95
+ // Agents whose subscription brings its own connectors (remote MCPs). They stay
96
+ // off unless chosen at creation.
97
+ export const connectorAgents = (ids = []) => ids.filter(id => AGENT_ACCESS[id]?.connectors)
98
+ // A Claude Code secret is an API key; a Codex secret is the ChatGPT sign-in
99
+ // itself, so only the former rules connectors out.
100
+ const API_KEYS = ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY', 'OPENAI_API_KEY']
101
+ export const holdsApiKey = (secret) => Boolean(secret?.credentialKeys?.some(key => API_KEYS.includes(key)))
102
+
103
+ function withConnectors(access, on) {
104
+ const { hosts = [], paths = {} } = access.connectors ?? {}
105
+ const endpoints = access.endpoints.map(e => !on && paths[e.host] ? { ...e, deny: [...(e.deny ?? []), ...paths[e.host].map(path => ({ method: '*', path }))] } : e)
106
+ return { ...access, endpoints: on ? [...endpoints, ...hosts.map(host => endpoint(host))] : endpoints }
107
+ }
108
+
109
+ export function agentAccessFor(recipe, { connectors = [] } = {}) {
89
110
  // Only built recipes have known installation locations. An existing image
90
111
  // keeps its policy as chosen; we do not guess what it contains.
91
112
  if (recipe?.source !== 'build') return { profiles: [], unsupported: [] }
92
113
  const ids = [...new Set(recipe.agents ?? [])]
114
+ if (!Array.isArray(connectors) || connectors.some(id => !connectorAgents(ids).includes(id))) throw new Error('Connectors can be turned on only for this sandbox’s Claude Code or Codex.')
93
115
  return {
94
- profiles: [...ids.filter(id => AGENT_ACCESS[id]).map(id => ({ id, rule: `agent-${id}`, ...AGENT_ACCESS[id] })), ...[...new Set(recipe.runtimes ?? [])].filter(id => TOOL_ACCESS[id]).map(id => ({ id, ...TOOL_ACCESS[id] }))],
116
+ profiles: [...ids.filter(id => AGENT_ACCESS[id]).map(id => ({ id, rule: `agent-${id}`, ...withConnectors(AGENT_ACCESS[id], connectors.includes(id)) })), ...[...new Set(recipe.runtimes ?? [])].filter(id => TOOL_ACCESS[id]).map(id => ({ id, ...TOOL_ACCESS[id] }))],
95
117
  unsupported: ids.filter(id => !AGENT_ACCESS[id]),
96
118
  }
97
119
  }
98
120
 
99
- export function agentAccessRules(recipe) {
100
- const { profiles, unsupported } = agentAccessFor(recipe)
121
+ export function agentAccessRules(recipe, options) {
122
+ const { profiles, unsupported } = agentAccessFor(recipe, options)
101
123
  if (unsupported.length) throw new Error(`Automatic network access is not configured for: ${unsupported.join(', ')}. Add a reviewed agent access profile before launching this template.`)
102
124
  return profiles.map(({ rule, binaries, endpoints }) => ({ name: rule, binaries, endpoints }))
103
125
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "projectToken": "phc_t8z9eNB6eJABMR9iLkDbqAZH5wFh7wmjjDqEpAxnPPAU",
3
+ "host": "https://us.i.posthog.com",
4
+ "projectId": 647098
5
+ }
@@ -10,7 +10,7 @@ export function activityRow(event) {
10
10
  const agent = known ? { name: known.name, logo: `/logos/agents/${known.logo}.svg` } : null
11
11
  const direction = event.direction || (event.kind === 'inbound' && ['NET', 'HTTP', 'SSH'].includes(event.category) ? 'in' : event.kind === 'audit' && ['NET', 'HTTP'].includes(event.category) ? 'out' : 'unknown')
12
12
  const values = {
13
- time: event.at ?? '', sandbox: event.sandbox ?? '', scope: event.scope ?? '', sandboxId: event.sandboxId ?? event.original?.sandboxId ?? '', agent: agent?.name ?? 'Unknown',
13
+ source: event.location?.label ?? '', time: event.at ?? '', sandbox: event.sandbox ?? '', scope: event.scope ?? '', sandboxId: event.sandboxId ?? event.original?.sandboxId ?? '', agent: agent?.name ?? 'Unknown',
14
14
  category: CATEGORY_NAMES[event.category] ?? (event.kind === 'log' ? 'Log' : 'Unclassified'),
15
15
  severity: event.severity || 'Not reported',
16
16
  logLevel: event.level && event.level !== 'OCSF' ? event.level : 'Not reported', direction,