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
@@ -0,0 +1,412 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { contextKey, contextSelection, runWithContext, resolveGateway } from './gateway.js'
5
+ import { scopedStateDirectory } from './paths.js'
6
+ import { listGroups, orgRoute, readOrg } from './org.js'
7
+ import { listPolicies } from './egress.js'
8
+ import { findTemplate, listTemplates, validateTemplate, policyRoute } from './policy.js'
9
+ import { composeTemplate } from '../shared/policy-templates.js'
10
+ import { getSetupStore } from './setups.js'
11
+ import { listImageTemplates, imageTemplateRoute } from './image-templates.js'
12
+ import { IMPORT_TYPES, IMPORT_BODY_LIMIT, fingerprint, importFail, portableResource, validateImportBundle, validImportId } from './resource-import-portable.js'
13
+
14
+ export { IMPORT_BODY_LIMIT } from './resource-import-portable.js'
15
+ export const importCapabilities = ({ activity = false } = {}) => ({
16
+ version: 1,
17
+ types: {
18
+ policyTemplates: { supported: true, internal: true, description: 'Materialized base security policies included as group dependencies.' },
19
+ groups: { supported: true, description: 'Group definitions; sandbox memberships are not copied.' },
20
+ network: { supported: true, description: 'Group/setup rules with remapped targets. Global and sandbox-specific rules need manual review.' },
21
+ setups: { supported: true, description: 'Saved MCP definitions and text skill files. Packages must be prepared and credentials reconnected.' },
22
+ templates: { supported: true, description: 'Image references or recipes rebuilt on the destination. Environment values are excluded.' },
23
+ sandboxes: { supported: false, reason: 'Use Copy workspace on a running sandbox. Configuration import does not move workspace files.' },
24
+ secrets: { supported: false, reason: 'Reconnect or add credentials on the destination. Secret values are never exported in configuration bundles.' },
25
+ activity: activity ? { supported: true, description: 'Normalized historical events, up to 500 per reviewed batch, preserving original timestamps and source identity.' } : { supported: false, reason: 'The activity store is unavailable at this location.' },
26
+ },
27
+ limits: { bytes: IMPORT_BODY_LIMIT, resources: 500, activityEvents: 500, activeJobs: 2 }, conflicts: ['create', 'reuse'],
28
+ })
29
+ const running = globalThis[Symbol.for('openrod.resource-import.running.v1')] ??= new Map()
30
+ const queues = globalThis[Symbol.for('openrod.resource-import.queues.v1')] ??= new Map()
31
+ const phases = { policyTemplates: -1, groups: 0, setups: 1, network: 2, templates: 3, activity: 4 }
32
+ const terminal = new Set(['completed', 'partial', 'failed', 'cancelled', 'interrupted'])
33
+ const JOB_ID = /^[a-f0-9]{32}$/
34
+ const now = () => new Date().toISOString()
35
+
36
+ function queue(key, task) {
37
+ const result = (queues.get(key) || Promise.resolve()).then(task, task)
38
+ const settled = result.catch(() => {})
39
+ queues.set(key, settled)
40
+ settled.finally(() => { if (queues.get(key) === settled) queues.delete(key) })
41
+ return result
42
+ }
43
+ async function atomic(file, value) {
44
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
45
+ const temporary = `${file}.${randomUUID()}.tmp`
46
+ try { await fs.writeFile(temporary, JSON.stringify(value), { flag: 'wx', mode: 0o600 }); await fs.rename(temporary, file) }
47
+ finally { await fs.rm(temporary, { force: true }) }
48
+ }
49
+ function jobView(job) {
50
+ return {
51
+ id: job.id, status: job.status, source: job.source, destination: job.destination, createdAt: job.createdAt, updatedAt: job.updatedAt,
52
+ warnings: job.warnings, cancelRequested: Boolean(job.cancelRequested),
53
+ items: job.items.map(({ data, reuseRevision, attempted, ...item }) => item),
54
+ counts: { total: job.items.length, completed: job.items.filter(item => ['created', 'reused'].includes(item.status)).length, failed: job.items.filter(item => ['failed', 'blocked'].includes(item.status)).length, pending: job.items.filter(item => ['pending', 'running'].includes(item.status)).length },
55
+ }
56
+ }
57
+ function translated(resource, mapping, targetId) {
58
+ const data = structuredClone(resource.data)
59
+ if (resource.type === 'templates') { data.name = targetId; data.recipe.name = targetId; data.recipe.setups = data.recipe.setups.map(id => mapping[`setups:${id}`]); data.recipe.setupRevisions = {} }
60
+ else {
61
+ data.id = targetId
62
+ if (resource.type === 'groups' && data.template) data.template = mapping[`policyTemplates:${data.template}`]
63
+ if (resource.type === 'network') {
64
+ data.appliesTo.groups = data.appliesTo.groups.map(id => mapping[`groups:${id}`])
65
+ data.appliesTo.setups = data.appliesTo.setups.map(id => mapping[`setups:${id}`])
66
+ if (data.setup) data.setup.id = mapping[`setups:${data.setup.id}`]
67
+ }
68
+ }
69
+ return data
70
+ }
71
+ function comparable(type, value) {
72
+ const { data } = portableResource(type, value)
73
+ // Destination preparation is independent from identity mapping; source ids and
74
+ // template revision pins are not content differences.
75
+ if (type === 'templates') data.recipe.setupRevisions = {}
76
+ return fingerprint(data)
77
+ }
78
+
79
+ function defaultAdapter({ activityStore } = {}) {
80
+ return {
81
+ async list(type) {
82
+ if (type === 'policyTemplates') return listTemplates()
83
+ if (type === 'activity') return activityStore ? activityStore.coverage().sources.filter(source => source.importBatch).map(source => ({ ...source, importReceipt: true })) : []
84
+ if (type === 'groups') return listGroups()
85
+ if (type === 'network') return listPolicies()
86
+ if (type === 'templates') return listImageTemplates()
87
+ const store = getSetupStore()
88
+ return Promise.all((await store.list()).map(setup => store.get(setup.id)))
89
+ },
90
+ async organizationRules(context) {
91
+ const [organization, groups] = await Promise.all([readOrg(), listGroups()])
92
+ const rules = []
93
+ for (const group of groups) for (let offset = 0; offset < organization.blocked.length; offset += 200) rules.push({
94
+ id: 'org-block-' + fingerprint([context, group.id, offset]).slice(0, 28), name: `Source restrictions: ${group.name}`.slice(0, 80), action: 'block',
95
+ destinations: organization.blocked.slice(offset, offset + 200), appliesTo: { everyone: false, groups: [group.id], sandboxes: [], setups: [] }, sourceOrganizationBlock: true,
96
+ })
97
+ return rules
98
+ },
99
+ async export(type, input, context) {
100
+ if (type === 'policyTemplates') {
101
+ const catalog = await listTemplates()
102
+ for (const group of await listGroups()) if (group.template && !catalog.some(policy => policy.id === group.template)) {
103
+ const policy = await findTemplate(group.template)
104
+ if (policy) catalog.push(policy)
105
+ }
106
+ return catalog.map(policy => validateTemplate({ ...composeTemplate(policy, [], catalog), accessTemplates: [] }))
107
+ }
108
+ if (type !== 'activity') return this.list(type)
109
+ if (!activityStore) throw importFail('The activity store is unavailable.', 503)
110
+ const query = input.activity || {}
111
+ const page = activityStore.query({ range: query.from || query.to ? 'custom' : 'all', ...(query.from ? { from: query.from } : {}), ...(query.to ? { to: query.to } : {}), ...(query.snapshot != null ? { snapshot: query.snapshot } : {}), offset: query.offset || 0, limit: 500 })
112
+ if (!page.events.length) return []
113
+ const id = 'history-' + fingerprint([context, page.snapshot, query.offset || 0, query.from, query.to]).slice(0, 24)
114
+ return [{ id, name: `Activity (${page.events.length} events)`, origin: context, events: page.events, exportPage: { snapshot: page.snapshot, offset: query.offset || 0, total: page.total, exported: page.events.length, nextOffset: page.nextOffset } }]
115
+ },
116
+ async prepare(type, data, job, item) {
117
+ if (type !== 'templates' || !data.recipe.setups.length) return data
118
+ const { prepareLaunch, preparationStatus, cancelPreparation } = await import('./setup-preparation.js')
119
+ const store = getSetupStore(), setups = []
120
+ for (const id of data.recipe.setups) {
121
+ const source = await store.get(id)
122
+ let result = await prepareLaunch(store, id, { revision: source.revision })
123
+ const preparationId = result.id, deadline = Date.now() + 25 * 60_000
124
+ while (result.status === 'running' && Date.now() < deadline) {
125
+ if (job.cancelRequested) { await cancelPreparation(preparationId); throw importFail('Setup preparation cancelled.', 409) }
126
+ await new Promise(resolve => setTimeout(resolve, 1500))
127
+ result = await preparationStatus(preparationId)
128
+ }
129
+ if (result.status !== 'complete' || !result.setup) throw importFail('A required MCP package could not be prepared. Open the imported setup and retry preparation.', 409)
130
+ const ready = await store.get(result.setup.id)
131
+ if (ready.items.some(item => !item.disabled && item.issues.length)) throw importFail('A required setup needs credentials or configuration. Resolve its warnings on the destination before importing this template.', 409)
132
+ setups.push(ready.id)
133
+ if (ready.id !== id) item.warnings = [...new Set([...item.warnings, `Prepared setup snapshot ${ready.id} is used by this template; source setup ${id} remains available.`])]
134
+ }
135
+ return { ...data, recipe: { ...data.recipe, setups, setupRevisions: {} } }
136
+ },
137
+ async create(type, data, job, item) {
138
+ if (type === 'policyTemplates') return policyRoute('POST', ['templates'], { ...data, isNew: true })
139
+ if (type === 'activity') {
140
+ if (!activityStore) throw importFail('The activity store is unavailable.', 503)
141
+ const scope = `import:${fingerprint(data.origin)}`
142
+ for (const event of data.events) {
143
+ if (job.cancelRequested) throw importFail('Activity import cancelled. Already imported events are retained and retry will deduplicate them.', 409)
144
+ activityStore.ingest({ ...event, importedFrom: { ...data.origin, eventId: event.id, receivedAt: event.receivedAt || null }, importedAt: now(), importJob: job.id, location: { label: `${data.origin.gateway} / ${data.origin.workspace} (imported)` } }, scope)
145
+ }
146
+ activityStore.source(data.id, { importBatch: true, name: data.name, revision: fingerprint(data), eventCount: data.events.length, origin: data.origin, completedAt: now() })
147
+ return
148
+ }
149
+ if (type === 'groups') return orgRoute('POST', ['org', 'groups'], { ...data, isNew: true })
150
+ if (type === 'network') return orgRoute('POST', ['egress', 'policies'], { ...data, isNew: true })
151
+ if (type === 'templates') {
152
+ await imageTemplateRoute('POST', ['image-templates'], { recipe: data.recipe })
153
+ const deadline = Date.now() + 25 * 60_000
154
+ while (Date.now() < deadline) {
155
+ const template = (await listImageTemplates()).find(template => template.name === data.name)
156
+ if (template?.status === 'ready') return template
157
+ if (template?.status === 'failed') throw importFail('Template build failed. Open the destination template to inspect its build log.', 502)
158
+ if (job.cancelRequested) {
159
+ await imageTemplateRoute('POST', ['image-templates', data.name, 'cancel'], {})
160
+ throw importFail('Template build cancelled.', 409)
161
+ }
162
+ await new Promise(resolve => setTimeout(resolve, 1500))
163
+ }
164
+ throw importFail('Template build timed out. Inspect the destination template before retrying.', 504)
165
+ }
166
+ const items = data.items
167
+ const setup = { ...data, revision: fingerprintItems(items), createdAt: now(), owner: 'local operator', importSource: { job: job.id, key: item.key }, items }
168
+ const dir = path.join(scopedStateDirectory(), 'setups')
169
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
170
+ const final = path.join(dir, `${data.id}.json`), temporary = `${final}.${randomUUID()}.tmp`
171
+ try {
172
+ await fs.writeFile(temporary, JSON.stringify(setup), { flag: 'wx', mode: 0o600 })
173
+ await fs.link(temporary, final) // Atomic, exclusive publication; a crash cannot leave a partial Setup.
174
+ } finally { await fs.rm(temporary, { force: true }) }
175
+ return setup
176
+ },
177
+ async reset(type, id) {
178
+ if (type !== 'templates') return
179
+ const template = (await listImageTemplates()).find(template => template.name === id)
180
+ if (template?.status === 'failed' && !template.exists) await imageTemplateRoute('POST', ['image-templates', id, 'dismiss'], {})
181
+ },
182
+ }
183
+ }
184
+ // Setup storage hashes its exact JSON array rather than a sorted-key canonical form.
185
+ import { hash as fingerprintItemsRaw } from './setup-discovery.js'
186
+ const fingerprintItems = items => fingerprintItemsRaw(JSON.stringify(items))
187
+
188
+ export function createResourceImports({ directory = path.join(scopedStateDirectory(), 'resource-imports'), context = contextSelection(), adapter, activityStore, sourceKind, onEvent = async () => {} } = {}) {
189
+ adapter ??= defaultAdapter({ activityStore })
190
+ const destination = { gateway: context.gateway || '', workspace: context.workspace || '' }
191
+ const file = id => { if (!JOB_ID.test(id || '')) throw importFail('Import not found.', 404); return path.join(directory, `${id}.json`) }
192
+ const key = id => file(id)
193
+ const save = async job => { job.updatedAt = now(); await atomic(file(job.id), job) }
194
+ const read = async id => {
195
+ // A job saves its final state before it stops running, so only a job that
196
+ // was already stopped when the read began can be stranded in 'running'.
197
+ const active = running.has(key(id))
198
+ let job
199
+ try { job = JSON.parse(await fs.readFile(file(id), 'utf8')) } catch (error) { if (error.code === 'ENOENT') throw importFail('Import not found.', 404); throw error }
200
+ if (contextKey(job.destination) !== contextKey(destination)) throw importFail('Import belongs to another destination.', 403)
201
+ if (job.status === 'running' && !active && !running.has(key(id))) {
202
+ job.status = 'interrupted'
203
+ for (const item of job.items) if (item.status === 'running') { item.status = 'failed'; item.error = 'The process stopped during this item. Retry to check the destination and resume.' }
204
+ }
205
+ return job
206
+ }
207
+ const inventory = async type => adapter.list(type)
208
+ const find = async (type, id) => (await inventory(type)).find(value => (value.id ?? value.name) === id)
209
+ const equal = (type, existing, data) => { if (type === 'activity' && existing.importReceipt) return existing.revision === fingerprint(data); try { return comparable(type, existing) === comparable(type, data) } catch { return false } }
210
+ const emit = async (job, action) => { try { await onEvent({ action, job: jobView(job) }) } catch { /* Activity delivery must not undo an import. */ } }
211
+
212
+ async function run(job) {
213
+ try {
214
+ for (const item of job.items) {
215
+ if (['created', 'reused'].includes(item.status)) continue
216
+ if (job.cancelRequested) break
217
+ const failedDependency = item.dependencies.find(dependency => !job.items.some(other => other.key === dependency && ['created', 'reused'].includes(other.status)))
218
+ if (failedDependency) { item.status = 'blocked'; item.error = 'A dependency did not complete. Retry after resolving the failed item.'; await save(job); continue }
219
+ item.status = 'running'; item.error = null; await save(job)
220
+ try {
221
+ let existing = await find(item.type, item.targetId)
222
+ if (item.action === 'reuse') {
223
+ if (!existing || (item.type === 'activity' && existing.importReceipt ? existing.revision : comparable(item.type, existing)) !== item.reuseRevision) throw importFail('The destination resource changed after review. Create a new plan.', 409)
224
+ item.status = 'reused'
225
+ } else if (existing && item.attempted && equal(item.type, existing, item.data) && (!existing.status || existing.status === 'ready')) {
226
+ item.status = 'created'
227
+ item.warnings = [...new Set([...item.warnings, 'Recovered an already-created resource from the interrupted attempt.'])]
228
+ } else {
229
+ if (existing && item.attempted && existing.status === 'failed' && !existing.exists) { await adapter.reset?.(item.type, item.targetId); existing = await find(item.type, item.targetId) }
230
+ if (existing) throw importFail('The destination identity is already in use or a build is still running. Review it before retrying or create a new plan.', 409)
231
+ await adapter.validate?.(item.type, item.data)
232
+ if (adapter.prepare) item.data = await adapter.prepare(item.type, item.data, job, item)
233
+ if (job.cancelRequested) throw importFail('Import cancelled before creating this resource.', 409)
234
+ item.attempted = true; await save(job)
235
+ await adapter.create(item.type, item.data, job, item)
236
+ const created = await find(item.type, item.targetId)
237
+ if (!created || !equal(item.type, created, item.data) || (created.status && created.status !== 'ready')) throw importFail('Destination verification did not complete. Inspect the resource before retrying.', 502)
238
+ item.status = 'created'
239
+ }
240
+ } catch (error) {
241
+ item.status = job.cancelRequested ? 'cancelled' : 'failed'; item.error = error.status ? error.message : 'Import failed. Check destination connectivity and retry.'
242
+ }
243
+ await save(job)
244
+ }
245
+ const completed = job.items.filter(item => ['created', 'reused'].includes(item.status)).length
246
+ job.status = job.cancelRequested ? 'cancelled' : completed === job.items.length ? 'completed' : completed ? 'partial' : 'failed'
247
+ if (job.cancelRequested) for (const item of job.items) if (item.status === 'pending') item.status = 'cancelled'
248
+ await save(job); await emit(job, 'import.' + job.status)
249
+ } catch {
250
+ job.status = 'failed'
251
+ job.warnings = [...new Set([...job.warnings, 'Import stopped unexpectedly. Check destination storage and retry.'])]
252
+ await save(job).catch(() => {})
253
+ } finally { running.delete(key(job.id)) }
254
+ }
255
+
256
+ const service = {
257
+ capabilities: () => importCapabilities({ activity: Boolean(activityStore) }),
258
+ async export(input = {}) {
259
+ const types = input.types || IMPORT_TYPES.filter(type => !['activity', 'policyTemplates'].includes(type))
260
+ if (!Array.isArray(types) || types.some(type => !IMPORT_TYPES.includes(type))) throw importFail('Choose supported resource types.')
261
+ const resources = [], excluded = []
262
+ const needed = new Set(types)
263
+ if (needed.has('templates')) needed.add('setups')
264
+ if (needed.has('network')) { needed.add('groups'); needed.add('setups') }
265
+ if (needed.has('groups')) needed.add('policyTemplates')
266
+ const pages = {}
267
+ for (const type of IMPORT_TYPES) {
268
+ if (!needed.has(type)) continue
269
+ try {
270
+ for (const raw of await (adapter.export ? adapter.export(type, input, destination) : inventory(type))) {
271
+ try {
272
+ const resource = portableResource(type, raw)
273
+ if (raw.exportPage) { pages[type] = raw.exportPage; if (raw.exportPage.nextOffset != null) resource.warnings.push(`This batch includes ${raw.exportPage.exported} of ${raw.exportPage.total} matching events. Export the next page to copy older history.`) }
274
+ resources.push(resource)
275
+ }
276
+ catch (error) { excluded.push({ type, id: raw.id ?? raw.name, name: raw.name, reason: error.status ? error.message : 'This resource could not be exported.' }) }
277
+ }
278
+ } catch { excluded.push({ type, reason: 'Could not read this resource type. Check source connectivity.' }) }
279
+ }
280
+ if (needed.has('groups') && adapter.organizationRules) {
281
+ for (const rule of await adapter.organizationRules(destination)) {
282
+ const resource = portableResource('network', rule)
283
+ if (!resources.some(other => other.key === resource.key)) {
284
+ resource.warnings.push('Source organization blocks are preserved for this imported group. Destination organization settings stay unchanged.')
285
+ resources.push(resource)
286
+ } else throw importFail('A source rule conflicts with a generated organization restriction. Rename the source rule and export again.', 409)
287
+ }
288
+ }
289
+ const selected = new Set(input.selection || resources.filter(resource => types.includes(resource.type)).map(resource => resource.key))
290
+ for (let previous = -1; previous !== selected.size;) {
291
+ previous = selected.size
292
+ for (const resource of resources) {
293
+ if (resource.requiredForGroups?.some(id => selected.has(`groups:${id}`))) selected.add(resource.key)
294
+ if (selected.has(resource.key)) for (const dependency of resource.dependencies) selected.add(dependency)
295
+ }
296
+ }
297
+ let remote = false
298
+ try { remote = resolveGateway(context.gateway).remote } catch { /* Tests can supply an isolated context. */ }
299
+ const bundle = { version: 1, source: { kind: sourceKind || (remote ? 'host' : 'local'), label: `${destination.gateway || 'Local'} / ${destination.workspace || 'default'}`, context: destination }, resources: resources.filter(resource => selected.has(resource.key)), excluded, ...(Object.keys(pages).length ? { pages } : {}) }
300
+ if (Buffer.byteLength(JSON.stringify(bundle)) > IMPORT_BODY_LIMIT) throw importFail('Configuration export exceeds 8 MiB. Select fewer resources.', 413)
301
+ return bundle
302
+ },
303
+ async plan(input) {
304
+ const bundle = validateImportBundle(input?.bundle)
305
+ const selected = new Set(input.selection || bundle.resources.map(resource => resource.key))
306
+ if (!selected.size || selected.size > 500 || [...selected].some(key => !bundle.resources.some(resource => resource.key === key))) throw importFail('Choose available resources to import.')
307
+ for (let previous = -1; previous !== selected.size;) {
308
+ previous = selected.size
309
+ for (const resource of bundle.resources) {
310
+ if (resource.requiredForGroups?.some(id => selected.has(`groups:${id}`))) selected.add(resource.key)
311
+ if (!selected.has(resource.key)) continue
312
+ for (const dependency of resource.dependencies) {
313
+ if (!bundle.resources.some(resource => resource.key === dependency)) throw importFail(`Missing dependency ${dependency}. Export it with the selected resources.`, 409)
314
+ selected.add(dependency)
315
+ }
316
+ }
317
+ }
318
+ // Organization blocks come before other rules, so a reused group never
319
+ // runs with an imported allow rule but without its source's blocks.
320
+ const blocks = resource => resource.requiredForGroups?.length ? 0 : 1
321
+ const resources = bundle.resources.filter(resource => selected.has(resource.key)).sort((a, b) => phases[a.type] - phases[b.type] || blocks(a) - blocks(b) || a.key.localeCompare(b.key))
322
+ const id = randomUUID().replaceAll('-', ''), mapping = {}, items = [], inventories = {}
323
+ for (const type of new Set(resources.map(resource => resource.type))) inventories[type] = await inventory(type)
324
+ for (const resource of resources) {
325
+ const choice = input.conflicts?.[resource.key]
326
+ if (choice && !['create', 'reuse'].includes(choice.action)) throw importFail('Unsupported conflict resolution.')
327
+ const reuse = resource.type === 'activity' && inventories.activity.some(value => (value.id ?? value.name) === resource.id) || choice?.action === 'reuse'
328
+ const existingIds = new Set(inventories[resource.type].map(value => value.id ?? value.name))
329
+ let targetId = resource.id
330
+ if (reuse) targetId = choice?.targetId || resource.id
331
+ else if (resource.type === 'activity') targetId = resource.id
332
+ else if (resource.type === 'setups') targetId = fingerprint([id, resource.key]).slice(0, 24)
333
+ else if (resource.type === 'policyTemplates') targetId = 'import-' + fingerprint([id, resource.key]).slice(0, 32)
334
+ else if (existingIds.has(targetId) || items.some(item => item.type === resource.type && item.targetId === targetId)) {
335
+ const max = { groups: 48, network: 40, templates: 19 }[resource.type]
336
+ targetId = resource.id.slice(0, max - 9).replace(/-+$/, '') + '-' + fingerprint([id, resource.key]).slice(0, 8)
337
+ }
338
+ if (!validImportId(resource.type, targetId)) throw importFail('Invalid destination resource identity.')
339
+ mapping[resource.key] = targetId
340
+ const data = translated(resource, mapping, targetId)
341
+ await adapter.validate?.(resource.type, data)
342
+ const warnings = [...resource.warnings]
343
+ if (targetId !== resource.id && resource.type !== 'setups') warnings.push(`Destination identity: ${targetId}. The existing resource will stay unchanged.`)
344
+ let reuseRevision
345
+ if (reuse) {
346
+ const existing = inventories[resource.type].find(value => (value.id ?? value.name) === targetId)
347
+ if (!existing || !equal(resource.type, existing, data)) throw importFail(`The destination ${resource.name} is not identical to the selected configuration. Create a copy instead.`, 409)
348
+ reuseRevision = resource.type === 'activity' && existing.importReceipt ? existing.revision : comparable(resource.type, existing)
349
+ }
350
+ items.push({ key: resource.key, type: resource.type, name: resource.name, sourceId: resource.id, targetId, action: reuse ? 'reuse' : 'create', status: 'pending', dependencies: resource.dependencies, warnings, error: null, data, ...(reuseRevision ? { reuseRevision } : {}) })
351
+ }
352
+ const job = { id, status: 'planned', source: bundle.source, destination, createdAt: now(), updatedAt: now(), items, warnings: ['Copies preserve the source. Credentials and sandbox memberships are not copied. Source organization blocks become rules scoped to imported groups; destination organization settings stay unchanged. Historical activity is included only when selected.'], cancelRequested: false }
353
+ await save(job)
354
+ return jobView(job)
355
+ },
356
+ async list() {
357
+ const names = await fs.readdir(directory).catch(error => { if (error.code === 'ENOENT') return []; throw error })
358
+ const jobs = await Promise.all(names.filter(name => /^[a-f0-9]{32}\.json$/.test(name)).map(async name => jobView(await read(name.slice(0, -5)))))
359
+ return { jobs: jobs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100) }
360
+ },
361
+ async get(id) { return jobView(await read(id)) },
362
+ async execute(id, input, retry = false) {
363
+ return queue(directory, async () => {
364
+ if (input?.acknowledged !== true) throw importFail('Review the import plan and acknowledge the changes before starting.')
365
+ if (running.has(key(id))) return jobView(running.get(key(id)))
366
+ if ([...running.keys()].filter(file => path.dirname(file) === directory).length >= 2) throw importFail('Two imports are already running at this destination. Wait or cancel one before starting another.', 429)
367
+ const job = await read(id)
368
+ if (job.status === 'completed') return jobView(job)
369
+ if (retry ? !terminal.has(job.status) : job.status !== 'planned') throw importFail('Use retry to resume this import.', 409)
370
+ if (retry) for (const item of job.items) if (!['created', 'reused'].includes(item.status)) { item.status = 'pending'; item.error = null }
371
+ job.status = 'running'; job.cancelRequested = false
372
+ running.set(key(id), job)
373
+ try { await save(job) } catch (error) { running.delete(key(id)); throw error }
374
+ const response = jobView(job)
375
+ void runWithContext(context, () => run(job))
376
+ await emit(job, retry ? 'import.retry' : 'import.started')
377
+ return response
378
+ })
379
+ },
380
+ async cancel(id) {
381
+ return queue(directory, async () => {
382
+ const active = running.get(key(id)), job = active || await read(id)
383
+ if (terminal.has(job.status)) return jobView(job)
384
+ job.cancelRequested = true
385
+ if (!active) { job.status = 'cancelled'; for (const item of job.items) if (item.status === 'pending') item.status = 'cancelled' }
386
+ await save(job)
387
+ return jobView(job)
388
+ })
389
+ },
390
+ }
391
+ // A retained service is destination-bound even after the console changes its
392
+ // selected gateway; async storage and background jobs use the captured scope.
393
+ for (const method of ['export', 'plan', 'list', 'get', 'execute', 'cancel']) {
394
+ const operation = service[method]
395
+ service[method] = (...args) => runWithContext(context, () => operation(...args))
396
+ }
397
+ return service
398
+ }
399
+
400
+ export async function resourceImportRoute(method, parts, input, options) {
401
+ if (parts[0] !== 'resource-imports') return undefined
402
+ const service = createResourceImports(options)
403
+ if (method === 'GET' && parts.length === 1) return service.list()
404
+ if (method === 'GET' && parts[1] === 'capabilities' && parts.length === 2) return service.capabilities()
405
+ if (method === 'GET' && parts.length === 2) return service.get(parts[1])
406
+ if (method === 'POST' && parts.length === 2 && parts[1] === 'export') return service.export(input)
407
+ if (method === 'POST' && parts.length === 2 && parts[1] === 'plan') return service.plan(input)
408
+ if (method === 'POST' && parts.length === 3 && parts[2] === 'execute') return service.execute(parts[1], input)
409
+ if (method === 'POST' && parts.length === 3 && parts[2] === 'retry') return service.execute(parts[1], input, true)
410
+ if (method === 'POST' && parts.length === 3 && parts[2] === 'cancel') return service.cancel(parts[1])
411
+ throw importFail('Unknown import operation.', 404)
412
+ }
@@ -5,7 +5,7 @@ import { identityContext } from './security.js'
5
5
  // configured operator, or the OS account running the server, never request data.
