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/ingress.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { gateway, iso, contextKey, contextSelection, runWithContext } from './gateway.js'
|
|
5
5
|
import { stateDirectory } from './paths.js'
|
|
6
|
+
import { assertCloudOperation } from './security.js'
|
|
6
7
|
|
|
7
8
|
// Ingress: the ways into a sandbox. OpenShell sandboxes accept nothing inbound
|
|
8
9
|
// on their own; every way in is something the gateway opened on purpose:
|
|
@@ -79,6 +80,7 @@ async function setDeadline(sandbox, name, expiresAt) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
export async function expose({ sandbox, name = '', port, closeAfterMinutes }) {
|
|
83
|
+
assertCloudOperation(['ingress', 'expose'])
|
|
82
84
|
if (!SANDBOX.test(sandbox ?? '')) throw fail('Unknown sandbox.')
|
|
83
85
|
if (!SERVICE.test(name)) throw fail('Service names use lowercase letters, digits and dashes (up to 32).')
|
|
84
86
|
const targetPort = Number(port)
|
|
@@ -99,6 +101,7 @@ export async function close({ sandbox, name = '' }, reason = 'closed') {
|
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
async function extend({ sandbox, name = '', closeAfterMinutes }) {
|
|
104
|
+
assertCloudOperation(['ingress', 'extend'])
|
|
102
105
|
if (!SANDBOX.test(sandbox ?? '') || !SERVICE.test(name)) throw fail('Unknown service.')
|
|
103
106
|
const services = await listServices()
|
|
104
107
|
if (!services.some((s) => s.sandbox === sandbox && s.name === name)) throw fail('That service is no longer open.')
|
|
@@ -10,6 +10,7 @@ import { projectOf } from '../src/lib/sandbox-session.js'
|
|
|
10
10
|
import { readJson, responseJson } from './remote-http.js'
|
|
11
11
|
import { validateHostKeys } from './cloud-ssh.js'
|
|
12
12
|
import { cloudOrigin } from './cloud-origin.js'
|
|
13
|
+
import { tokenCookie } from './launch-token.js'
|
|
13
14
|
|
|
14
15
|
const NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
|
|
15
16
|
const EDITORS = { cursor: { label: 'Cursor', binary: 'cursor', app: 'Cursor.app' }, vscode: { label: 'VS Code', binary: 'code', app: 'Visual Studio Code.app' } }
|
|
@@ -65,13 +66,14 @@ export function createLocalCloudNative(dependencies = {}) {
|
|
|
65
66
|
if (requestedContext && ticket.context !== requestedContext) throw fail('Cloud workspace changed. Open the sandbox from OpenRod again.', 409)
|
|
66
67
|
const alias = aliasFor(owner, name, context)
|
|
67
68
|
assertActive(services, grant)
|
|
69
|
+
const authFile = dependencies.token ? path.join(directory, `cloud_ssh_auth_${ownerId(origin)}`) : null
|
|
68
70
|
const config = [
|
|
69
71
|
`Host ${alias}`, ` HostName ${alias}`, ' User sandbox', ' StrictHostKeyChecking yes',
|
|
70
72
|
` UserKnownHostsFile ${configQuote(hostsFile)}`, ' GlobalKnownHostsFile /dev/null',
|
|
71
73
|
' CheckHostIP no', ' UpdateHostKeys no', ' IdentityAgent none', ' IdentitiesOnly yes',
|
|
72
74
|
' PubkeyAuthentication no', ' PasswordAuthentication no', ' KbdInteractiveAuthentication no',
|
|
73
75
|
' ControlMaster no', ' ServerAliveInterval 15', ' ServerAliveCountMax 3',
|
|
74
|
-
` ProxyCommand ${[node, helper, '--origin', origin, '--sandbox', name, '--owner', ownerId(owner), ...(context ? ['--context', context] : [])].map(proxyQuote).join(' ')}`,
|
|
76
|
+
` ProxyCommand ${[node, helper, '--origin', origin, '--sandbox', name, '--owner', ownerId(owner), ...(authFile ? ['--auth-file', authFile] : []), ...(context ? ['--context', context] : [])].map(proxyQuote).join(' ')}`,
|
|
75
77
|
].join('\n') + '\n'
|
|
76
78
|
await serial(async () => {
|
|
77
79
|
assertActive(services, grant)
|
|
@@ -79,6 +81,10 @@ export function createLocalCloudNative(dependencies = {}) {
|
|
|
79
81
|
const stat = await fs.lstat(directory)
|
|
80
82
|
if (stat.isSymbolicLink() || !stat.isDirectory()) throw fail('OpenRod SSH directory must be a regular directory.', 409)
|
|
81
83
|
await fs.chmod(directory, 0o700)
|
|
84
|
+
if (authFile) {
|
|
85
|
+
assertActive(services, grant)
|
|
86
|
+
await writeManaged(authFile, JSON.stringify({ origin, cookie: tokenCookie(new URL(origin).port || '80', dependencies.token) }) + '\n')
|
|
87
|
+
}
|
|
82
88
|
const oldHosts = await readManaged(hostsFile)
|
|
83
89
|
assertActive(services, grant)
|
|
84
90
|
await writeManaged(hostsFile, oldHosts.split('\n').filter(line => line && !line.startsWith(`${alias} `)).concat(keys.map(key => `${alias} ${key}`)).join('\n') + '\n')
|
package/server/local-cloud.js
CHANGED
|
@@ -9,7 +9,7 @@ const PREFIX='/api/cloud/local-connect'
|
|
|
9
9
|
const cloudPath=req=>{try{const path=requestPath(req);return path.startsWith('/api/local-cloud/')||path.startsWith('/api/remote/')}catch{return false}}
|
|
10
10
|
// Without a configured cloud origin every sign-in, relay and socket route refuses before any cloud request.
|
|
11
11
|
function unavailableLocalCloud(){return {close(){},setNative(){},
|
|
12
|
-
middleware(req,res,next){if(!cloudPath(req))return next();req.resume();responseJson(res,409,{error:CLOUD_SOON})},
|
|
12
|
+
middleware(req,res,next){if(!cloudPath(req))return next();if(!isLocalApiRequest(req)){req.resume();return responseJson(res,403,{error:'Request rejected'})}if(requestPath(req)==='/api/local-cloud/status'&&req.method==='GET')return responseJson(res,200,{available:false,connected:false,reason:CLOUD_SOON});req.resume();responseJson(res,409,{error:CLOUD_SOON})},
|
|
13
13
|
upgrade(req,socket){if(!cloudPath(req))return false;socket.on('error',()=>{});socket.end(`HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n${JSON.stringify({error:CLOUD_SOON})}`);return true},
|
|
14
14
|
}}
|
|
15
15
|
export function createLocalCloud({origin=cloudOrigin(),allowTestHttp=false,native}={}) {
|
|
@@ -31,7 +31,7 @@ export function createLocalCloud({origin=cloudOrigin(),allowTestHttp=false,nativ
|
|
|
31
31
|
const call=(path,options={})=>upstream(path,{...options,token:connection().token})
|
|
32
32
|
const ownerId=uid=>createHash('sha256').update(uid).digest('hex').slice(0,16)
|
|
33
33
|
const assertOwner=req=>{const current=connection(),supplied=req.headers['x-openrod-local-owner']??new URL(req.url,'http://local').searchParams.get('owner');if(supplied!==ownerId(current.user.uid))throw Object.assign(remoteFail('This cloud connection belongs to a different account. Sign in again in this tab.',403),{code:'CLOUD_OWNER_CHANGED'});return current}
|
|
34
|
-
const status=()=>grant?{connected:true,user:grant.user,owner:ownerId(grant.user.uid),expires:grant.expires}:{connected:false}
|
|
34
|
+
const status=()=>({available:true,origin,...(grant?{connected:true,user:grant.user,owner:ownerId(grant.user.uid),expires:grant.expires}:{connected:false})})
|
|
35
35
|
function relay(req,res,target){
|
|
36
36
|
const current=connection(),headers={host:url.host,origin,'x-openshell-console':'1',authorization:`Bearer ${current.token}`}
|
|
37
37
|
for(const k of ['content-type','content-length','accept','range','x-openshell-context','x-openshell-location'])if(req.headers[k])headers[k]=req.headers[k]
|
|
@@ -67,7 +67,7 @@ export function createLocalCloud({origin=cloudOrigin(),allowTestHttp=false,nativ
|
|
|
67
67
|
if(grant){const current=grant;try{const result=await call('/identity');if(grant===current&&result.user?.uid===current.user.uid)current.user=result.user;return responseJson(res,200,status())}catch(error){if(grant!==current)return responseJson(res,200,status());if([401,403].includes(error.status))clear();else return responseJson(res,200,{...status(),error:error.message})}}
|
|
68
68
|
return responseJson(res,200,status())
|
|
69
69
|
}
|
|
70
|
-
if(path==='/api/local-cloud/machine'&&req.method
|
|
70
|
+
if(path==='/api/local-cloud/machine'&&['GET','POST'].includes(req.method)){const current=assertOwner(req);if(req.method==='POST')await readJson(req);const machine=await call('/machine',{method:req.method,...(req.method==='POST'?{body:{}}:{})});if(grant!==current)throw remoteFail('Cloud connection changed. Try again.',401);return responseJson(res,200,{...status(),machine})}
|
|
71
71
|
if(path==='/api/local-cloud/inventory'&&req.method==='GET'){const current=assertOwner(req),inventory=await call('/inventory',{maxBytes:4*1024*1024});if(grant!==current)throw remoteFail('Cloud connection changed. Try again.',401);return responseJson(res,200,inventory)}
|
|
72
72
|
if(path==='/api/local-cloud/start'&&req.method==='POST'){
|
|
73
73
|
const body=await readJson(req),verifier=randomBytes(32).toString('hex'),nonce=randomUUID(),challenge=createHash('sha256').update(verifier).digest('hex')
|
package/server/machines.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import http from 'node:http'
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto'
|
|
3
3
|
import { signWorkerRequest } from './worker-auth.js'
|
|
4
|
+
import { workerPrefix, workerAuthHeader, workerOwnerHash } from './cloud-deployment.js'
|
|
4
5
|
const fail=(message,status=503)=>Object.assign(Error(message),{status})
|
|
5
|
-
export function machineName(uid) {
|
|
6
|
-
|
|
7
|
-
return `openrod-user-${createHash('sha256').update(uid).digest('hex').slice(0,24)}`
|
|
6
|
+
export function machineName(uid,prefix='openrod-user') {
|
|
7
|
+
return `${workerPrefix(prefix)}-${workerOwnerHash(uid)}`
|
|
8
8
|
}
|
|
9
|
-
export function createMachineStore(db,{maxMachines=10,now=()=>Date.now()}={}) {
|
|
9
|
+
export function createMachineStore(db,{maxMachines=10,now=()=>Date.now(),prefix='openrod-user'}={}) {
|
|
10
|
+
workerPrefix(prefix)
|
|
10
11
|
if(!Number.isInteger(maxMachines)||maxMachines<1||maxMachines>100)throw Error('Fleet limit must be between 1 and 100')
|
|
11
|
-
const ref=uid=>db.collection('machines').doc(machineName(uid))
|
|
12
|
+
const ref=uid=>db.collection('machines').doc(machineName(uid,prefix))
|
|
12
13
|
return {
|
|
13
14
|
async reserve(identity) {
|
|
14
15
|
const document=ref(identity.uid),fleet=db.collection('control').doc('fleet')
|
|
@@ -17,22 +18,22 @@ export function createMachineStore(db,{maxMachines=10,now=()=>Date.now()}={}) {
|
|
|
17
18
|
if(current.exists){const record=current.data();if(record.uid!==identity.uid)throw fail('Machine ownership mismatch',403);return record}
|
|
18
19
|
const counter=await tx.get(fleet),count=counter.exists?counter.data().count:0
|
|
19
20
|
if(!Number.isInteger(count)||count<0||count>=maxMachines)throw fail('Cloud capacity is full. Please try again later.')
|
|
20
|
-
const record={uid:identity.uid,email:identity.email??null,name:machineName(identity.uid),key:randomBytes(32).toString('hex'),requestId:randomUUID(),createdAt:now(),state:'provisioning',leaseUntil:0}
|
|
21
|
+
const record={uid:identity.uid,email:identity.email??null,name:machineName(identity.uid,prefix),key:randomBytes(32).toString('hex'),requestId:randomUUID(),createdAt:now(),state:'provisioning',leaseUntil:0}
|
|
21
22
|
tx.set(document,record);tx.set(fleet,{count:count+1});return record
|
|
22
23
|
})
|
|
23
24
|
},
|
|
24
25
|
async get(uid){const result=await ref(uid).get();return result.exists?result.data():null},
|
|
25
|
-
async byName(name){if(
|
|
26
|
+
async byName(name){if(!new RegExp(`^${prefix}-[a-f0-9]{24}$`).test(name))return null;const r=await db.collection('machines').doc(name).get();return r.exists?r.data():null},
|
|
26
27
|
async lease(uid){return db.runTransaction(async tx=>{const r=ref(uid),s=await tx.get(r);if(!s.exists||s.data().leaseUntil>now())return false;tx.update(r,{leaseUntil:now()+120000});return true})},
|
|
27
28
|
async update(uid,values){return db.runTransaction(async tx=>{const r=ref(uid);await tx.get(r);tx.update(r,values)})},
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
|
-
export function workerReady(record,address,{port=4600,host=new URL(process.env.OPENROD_PUBLIC_ORIGIN||process.env.OPENROD_CLOUD_ORIGIN||'https://localhost').host,timeoutMs=5000,requestProbe=http.request}={}) {
|
|
31
|
+
export function workerReady(record,address,{port=4600,protocol='current',host=new URL(process.env.OPENROD_PUBLIC_ORIGIN||process.env.OPENROD_CLOUD_ORIGIN||'https://localhost').host,timeoutMs=5000,requestProbe=http.request}={}) {
|
|
31
32
|
const request={method:'GET',url:'/api/os/overview'}
|
|
32
33
|
return new Promise(resolve=>{
|
|
33
34
|
let settled=false,deadline
|
|
34
35
|
const finish=ready=>{if(settled)return;settled=true;clearTimeout(deadline);resolve(ready)}
|
|
35
|
-
const probe=requestProbe({hostname:address,port,path:request.url,method:request.method,headers:{host,
|
|
36
|
+
const probe=requestProbe({hostname:address,port,path:request.url,method:request.method,headers:{host,[workerAuthHeader(protocol)]:signWorkerRequest(record.key,{uid:record.uid,expires:Date.now()+60000},request)}},response=>{response.resume();finish(response.statusCode===200)})
|
|
36
37
|
// Socket inactivity does not include DNS or a pending connection. Bound the
|
|
37
38
|
// entire probe so an unreachable worker cannot hold a provisioning lease.
|
|
38
39
|
deadline=setTimeout(()=>{finish(false);probe.destroy()},timeoutMs)
|
|
@@ -42,23 +43,36 @@ export function workerReady(record,address,{port=4600,host=new URL(process.env.O
|
|
|
42
43
|
probe.end()
|
|
43
44
|
})
|
|
44
45
|
}
|
|
45
|
-
export function createMachineManager(store,compute,{ready=workerReady}={}) {
|
|
46
|
+
export function createMachineManager(store,compute,{ready=workerReady,ownerLabel='openrod_owner',allowProvisioning=true}={}) {
|
|
46
47
|
const inflight=new Map(),readyCache=new Map()
|
|
47
|
-
async function ensure(identity) {
|
|
48
|
+
async function ensure(identity,{provision=true}={}) {
|
|
48
49
|
const cached=readyCache.get(identity.uid)
|
|
49
50
|
if(cached && cached.until>Date.now())return cached.record
|
|
50
|
-
|
|
51
|
+
const pending=inflight.get(identity.uid)
|
|
52
|
+
if(pending){
|
|
53
|
+
const result=await pending.task
|
|
54
|
+
// An explicit prepare arriving during a read-only inspection must retain
|
|
55
|
+
// its intent. Retry after that inspection releases the per-owner lease.
|
|
56
|
+
if(provision&&!pending.provision){if(inflight.get(identity.uid)===pending)inflight.delete(identity.uid);return ensure(identity,{provision:true})}
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
51
59
|
const task=(async()=>{
|
|
52
|
-
const
|
|
60
|
+
const existing=await store.get(identity.uid)
|
|
61
|
+
if(!existing&&provision&&!allowProvisioning)throw fail('New cloud environments are temporarily unavailable during deployment.')
|
|
62
|
+
const record=existing??(provision?await store.reserve(identity):null)
|
|
63
|
+
if(!record)return null
|
|
64
|
+
if(record.uid!==identity.uid)throw fail('Machine ownership mismatch',403)
|
|
53
65
|
if(!await store.lease(identity.uid))return record
|
|
54
66
|
try{
|
|
55
67
|
const instance=await compute.get(record.name)
|
|
56
68
|
if(!instance) {
|
|
57
69
|
if(record.everReady)throw fail('Your machine is missing. Contact your OpenRod administrator.')
|
|
70
|
+
if(!provision)return record
|
|
71
|
+
if(!allowProvisioning)throw fail('Cloud provisioning is temporarily unavailable during deployment.')
|
|
58
72
|
await compute.create(record)
|
|
59
73
|
await store.update(identity.uid,{state:'provisioning',error:null})
|
|
60
74
|
}else {
|
|
61
|
-
if(instance.labels?.
|
|
75
|
+
if(instance.labels?.[ownerLabel]!==workerOwnerHash(record.uid))throw fail('Machine ownership mismatch',403)
|
|
62
76
|
const address=instance.networkInterfaces?.[0]?.networkIP
|
|
63
77
|
if(address&&!/^10\.80\.0\.\d{1,3}$/.test(address))throw fail('Unexpected worker address')
|
|
64
78
|
const isReady=instance.status==='RUNNING'&&address&&await ready(record,address)
|
|
@@ -71,8 +85,9 @@ export function createMachineManager(store,compute,{ready=workerReady}={}) {
|
|
|
71
85
|
if(result.state==='ready')readyCache.set(identity.uid,{record:result,until:Date.now()+30000})
|
|
72
86
|
return result
|
|
73
87
|
})()
|
|
74
|
-
inflight.set(identity.uid,
|
|
75
|
-
try{return await task}finally{inflight.delete(identity.uid)}
|
|
88
|
+
const entry={task,provision};inflight.set(identity.uid,entry)
|
|
89
|
+
try{return await task}finally{if(inflight.get(identity.uid)===entry)inflight.delete(identity.uid)}
|
|
76
90
|
}
|
|
77
|
-
|
|
91
|
+
const publicStatus=r=>({name:r?.name??null,status:r?.state??'none',error:r?.error??null})
|
|
92
|
+
return {store,ensure,async status(identity){return publicStatus(await ensure(identity,{provision:false}))},async prepare(identity){return publicStatus(await ensure(identity))},async target(identity,{provision=true}={}){const r=await ensure(identity,{provision});if(!r)throw fail('Prepare your cloud environment before using this resource.',409);if(r.state!=='ready'||!r.address)throw fail('Your cloud machine is starting. Please try again shortly.');return r}}
|
|
78
93
|
}
|
|
@@ -61,6 +61,7 @@ export function createTemplateStore({ directory, builtins, legacy = [], validate
|
|
|
61
61
|
save: (input) => mutate(async () => {
|
|
62
62
|
const template = validate(input)
|
|
63
63
|
const current = await state()
|
|
64
|
+
if (input.isNew && (await list(current)).some(item => item.id === template.id)) throw fail('A policy with this id already exists. Pick another identity.', 409)
|
|
64
65
|
const catalog = (await list(current)).filter(item => item.id !== template.id).concat(template)
|
|
65
66
|
// An edited addition must also remain compatible with saved combinations.
|
|
66
67
|
for (const item of catalog) {
|
package/server/remote-gateway.js
CHANGED
|
@@ -102,17 +102,19 @@ export function createRemoteConnections({ onSelected = () => {}, onDeselected =
|
|
|
102
102
|
const initial = contextSelection()
|
|
103
103
|
let returnContext = contextConfigured() && localGateways().some(target => target.name === initial.gateway) ? initial : null
|
|
104
104
|
let lastRemote = null, historyReady = null, historyWrites = Promise.resolve()
|
|
105
|
+
const forgottenHosts = new Set()
|
|
105
106
|
const historyFile = path.join(stateDirectory(), 'remote-gateways', 'last-location.json')
|
|
106
107
|
const validContext = value => value && /^[\w.-]{1,64}$/.test(value.gateway ?? '') && /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(value.workspace ?? '')
|
|
107
108
|
const loadHistory = () => historyReady ??= (async () => {
|
|
108
109
|
try {
|
|
109
110
|
const saved = JSON.parse(await fs.readFile(historyFile, 'utf8'))
|
|
111
|
+
for (const host of Array.isArray(saved.forgottenHosts) ? saved.forgottenHosts : []) if (typeof host === 'string') forgottenHosts.add(host)
|
|
110
112
|
if (!returnContext && validContext(saved.returnContext) && localGateways().some(target => target.name === saved.returnContext.gateway)) returnContext = saved.returnContext
|
|
111
|
-
if (validContext(saved.remote) && /^console-ssh-[a-f0-9]{24}$/.test(saved.remote.gateway) && typeof saved.remote.host === 'string') lastRemote = { ...saved.remote, status: 'disconnected', error: null }
|
|
113
|
+
if (validContext(saved.remote) && /^console-ssh-[a-f0-9]{24}$/.test(saved.remote.gateway) && typeof saved.remote.host === 'string' && !forgottenHosts.has(saved.remote.host)) lastRemote = { ...saved.remote, status: 'disconnected', error: null }
|
|
112
114
|
} catch (error) { if (error.code !== 'ENOENT') logger.warn(`Remote location history unavailable: ${error.message}`) }
|
|
113
115
|
})()
|
|
114
116
|
const saveHistory = () => {
|
|
115
|
-
const data = JSON.stringify({ returnContext, remote: lastRemote })
|
|
117
|
+
const data = JSON.stringify({ returnContext, remote: lastRemote, forgottenHosts: [...forgottenHosts] })
|
|
116
118
|
historyWrites = historyWrites.catch(() => {}).then(async () => {
|
|
117
119
|
await fs.mkdir(path.dirname(historyFile), { recursive: true, mode: 0o700 })
|
|
118
120
|
const temporary = `${historyFile}.${randomUUID()}.tmp`
|
|
@@ -121,6 +123,12 @@ export function createRemoteConnections({ onSelected = () => {}, onDeselected =
|
|
|
121
123
|
})
|
|
122
124
|
return historyWrites.catch(error => logger.warn(`Remote location history could not be saved: ${error.message}`))
|
|
123
125
|
}
|
|
126
|
+
// Adding, deleting or connecting to a host again undoes Forget for it, so
|
|
127
|
+
// its alias is never hidden for good.
|
|
128
|
+
const unforgetHost = async host => {
|
|
129
|
+
await loadHistory()
|
|
130
|
+
if (forgottenHosts.delete(host)) await saveHistory()
|
|
131
|
+
}
|
|
124
132
|
async function locationSnapshot() {
|
|
125
133
|
await loadHistory()
|
|
126
134
|
const selected = defaultContextSelection()
|
|
@@ -134,7 +142,9 @@ export function createRemoteConnections({ onSelected = () => {}, onDeselected =
|
|
|
134
142
|
if (!remote && /^console-ssh-[a-f0-9]{24}$/.test(selected.gateway)) {
|
|
135
143
|
try {
|
|
136
144
|
const owner = JSON.parse(await fs.readFile(path.join(CONFIG_DIR, 'gateways', selected.gateway, 'console-managed.json'), 'utf8'))
|
|
137
|
-
|
|
145
|
+
// Deleting a host undoes Forget but leaves its gateway registered, so
|
|
146
|
+
// a registration is retained only while its host still exists.
|
|
147
|
+
if (typeof owner.host === 'string' && !forgottenHosts.has(owner.host) && listSshHosts().some(host => host.name === owner.host)) remote = { host: owner.host, ...selected, status: 'disconnected', error: null }
|
|
138
148
|
} catch { /* No managed registration to retain. */ }
|
|
139
149
|
}
|
|
140
150
|
if (!returnContext && (remote || contextConfigured())) {
|
|
@@ -292,6 +302,7 @@ export function createRemoteConnections({ onSelected = () => {}, onDeselected =
|
|
|
292
302
|
return
|
|
293
303
|
}
|
|
294
304
|
if (!(await listSshHosts()).some(host => host.name === input.host)) throw fail('Choose a host from your SSH configuration.')
|
|
305
|
+
await unforgetHost(input.host)
|
|
295
306
|
const installedVersion = await version(value)
|
|
296
307
|
value.probe = await probeHost(input.host, installedVersion, { signal })
|
|
297
308
|
await prepareRuntime(value)
|
|
@@ -319,7 +330,7 @@ export function createRemoteConnections({ onSelected = () => {}, onDeselected =
|
|
|
319
330
|
async overview() {
|
|
320
331
|
const snapshot = await locationSnapshot()
|
|
321
332
|
const connection = snapshot.remote
|
|
322
|
-
return { hosts: await listSshHosts(), locals: localGateways().map(({ name, endpoint }) => ({ name, endpoint })), active: view(connection), job: view(job), tools: { ssh: Boolean(sshBinary()), gateway: Boolean(findExecutable('openshell-gateway')), openshell: Boolean(openshellBinary()), installCommand: installCommand(process.platform) } }
|
|
333
|
+
return { hosts: (await listSshHosts()).filter(host => !forgottenHosts.has(host.name)), forgottenHosts: [...forgottenHosts], locals: localGateways().map(({ name, endpoint }) => ({ name, endpoint })), active: view(connection), job: view(job), tools: { ssh: Boolean(sshBinary()), gateway: Boolean(findExecutable('openshell-gateway')), openshell: Boolean(openshellBinary()), installCommand: installCommand(process.platform) } }
|
|
323
334
|
},
|
|
324
335
|
changing: () => disconnecting || job?.status === 'working',
|
|
325
336
|
// A fresh console connects to the CLI's local gateway the way "Use this
|
|
@@ -378,15 +389,24 @@ export function createRemoteConnections({ onSelected = () => {}, onDeselected =
|
|
|
378
389
|
async forget() {
|
|
379
390
|
if (disconnecting || job?.status === 'working') throw fail('Wait for the current connection change to finish.', 409)
|
|
380
391
|
await loadHistory()
|
|
381
|
-
|
|
392
|
+
const remote = (await locationSnapshot()).remote
|
|
393
|
+
if (remote || active?.status === 'connected' || active?.status === 'connecting') await this.disconnect()
|
|
394
|
+
if (remote) forgottenHosts.add(remote.host)
|
|
382
395
|
active = null
|
|
383
396
|
lastRemote = null
|
|
384
397
|
await saveHistory()
|
|
385
398
|
return { ok: true }
|
|
386
399
|
},
|
|
400
|
+
async restoreHost(host) {
|
|
401
|
+
await loadHistory()
|
|
402
|
+
if (typeof host !== 'string' || !forgottenHosts.has(host)) throw fail('This host is not forgotten.', 409)
|
|
403
|
+
await unforgetHost(host)
|
|
404
|
+
return { ok: true }
|
|
405
|
+
},
|
|
406
|
+
unforgetHost,
|
|
387
407
|
async disconnect() {
|
|
388
408
|
idle()
|
|
389
|
-
const current =
|
|
409
|
+
const current = defaultContextSelection()
|
|
390
410
|
const replaceSelection = current.gateway === active?.gateway || /^console-ssh-[a-f0-9]{24}$/.test(current.gateway)
|
|
391
411
|
if (replaceSelection && process.env.OPENSHELL_GATEWAY) throw fail('Remove OPENSHELL_GATEWAY before disconnecting its selected gateway.', 409)
|
|
392
412
|
disconnecting = true
|
package/server/remote-http.js
CHANGED
|
@@ -9,6 +9,6 @@ export function safeWorkspaceTarget(target,prefix){
|
|
|
9
9
|
if(typeof target!=='string'||!target.startsWith(prefix+'/'))throw remoteFail('Invalid remote target',404)
|
|
10
10
|
const suffix=target.slice(prefix.length),path=suffix.split('?')[0]
|
|
11
11
|
if(/%2f|%5c|%2e|\\|\/\//i.test(path)||path.split('/').some(p=>p==='.'||p==='..'))throw remoteFail('Invalid remote target')
|
|
12
|
-
if(!/^\/(context|inventory|overview|sandboxes|activity|activity-destinations|setups|cloud-export|cloud-import|cloud-transfer|policy|settings|secrets|profiles|templates|image-templates|org|egress|files|downloads|ingress|stream|editors|local-folder|terminal|ssh)(?:\/|$)/.test(path))throw remoteFail('Unknown remote operation',404)
|
|
12
|
+
if(!/^\/(capabilities|resource-imports|context|inventory|overview|sandboxes|activity|activity-destinations|setups|cloud-export|cloud-import|cloud-transfer|policy|settings|secrets|profiles|templates|image-templates|org|egress|files|downloads|ingress|stream|editors|local-folder|terminal|ssh)(?:\/|$)/.test(path))throw remoteFail('Unknown remote operation',404)
|
|
13
13
|
return '/api/os'+suffix
|
|
14
14
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { newRecipe, recipeErrors } from '../src/lib/image-templates.js'
|
|
3
|
+
import { validateTemplate } from './policy.js'
|
|
4
|
+
import { normalizeMcp } from './setup-discovery.js'
|
|
5
|
+
import { validatePolicy } from './egress.js'
|
|
6
|
+
|
|
7
|
+
export const IMPORT_TYPES = ['policyTemplates', 'groups', 'network', 'setups', 'templates', 'activity']
|
|
8
|
+
export const IMPORT_BODY_LIMIT = 8 * 1024 * 1024
|
|
9
|
+
export const importFail = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
10
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
|
|
11
|
+
export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
|
|
12
|
+
const sensitive = /(?:-----BEGIN [\w ]*PRIVATE KEY-----|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16})|(?:api[_-]?key|token|password|secret|authorization)["']?\s*[:=]\s*["']?(?!\$|<|\{|YOUR_|your_|example|placeholder|process\.env|os\.environ)[A-Za-z0-9_+/.=-]{16,})/i
|
|
13
|
+
const cleanText = (value, length = 400) => {
|
|
14
|
+
if (typeof value !== 'string' || value.length > length || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value) || sensitive.test(value)) throw importFail('Resource contains unsupported text or possible credentials. Remove it before importing.')
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
const idPatterns = { policyTemplates: /^[a-z0-9][a-z0-9-]{0,47}$/, groups: /^[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$/, network: /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/, setups: /^[a-f0-9]{24}$/, templates: /^[a-z0-9]([a-z0-9-]{0,17}[a-z0-9])?$/, activity: /^history-[a-f0-9]{24}$/ }
|
|
18
|
+
export const validImportId = (type, id) => typeof id === 'string' && Boolean(idPatterns[type]?.test(id))
|
|
19
|
+
|
|
20
|
+
function setupItems(items, warnings) {
|
|
21
|
+
if (!Array.isArray(items) || items.length > 500) throw importFail('Invalid setup items.')
|
|
22
|
+
return items.map((item, index) => {
|
|
23
|
+
const name = cleanText(item.name, 200)
|
|
24
|
+
const id = fingerprint({ kind: item.kind, name, index }).slice(0, 24)
|
|
25
|
+
if (item.kind === 'mcp') {
|
|
26
|
+
const credentialFields = [...new Set([...(item.credentialFields || []), ...Object.keys(item.credentialRef?.aliases || {}), ...Object.keys(item.credentialBindings || {})])].filter(key => /^[A-Za-z_][A-Za-z0-9_]{0,100}$/.test(key))
|
|
27
|
+
const raw = item.package ? { command: 'npx', args: [`${item.package.name}@${item.package.requested || item.package.version || 'latest'}`, ...(item.package.args || [])] } : { ...(item.config || {}) }
|
|
28
|
+
// Environment/header values and source provider references never cross locations.
|
|
29
|
+
if (Object.keys(raw.env || {}).length || Object.keys(raw.headers || {}).length || item.environment) warnings.push(`${name}: environment and HTTP header values are excluded; configure required values on the destination.`)
|
|
30
|
+
delete raw.env; delete raw.headers; delete raw.http_headers
|
|
31
|
+
const normalized = normalizeMcp(name, raw, 'import')
|
|
32
|
+
delete normalized._sourceCredentials
|
|
33
|
+
normalized.credentialFields = [...new Set([...normalized.credentialFields, ...credentialFields])]
|
|
34
|
+
normalized.credentialBindings = Object.fromEntries(Object.entries(item.credentialBindings || {}).filter(([key, binding]) => normalized.credentialFields.includes(key) && /^[A-Za-z0-9_-]{1,80}$/.test(binding?.header || '') && ['', 'Bearer '].includes(binding.prefix || '')).map(([key, binding]) => [key, { header: binding.header, prefix: binding.prefix || '' }]))
|
|
35
|
+
if (normalized.credentialFields.length) {
|
|
36
|
+
normalized.issues = [...new Set([...normalized.issues, `Connect credentials (${normalized.credentialFields.join(', ')}) to use this MCP. They’re kept in the gateway, never in the setup.`])]
|
|
37
|
+
warnings.push(`${name}: reconnect credentials on the destination.`)
|
|
38
|
+
}
|
|
39
|
+
if (item.auth?.mode === 'agent-session') { normalized.auth = { mode: 'agent-session', status: 'sign-in-required' }; warnings.push(`${name}: sign in again on the destination.`) }
|
|
40
|
+
if (normalized.package) warnings.push(`${name}: download and prepare this package on the destination before use.`)
|
|
41
|
+
return { ...normalized, id, ...(item.disabled ? { disabled: true } : {}) }
|
|
42
|
+
}
|
|
43
|
+
if (item.kind !== 'skill') throw importFail('Unsupported setup item.')
|
|
44
|
+
if (!Array.isArray(item.files) || item.files.length > 200) throw importFail('Invalid skill files.')
|
|
45
|
+
const files = [], seen = new Set()
|
|
46
|
+
let bytes = 0
|
|
47
|
+
for (const file of item.files) {
|
|
48
|
+
if (typeof file.path !== 'string' || file.path.length > 200 || !/^[a-zA-Z0-9_ ./@+-]+$/.test(file.path) || file.path.split('/').some(part => !part || part === '.' || part === '..' || part.startsWith('.')) || seen.has(file.path)) throw importFail('Invalid or duplicate skill file path.')
|
|
49
|
+
seen.add(file.path)
|
|
50
|
+
if (file.encoding) { warnings.push(`${name}: binary/archive file ${file.path} is excluded; re-import it on the destination.`); continue }
|
|
51
|
+
const content = cleanText(file.content, 512 * 1024)
|
|
52
|
+
bytes += Buffer.byteLength(content)
|
|
53
|
+
if (bytes > 4 * 1024 * 1024) throw importFail('Skill exceeds the 4 MiB limit.', 413)
|
|
54
|
+
files.push({ path: file.path, content, executable: file.executable === true })
|
|
55
|
+
}
|
|
56
|
+
if (!files.some(file => file.path === 'SKILL.md')) throw importFail('Imported skills require SKILL.md.')
|
|
57
|
+
return { id, kind: 'skill', name, sources: ['import'], files, bytes, digest: fingerprint(files), requirements: [], credentialFields: [], issues: [] }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Both exports and incoming bundles pass through the same allow-list. Never trust
|
|
62
|
+
// the browser to preserve redaction or source schemas.
|
|
63
|
+
export function portableResource(type, raw) {
|
|
64
|
+
if (!IMPORT_TYPES.includes(type) || !raw || typeof raw !== 'object') throw importFail('Unsupported import resource.')
|
|
65
|
+
const id = raw.id ?? raw.name
|
|
66
|
+
if (!validImportId(type, id)) throw importFail(`Invalid ${type} resource identity.`)
|
|
67
|
+
const warnings = [], dependencies = []
|
|
68
|
+
let data
|
|
69
|
+
if (type === 'policyTemplates') {
|
|
70
|
+
if (raw.accessTemplates?.length) throw importFail('Base policy additions must be materialized before import.')
|
|
71
|
+
const template = validateTemplate(raw)
|
|
72
|
+
const select = (value, keys) => Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]))
|
|
73
|
+
const rules = template.rules.map(rule => ({
|
|
74
|
+
name: rule.name, binaries: rule.binaries,
|
|
75
|
+
endpoints: rule.endpoints.map(endpoint => ({
|
|
76
|
+
...select(endpoint, ['host', 'ports', 'protocol', 'access', 'enforcement', 'tlsSkip', 'allowedIps']),
|
|
77
|
+
...(endpoint.allow ? { allow: endpoint.allow.map(entry => select(entry, ['method', 'path'])) } : {}),
|
|
78
|
+
...(endpoint.deny ? { deny: endpoint.deny.map(entry => select(entry, ['method', 'path'])) } : {}),
|
|
79
|
+
})),
|
|
80
|
+
}))
|
|
81
|
+
data = validateTemplate({ ...template, rules, accessTemplates: [] })
|
|
82
|
+
cleanText(JSON.stringify(data), 256 * 1024)
|
|
83
|
+
warnings.push('The source base policy and its access additions are copied into a separate destination policy; existing destination policies are unchanged.')
|
|
84
|
+
} else if (type === 'groups') {
|
|
85
|
+
const template = raw.template ? String(raw.template) : null
|
|
86
|
+
if (template && !validImportId('policyTemplates', template)) throw importFail('Invalid group base policy.')
|
|
87
|
+
if (template) dependencies.push(`policyTemplates:${template}`)
|
|
88
|
+
data = { id, name: cleanText(raw.name || id, 80), description: cleanText(raw.description || ''), template, outside: 'block' }
|
|
89
|
+
} else if (type === 'network') {
|
|
90
|
+
data = validatePolicy(raw)
|
|
91
|
+
if (data.appliesTo.everyone || data.appliesTo.sandboxes.length) throw importFail('Legacy global or sandbox-specific network rules require destination group selection before import.')
|
|
92
|
+
data.name = cleanText(data.name, 80)
|
|
93
|
+
cleanText(JSON.stringify(data), 128 * 1024)
|
|
94
|
+
if (data.advanced?.privateIps.length) warnings.push('This rule includes private addresses. Verify that they refer to the intended cloud services.')
|
|
95
|
+
dependencies.push(...data.appliesTo.groups.map(id => `groups:${id}`), ...data.appliesTo.setups.map(id => `setups:${id}`))
|
|
96
|
+
if (data.setup) dependencies.push(`setups:${data.setup.id}`)
|
|
97
|
+
} else if (type === 'setups') {
|
|
98
|
+
data = { id, name: cleanText(raw.name, 80), items: setupItems(raw.items, warnings) }
|
|
99
|
+
} else if (type === 'activity') {
|
|
100
|
+
if (!Array.isArray(raw.events) || raw.events.length > 500) throw importFail('Activity imports support up to 500 events per batch.')
|
|
101
|
+
const origin = { gateway: cleanText(raw.origin?.gateway || '', 160), workspace: cleanText(raw.origin?.workspace || '', 160) }
|
|
102
|
+
const seen = new Set()
|
|
103
|
+
const fields = ['sandbox', 'sandboxId', 'binary', 'category', 'severity', 'level', 'kind', 'direction', 'verdict', 'outcome', 'action', 'method', 'destination', 'policy', 'sessionId', 'correlationId', 'reason', 'message', 'detail']
|
|
104
|
+
const events = raw.events.map(event => {
|
|
105
|
+
if (!/^[a-f0-9]{64}$/.test(event.id || '') || seen.has(event.id)) throw importFail('Invalid or duplicate activity event identity.')
|
|
106
|
+
if (!Number.isFinite(Date.parse(event.at || event.receivedAt))) throw importFail('Activity events require a valid original timestamp.')
|
|
107
|
+
seen.add(event.id)
|
|
108
|
+
const safe = { id: event.id, at: new Date(Date.parse(event.at || event.receivedAt)).toISOString() }
|
|
109
|
+
if (event.receivedAt && Number.isFinite(Date.parse(event.receivedAt))) safe.receivedAt = new Date(Date.parse(event.receivedAt)).toISOString()
|
|
110
|
+
for (const field of fields) if (typeof event[field] === 'string') {
|
|
111
|
+
const text = event[field].slice(0, field === 'message' || field === 'detail' ? 4096 : 512)
|
|
112
|
+
safe[field] = sensitive.test(text) || /(?:Bearer\s+|[?&](?:token|api[_-]?key|password|secret)=)/i.test(text) ? '[redacted]' : text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
113
|
+
}
|
|
114
|
+
safe.sandbox ||= 'imported-history'
|
|
115
|
+
return safe
|
|
116
|
+
})
|
|
117
|
+
data = { id, name: cleanText(raw.name, 80), origin, events }
|
|
118
|
+
warnings.push('Normalized event fields are copied with original timestamps and source identity. Raw event payloads are excluded and detected credential values are redacted.')
|
|
119
|
+
} else {
|
|
120
|
+
if (raw.status && raw.status !== 'ready') throw importFail('Wait for this template build to finish before exporting its recipe.')
|
|
121
|
+
const rawRecipe = raw.recipe || raw
|
|
122
|
+
const recipe = newRecipe(Object.fromEntries(Object.keys(newRecipe()).filter(key => key in rawRecipe).map(key => [key, rawRecipe[key]])))
|
|
123
|
+
recipe.name = id
|
|
124
|
+
if (recipe.environment.length) warnings.push('Template environment values are excluded. Re-enter required values on the destination.')
|
|
125
|
+
recipe.environment = []
|
|
126
|
+
recipe.setupRevisions = {}
|
|
127
|
+
if (Object.keys(recipeErrors(recipe)).length) throw importFail(Object.values(recipeErrors(recipe))[0])
|
|
128
|
+
cleanText(JSON.stringify(recipe), 16000)
|
|
129
|
+
if (recipe.source === 'image') {
|
|
130
|
+
if (recipe.image.startsWith('openshell-template/')) throw importFail('A local-only template image cannot be copied. Rebuild from its recipe on the destination.')
|
|
131
|
+
warnings.push('The destination must be able to pull this image; registry credentials are not copied.')
|
|
132
|
+
} else warnings.push('The template will be rebuilt on the destination; build commands may download dependencies.')
|
|
133
|
+
dependencies.push(...recipe.setups.map(id => `setups:${id}`))
|
|
134
|
+
data = { name: id, recipe }
|
|
135
|
+
}
|
|
136
|
+
return { key: `${type}:${id}`, type, id, name: type === 'templates' ? id : data.name, revision: fingerprint(data), data, dependencies: [...new Set(dependencies)], warnings: [...new Set(warnings)], ...(type === 'network' && raw.sourceOrganizationBlock === true && data.action === 'block' ? { requiredForGroups: [...data.appliesTo.groups] } : {}) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function validateImportBundle(bundle) {
|
|
140
|
+
if (bundle?.version !== 1 || !Array.isArray(bundle.resources) || bundle.resources.length > 500 || Buffer.byteLength(JSON.stringify(bundle)) > IMPORT_BODY_LIMIT) throw importFail('Invalid or oversized configuration bundle.', 400)
|
|
141
|
+
const seen = new Set()
|
|
142
|
+
const resources = bundle.resources.map(resource => {
|
|
143
|
+
const safe = portableResource(resource.type, resource.data)
|
|
144
|
+
if (seen.has(safe.key)) throw importFail('Duplicate import resource.')
|
|
145
|
+
seen.add(safe.key)
|
|
146
|
+
if (resource.requiredForGroups !== undefined) {
|
|
147
|
+
if (safe.type !== 'network' || safe.data.action !== 'block' || !Array.isArray(resource.requiredForGroups) || resource.requiredForGroups.some(id => !safe.data.appliesTo.groups.includes(id))) throw importFail('Invalid required organization restriction.')
|
|
148
|
+
safe.requiredForGroups = [...new Set(resource.requiredForGroups)]
|
|
149
|
+
}
|
|
150
|
+
safe.warnings = [...new Set([...safe.warnings, ...(resource.warnings || []).slice(0, 50).map(warning => cleanText(warning, 500))])]
|
|
151
|
+
return safe
|
|
152
|
+
})
|
|
153
|
+
const context = bundle.source?.context || {}
|
|
154
|
+
return { version: 1, source: { kind: ['local', 'host', 'cloud'].includes(bundle.source?.kind) ? bundle.source.kind : 'host', label: cleanText(bundle.source?.label || 'Source', 160), context: { gateway: cleanText(context.gateway || '', 160), workspace: cleanText(context.workspace || '', 160) } }, resources }
|
|
155
|
+
}
|