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.
- package/README.md +1 -2
- package/THIRD_PARTY_NOTICES.md +84 -1
- package/dist/assets/index-DBqg9X9x.js +553 -0
- package/dist/assets/index-HZ0q_FBv.css +1 -0
- package/dist/assets/index.esm-BqjC2fhp.js +376 -0
- package/dist/assets/index.esm-Cj6nXjOx.js +16 -0
- package/dist/assets/index.esm-Z1y-oluO.js +1666 -0
- package/dist/assets/terminal-view-D-SQcdM5.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +8 -1
- package/server/announcements.js +22 -0
- package/server/api.js +54 -11
- package/server/capabilities.js +13 -0
- package/server/cli.js +18 -2
- package/server/cloud-deployment.js +29 -0
- package/server/cloud-proxy.js +19 -9
- package/server/cloud-runtime.js +72 -0
- package/server/cloud-ssh-proxy.cjs +22 -4
- package/server/cloud-transfer.js +1 -1
- package/server/compute.js +18 -7
- package/server/image-templates.js +17 -0
- package/server/ingress.js +3 -0
- package/server/local-cloud-native.js +7 -1
- package/server/local-cloud.js +3 -3
- package/server/machines.js +33 -18
- package/server/policy-template-store.js +1 -0
- package/server/remote-gateway.js +26 -6
- package/server/remote-http.js +1 -1
- package/server/resource-import-portable.js +155 -0
- package/server/resource-imports.js +412 -0
- package/server/sandbox-identity.js +1 -1
- package/server/security.js +30 -14
- package/server/setup-discovery.js +2 -1
- package/server/setup-packages.js +138 -59
- package/server/setup-preparation.js +4 -3
- package/server/start.js +29 -7
- package/server/worker-auth.js +2 -2
- package/shared/agent-access.js +26 -4
- package/shared/analytics-release.json +5 -0
- package/src/lib/activity-inventory.js +1 -1
- package/src/lib/analytics.js +334 -0
- package/src/lib/api.js +28 -11
- package/src/lib/cloud-sign-in-error.js +13 -0
- package/src/lib/cloud-transfer.js +1 -1
- package/src/lib/combined-activity.js +95 -0
- package/src/lib/connection-host.js +6 -0
- package/src/lib/egress-inventory.js +63 -0
- package/src/lib/local-cloud.js +16 -3
- package/src/lib/location-choice.js +7 -0
- package/src/lib/location-data-reader.js +25 -0
- package/src/lib/locations.js +18 -8
- package/src/lib/placement.js +1 -1
- package/src/lib/resource-imports.js +63 -0
- package/src/lib/resource-sources.js +105 -0
- package/src/lib/sandbox-creations.js +14 -9
- package/src/lib/sandboxes.js +5 -1
- package/src/lib/working-location.js +10 -0
- package/dist/assets/index-89TNohi_.css +0 -1
- package/dist/assets/index-DoKK2G9s.js +0 -545
- package/dist/assets/terminal-view-DzqQnmh2.js +0 -40
package/server/setup-packages.js
CHANGED
|
@@ -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,
|
|
130
|
-
//
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if(
|
|
205
|
-
|
|
206
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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(
|
|
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}`) })
|
package/server/worker-auth.js
CHANGED
|
@@ -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[
|
|
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()
|
package/shared/agent-access.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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,
|