6
6
  export function sandboxIdentityLabels() {
7
7
  const identity = identityContext.getStore()
8
- if (identity) return { 'openshell.console/created-by': identity.uid, 'openshell.console/owner': identity.uid, 'openrod/org': identity.org }
8
+ if (identity) return { 'openshell.console/created-by': identity.uid, 'openshell.console/owner': identity.uid, ...(identity.org ? { 'openrod/org': identity.org } : {}) }
9
9
  const operator = process.env.OPENSHELL_CONSOLE_OPERATOR?.trim() || userInfo().username
10
10
  return {
11
11
  'openshell.console/created-by': operator,
@@ -2,6 +2,7 @@ import { verifyWorkerRequest } from './worker-auth.js'
2
2
  import { isIP } from 'node:net'
3
3
  import {createHash} from 'node:crypto'
4
4
  import { AsyncLocalStorage } from 'node:async_hooks'
5
+ import { workerProtocol, workerAuthHeader } from './cloud-deployment.js'
5
6
  export function requestPath(req) {
6
7
  if (typeof req.url !== 'string' || !req.url.startsWith('/') || req.url.startsWith('//') || req.url.includes('\\')) throw fail('Invalid request target', 400)
7
8
  try { return new URL(req.url, 'http://local').pathname } catch { throw fail('Invalid request target', 400) }
@@ -19,30 +20,42 @@ export function cloudConfig(env = process.env) {
19
20
  const mode = env.OPENROD_MODE ?? 'local'
20
21
  if (!['local', 'cloud', 'worker'].includes(mode)) throw Error('OPENROD_MODE must be local, cloud or worker')
21
22
  if (mode === 'local') return { mode }
23
+ const protocol = workerProtocol(env.OPENROD_WORKER_PROTOCOL, env)
22
24
  if (mode === 'worker') {
23
25
  if (!/^[a-f0-9]{64}$/.test(env.OPENROD_WORKER_KEY ?? '') || !env.OPENROD_WORKER_UID || !env.OPENROD_PUBLIC_ORIGIN) throw Error('Worker mode requires its owner, key and public origin')
24
26
  const origin = new URL(env.OPENROD_PUBLIC_ORIGIN)
25
27
  if (origin.protocol !== 'https:' || origin.origin !== env.OPENROD_PUBLIC_ORIGIN) throw Error('Invalid worker public origin')
26
- return {mode, key: env.OPENROD_WORKER_KEY, owner: env.OPENROD_WORKER_UID, origin: origin.origin, host: origin.host}
28
+ return {mode, key: env.OPENROD_WORKER_KEY, owner: env.OPENROD_WORKER_UID, origin: origin.origin, host: origin.host, workerProtocol: protocol, authHeader: workerAuthHeader(protocol, env)}
27
29
  }
28
30
  for (const key of ['OPENROD_ORG_ID', 'OPENROD_PUBLIC_ORIGIN', 'GOOGLE_CLOUD_PROJECT', 'OPENROD_FIREBASE_API_KEY', 'OPENROD_FIREBASE_AUTH_DOMAIN']) if (!env[key]?.trim()) throw Error(`Cloud mode requires ${key}`)
29
31
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(env.OPENROD_ORG_ID)) throw Error('Invalid OPENROD_ORG_ID')
30
32
  if (!/^[a-z0-9.-]+$/i.test(env.OPENROD_FIREBASE_AUTH_DOMAIN) || !/^[a-z][a-z0-9-]{4,62}$/.test(env.GOOGLE_CLOUD_PROJECT)) throw Error('Invalid Identity Platform project or auth domain')
31
33
  const origin = new URL(env.OPENROD_PUBLIC_ORIGIN)
32
34
  if (origin.protocol !== 'https:' || origin.origin !== env.OPENROD_PUBLIC_ORIGIN || origin.username || origin.password) throw Error('OPENROD_PUBLIC_ORIGIN must be an HTTPS origin without a path')
33
- if (env.FIREBASE_AUTH_EMULATOR_HOST) throw Error('Cloud mode cannot use the authentication emulator')
34
- return { mode, org: env.OPENROD_ORG_ID, origin: origin.origin, host: origin.host, firebase: { apiKey: env.OPENROD_FIREBASE_API_KEY, authDomain: env.OPENROD_FIREBASE_AUTH_DOMAIN, projectId: env.GOOGLE_CLOUD_PROJECT } }
35
+ if (env.FIREBASE_AUTH_EMULATOR_HOST || env.FIRESTORE_EMULATOR_HOST) throw Error('Cloud mode cannot use Firebase emulators')
36
+ const sessionCookie = env.OPENROD_SESSION_COOKIE ?? '__Host-openrod_session'
37
+ if (!/^__Host-[A-Za-z0-9_-]{1,64}$/.test(sessionCookie)) throw Error('OPENROD_SESSION_COOKIE must be a __Host- cookie name')
38
+ const proxyClientIpHeader = env.OPENROD_PROXY_CLIENT_IP_HEADER ?? 'x-openrod-client-ip'
39
+ // Only a dedicated header the loopback proxy overwrites, never X-Forwarded-For.
40
+ if (!/^x-[a-z0-9]+(?:-[a-z0-9]+)*-client-ip$/.test(proxyClientIpHeader)) throw Error('Invalid OPENROD_PROXY_CLIENT_IP_HEADER')
41
+ return { mode, org: env.OPENROD_ORG_ID, origin: origin.origin, host: origin.host, workerProtocol: protocol, sessionCookie, proxyClientIpHeader, firebase: { apiKey: env.OPENROD_FIREBASE_API_KEY, authDomain: env.OPENROD_FIREBASE_AUTH_DOMAIN, projectId: env.GOOGLE_CLOUD_PROJECT } }
35
42
  }
36
- export const CLOUD_UNRELEASED = 'OpenRod cloud and worker modes are not part of this release.'
37
- // Cloud and worker modes return with the cloud release; until then only local mode starts.
43
+ // Selecting cloud/worker is an explicit deployment choice. Their configuration
44
+ // and runtime dependencies are validated before opening a listening socket.
38
45
  export function releaseConfig(env = process.env) {
39
- if (['cloud', 'worker'].includes(env.OPENROD_MODE)) throw Error(CLOUD_UNRELEASED)
40
46
  return cloudConfig(env)
41
47
  }
48
+ // OPENROD_TELEMETRY=0 (or false, off, no) and the DO_NOT_TRACK=1 convention
49
+ // turn off both usage sharing and feedback, whatever the browser chose.
50
+ export function telemetryAllowed(env = process.env) {
51
+ const value = name => String(env[name] ?? '').trim().toLowerCase()
52
+ return !['0', 'false', 'off', 'no'].includes(value('OPENROD_TELEMETRY')) && !['1', 'true', 'yes'].includes(value('DO_NOT_TRACK'))
53
+ }
42
54
  const COOKIE = '__Host-openrod_session'
43
- const cookieOf = req => (req.headers.cookie ?? '').split(';').map(s => s.trim()).find(s => s.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1)
55
+ const cookieOf = (req, name = COOKIE) => (req.headers.cookie ?? '').split(';').map(s => s.trim()).find(s => s.startsWith(`${name}=`))?.slice(name.length + 1)
44
56
  export function createSecurity(config, auth, revocations = { has: () => false, add: () => { throw Error("Session revocation store is required") } }) {
45
57
  const verified = new WeakMap()
58
+ const cookieName = config.sessionCookie ?? COOKIE
46
59
  const attempts = new Map()
47
60
  const originFor = req => config.mode !== 'local' ? config.origin : `http://${req.headers.host}`
48
61
  const checkBoundary = req => {
@@ -60,11 +73,11 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
60
73
  checkBoundary(req)
61
74
  if (config.mode === 'local') return null
62
75
  if (config.mode === 'worker') {
63
- const identity = verifyWorkerRequest(config.key, config.owner, req)
76
+ const identity = verifyWorkerRequest(config.key, config.owner, req, Date.now(), config.authHeader ?? workerAuthHeader(config.workerProtocol))
64
77
  verified.set(req, identity)
65
78
  return identity
66
79
  }
67
- const cookie = cookieOf(req)
80
+ const cookie = cookieOf(req, cookieName)
68
81
  if (!cookie || cookie.length > 10000 || revocations.has(cookie)) throw fail('Sign in to OpenRod Cloud', 401)
69
82
  let decoded
70
83
  try { decoded = await auth.verifySessionCookie(cookie, true) } catch { throw fail('Session expired. Sign in again.', 401) }
@@ -75,18 +88,20 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
75
88
  }
76
89
  const isAllowed = req => config.mode === 'local' ? isLocalApiRequest(req) : verified.has(req)
77
90
  const json = (res, status, value) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)) }
