openrod 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1 -2
  2. package/THIRD_PARTY_NOTICES.md +84 -1
  3. package/dist/assets/index-DBqg9X9x.js +553 -0
  4. package/dist/assets/index-HZ0q_FBv.css +1 -0
  5. package/dist/assets/index.esm-BqjC2fhp.js +376 -0
  6. package/dist/assets/index.esm-Cj6nXjOx.js +16 -0
  7. package/dist/assets/index.esm-Z1y-oluO.js +1666 -0
  8. package/dist/assets/terminal-view-D-SQcdM5.js +40 -0
  9. package/dist/index.html +3 -3
  10. package/package.json +8 -1
  11. package/server/announcements.js +22 -0
  12. package/server/api.js +54 -11
  13. package/server/capabilities.js +13 -0
  14. package/server/cli.js +18 -2
  15. package/server/cloud-deployment.js +29 -0
  16. package/server/cloud-proxy.js +19 -9
  17. package/server/cloud-runtime.js +72 -0
  18. package/server/cloud-ssh-proxy.cjs +22 -4
  19. package/server/cloud-transfer.js +1 -1
  20. package/server/compute.js +18 -7
  21. package/server/image-templates.js +17 -0
  22. package/server/ingress.js +3 -0
  23. package/server/local-cloud-native.js +7 -1
  24. package/server/local-cloud.js +3 -3
  25. package/server/machines.js +33 -18
  26. package/server/policy-template-store.js +1 -0
  27. package/server/remote-gateway.js +26 -6
  28. package/server/remote-http.js +1 -1
  29. package/server/resource-import-portable.js +155 -0
  30. package/server/resource-imports.js +412 -0
  31. package/server/sandbox-identity.js +1 -1
  32. package/server/security.js +30 -14
  33. package/server/setup-discovery.js +2 -1
  34. package/server/setup-packages.js +138 -59
  35. package/server/setup-preparation.js +4 -3
  36. package/server/start.js +29 -7
  37. package/server/worker-auth.js +2 -2
  38. package/shared/agent-access.js +26 -4
  39. package/shared/analytics-release.json +5 -0
  40. package/src/lib/activity-inventory.js +1 -1
  41. package/src/lib/analytics.js +334 -0
  42. package/src/lib/api.js +28 -11
  43. package/src/lib/cloud-sign-in-error.js +13 -0
  44. package/src/lib/cloud-transfer.js +1 -1
  45. package/src/lib/combined-activity.js +95 -0
  46. package/src/lib/connection-host.js +6 -0
  47. package/src/lib/egress-inventory.js +63 -0
  48. package/src/lib/local-cloud.js +16 -3
  49. package/src/lib/location-choice.js +7 -0
  50. package/src/lib/location-data-reader.js +25 -0
  51. package/src/lib/locations.js +18 -8
  52. package/src/lib/placement.js +1 -1
  53. package/src/lib/resource-imports.js +63 -0
  54. package/src/lib/resource-sources.js +105 -0
  55. package/src/lib/sandbox-creations.js +14 -9
  56. package/src/lib/sandboxes.js +5 -1
  57. package/src/lib/working-location.js +10 -0
  58. package/dist/assets/index-89TNohi_.css +0 -1
  59. package/dist/assets/index-DoKK2G9s.js +0 -545
  60. package/dist/assets/terminal-view-DzqQnmh2.js +0 -40
package/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')
@@ -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==='GET'){const current=assertOwner(req),machine=await call('/machine');if(grant!==current)throw remoteFail('Cloud connection changed. Try again.',401);return responseJson(res,200,{...status(),machine})}
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')
@@ -1,14 +1,15 @@
1
1
  import http from 'node:http'
2
- import { createHash, randomBytes, randomUUID } from 'node:crypto'
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
- if(typeof uid!=='string'||!uid||uid.length>128)throw fail('Invalid account',400)
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(!/^openrod-user-[a-f0-9]{24}$/.test(name))return null;const r=await db.collection('machines').doc(name).get();return r.exists?r.data():null},
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,'x-openrod-worker-auth':signWorkerRequest(record.key,{uid:record.uid,expires:Date.now()+60000},request)}},response=>{response.resume();finish(response.statusCode===200)})
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
- if(inflight.has(identity.uid))return inflight.get(identity.uid)
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 record=await store.reserve(identity)
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?.openrod_owner!==record.name.slice(9))throw fail('Machine ownership mismatch',403)
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,task)
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
- return {store,ensure,async status(identity){const r=await ensure(identity);return {name:r.name,status:r.state,error:r.error??null}},async target(identity){const r=await ensure(identity);if(r.state!=='ready'||!r.address)throw fail('Your cloud machine is starting. Please try again shortly.');return r}}
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) {
@@ -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
- if (typeof owner.host === 'string') remote = { host: owner.host, ...selected, status: 'disconnected', error: null }
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
- if (active?.status === 'connected' || active?.status === 'connecting') await this.disconnect()
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 = contextSelection()
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
@@ -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
+ }