78
- const setCookie = (res, value, age) => res.setHeader('Set-Cookie', `${COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${age}`)
91
+ const setCookie = (res, value, age) => res.setHeader('Set-Cookie', `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${age}`)
79
92
  const middleware = async (req, res, next) => {
80
93
  try {
81
94
  const pathname = requestPath(req)
82
95
  if (!pathname.startsWith('/api/')) return next()
83
96
  checkBoundary(req)
84
- if (pathname === '/api/auth/config' && req.method === 'GET') return json(res, 200, { mode: config.mode, ...(config.mode === 'cloud' ? { firebase: config.firebase } : {}) })
97
+ if (pathname === '/api/auth/config' && req.method === 'GET') return json(res, 200, { mode: config.mode,
98
+ telemetryEnabled: config.mode === 'local' && telemetryAllowed(),
99
+ ...(config.mode === 'cloud' ? { firebase: config.firebase } : {}) })
85
100
  if (config.mode === 'cloud' && pathname === '/api/auth/session' && req.method === 'POST') {
86
101
  if (req.headers['x-openshell-console'] !== '1' || req.headers['content-type'] !== 'application/json') throw fail('Request rejected', 403)
87
102
  // nginx overwrites this header from the GCP-appended client address.
88
103
  // Trust it only from our loopback proxy, never from an external socket.
89
- const forwarded = req.headers['x-openrod-client-ip']
104
+ const forwarded = req.headers[config.proxyClientIpHeader ?? 'x-openrod-client-ip']
90
105
  const loopback = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress)
91
106
  const key = loopback && typeof forwarded === 'string' && isIP(forwarded) ? forwarded : req.socket.remoteAddress
92
107
  const now = Date.now()
@@ -112,7 +127,7 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
112
127
  if (config.mode === 'cloud' && pathname === '/api/auth/logout' && req.method === 'POST') {
113
128
  if (req.headers['x-openshell-console'] !== '1') throw fail('Request rejected', 403)
114
129
  setCookie(res, '', 0)
115
- const cookie = cookieOf(req)
130
+ const cookie = cookieOf(req, cookieName)
116
131
  if (cookie) revocations.add(cookie, Date.now() + 3600000)
117
132
  return json(res, 200, { ok: true })
118
133
  }
@@ -132,9 +147,10 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
132
147
  check?.unref?.()
133
148
  target.once('close', () => { clearTimeout(expiry); clearInterval(check) })
134
149
  }
135
- return { config, authenticate, isAllowed, originFor, middleware, watch, sessionHash: req => createHash('sha256').update(cookieOf(req) ?? '').digest('hex'), isSessionRevoked: hash => revocations.hasDigest?.(hash) ?? false }
150
+ return { config, authenticate, isAllowed, originFor, middleware, watch, sessionHash: req => createHash('sha256').update(cookieOf(req, cookieName) ?? '').digest('hex'), isSessionRevoked: hash => revocations.hasDigest?.(hash) ?? false }
136
151
  }
137
152
  export function assertCloudOperation(parts, input = {}) {
138
153
  if (!identityContext.getStore()) return
154
+ if (parts[0] === 'ingress' && ['expose', 'extend'].includes(parts[1])) throw fail('Public service forwarding is not available in OpenRod Cloud.', 501)
139
155
  if (parts[0] === 'local-folder' || parts[0] === 'editors' || (parts[0] === 'sandboxes' && ['editor', 'terminal'].includes(parts[2])) || (parts[0] === 'sandboxes' && parts.length === 1 && input?.folder)) throw fail('Host-local actions are unavailable in OpenRod Cloud. Use the browser terminal or upload files.', 403)
140
156
  }
@@ -285,5 +285,6 @@ export async function discover({ sources, home = os.homedir() }) {
285
285
 
286
286
  export function publicItem(item) {
287
287
  const { root, config, files, _sourceCredentials, ...rest } = item
288
- return { ...rest, sourceCredentialFields: item.sourceCredentialFields || Object.keys(_sourceCredentials || {}), ...(config ? { configuration: config.url ? { endpoint: config.url } : { command: config.command, args: config.args } } : {}), ...(files ? { files: files.map(({ path, content, encoding, archiveEntries }) => ({ path, bytes: Buffer.byteLength(content, encoding === 'base64' ? 'base64' : 'utf8'), ...(encoding ? { encoding, archiveEntries } : {}) })) } : {}) }
288
+ const contentDigest = hash(JSON.stringify(canonical({kind:item.kind, name:item.name, config, files, disabled:Boolean(item.disabled), credentialRef:item.credentialRef, credentialBindings:item.credentialBindings, requirements:item.requirements, auth:item.auth})))
289
+ return { ...rest, contentDigest, sourceCredentialFields: item.sourceCredentialFields || Object.keys(_sourceCredentials || {}), ...(config ? { configuration: config.url ? { endpoint: config.url } : { command: config.command, args: config.args } } : {}), ...(files ? { files: files.map(({ path, content, encoding, archiveEntries }) => ({ path, bytes: Buffer.byteLength(content, encoding === 'base64' ? 'base64' : 'utf8'), ...(encoding ? { encoding, archiveEntries } : {}) })) } : {}) }
289
290
  }