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,334 @@
1
+ // Explicit, anonymous PostHog capture. No SDK, autocapture, queue, retry, or logs.
2
+ // This module is also imported by Node-side libraries: never touch the browser
3
+ // until the authorized local UI configures it.
4
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i
5
+ export const USAGE_KEY = 'openrod.usage.v1'
6
+ const ID_KEY = 'openrod.usage-id.v1'
7
+ const SESSION_KEY = 'openrod.usage-session.v1'
8
+ const TERMINAL_KEY = 'openrod.terminal-flows.v1'
9
+ const choices = (...values) => value => values.includes(value) ? value : undefined
10
+ const uuid = value => typeof value === 'string' && UUID.test(value) ? value : undefined
11
+ const number = value => Number.isFinite(value) && value >= 0 ? Math.min(Math.round(value), 86400000) : undefined
12
+ const boolean = value => typeof value === 'boolean' ? value : undefined
13
+ const view = choices('sandboxes', 'activity', 'groups', 'egress', 'ingress', 'secrets', 'templates', 'setups', 'connections', 'terminal')
14
+ const flow = choices('gateway_connection', 'sandbox_creation', 'session_launch')
15
+ const location = choices('local', 'ssh', 'cloud', 'unknown')
16
+ const errorCategory = choices('docker_missing', 'docker_unavailable', 'openshell_missing', 'gateway_unavailable', 'ssh_auth', 'host_key', 'runtime_missing', 'image_build', 'credentials_missing', 'setup_import', 'policy', 'file_seed', 'timeout', 'connection_lost', 'unknown')
17
+ const flowFields = { flow, flow_id: uuid, attempt: number, location_type: location, launch_target: choices('browser', 'terminal', 'vscode', 'cursor') }
18
+ const text = value => typeof value === 'string' ? value.trim().slice(0, 2000) : undefined
19
+ const agentIds = value => Array.isArray(value) ? [...new Set(value.filter(id => ['claude', 'codex', 'cursor', 'pi', 'antigravity', 'opencode', 'aider', 'copilot', 'kiro', 'droid'].includes(id)))].slice(0, 12) : undefined
20
+ const configFields = { creation_mode: choices('quick', 'template'), file_source: choices('empty', 'folder', 'repo'), agent_ids: agentIds, setup_count: number, provider_count: number, group_count: number }
21
+ const EVENTS = {
22
+ console_opened: { gateway_state: choices('available', 'setup_required', 'unavailable'), first_observed_visit: boolean, connection_method: choices('automatic', 'manual', 'existing', 'unknown') },
23
+ view_opened: { previous_view: view },
24
+ flow_started: { ...flowFields, ...configFields },
25
+ flow_step_changed: { ...flowFields, step: choices('location', 'configuration', 'validation', 'host_selection', 'connecting', 'needs_docker', 'needs_runtime', 'preparing_tools', 'building_image', 'creating', 'requesting'), state: choices('entered', 'blocked'), error_category: errorCategory },
26
+ flow_finished: { ...flowFields, ...configFields, outcome: choices('connected', 'created', 'live', 'handoff_requested', 'failed', 'cancelled'), duration_ms: number, start_observed: boolean, error_category: errorCategory },
27
+ sandbox_ready: { flow_id: uuid, duration_ms: number, location_type: location },
28
+ feature_used: { feature: choices('template', 'setup', 'network', 'group', 'activity', 'files', 'sandbox'), action: choices('built', 'imported', 'activated', 'rule_saved', 'membership_saved', 'filter_applied', 'exported', 'uploaded', 'started', 'stopped', 'deleted'), count: number, location_type: location },
29
+ feedback_submitted: { prompt: choices('general'), category: choices('general'), text, feedback_only: boolean },
30
+ }
31
+
32
+ export function sanitizeEvent(event, properties = {}) {
33
+ if (!Object.hasOwn(EVENTS, event)) return null
34
+ const safe = {}
35
+ for (const [key, validate] of Object.entries({ view, ...EVENTS[event] })) {
36
+ const value = validate(properties[key])
37
+ if (value !== undefined) safe[key] = value
38
+ }
39
+ return safe
40
+ }
41
+
42
+ // Only the category leaves the browser. Messages can contain private hosts or
43
+ // paths, so they are matched here against OpenRod's own wording and dropped.
44
+ const ERROR_PATTERNS = [
45
+ [/host key/i, 'host_key'],
46
+ [/permission denied|publickey|authentication failed/i, 'ssh_auth'],
47
+ [/docker (engine )?is missing|docker isn.t installed|install docker/i, 'docker_missing'],
48
+ [/docker isn.t running|docker daemon|reach docker|docker.*not available/i, 'docker_unavailable'],
49
+ [/openshell (cli )?is not installed|openshell isn.t installed/i, 'openshell_missing'],
50
+ [/runtime (image|package)|sandbox runtime/i, 'runtime_missing'],
51
+ [/timed out/i, 'timeout'],
52
+ [/gateway/i, 'gateway_unavailable'],
53
+ ]
54
+ export function classifyAnalyticsError(error) {
55
+ if (error?.name === 'TimeoutError' || error?.status === 504) return 'timeout'
56
+ if (error?.code === 'GATEWAY_DOCKER_MISMATCH') return 'docker_unavailable'
57
+ const message = typeof error?.message === 'string' ? error.message : ''
58
+ for (const [pattern, category] of ERROR_PATTERNS) if (pattern.test(message)) return category
59
+ if (error?.name === 'TypeError') return 'connection_lost'
60
+ if (error?.status === 502 || error?.status === 503) return 'gateway_unavailable'
61
+ return 'unknown'
62
+ }
63
+
64
+ export function createAnalytics({ getStorage = () => window.localStorage, getLocation = () => window.location.href,
65
+ randomUUID = () => crypto.randomUUID(), now = () => Date.now(), fetch: send = (...args) => fetch(...args),
66
+ setTimer = setTimeout, clearTimer = clearTimeout,
67
+ replaceLocation = url => window.history.replaceState(window.history.state, '', url),
68
+ config = typeof __OPENROD_ANALYTICS__ === 'undefined' ? {} : __OPENROD_ANALYTICS__,
69
+ } = {}) {
70
+ let authorized = false, runtimeEnabled = false, storageFailed = false, currentView, lastView, opened = false, firstVisit
71
+ const destination = config.host === 'https://eu.i.posthog.com' ? 'EU' : 'US'
72
+ let state = Object.freeze({ available: false, sharing: null, destination })
73
+ const listeners = new Set(), pending = new Map(), finished = new Set(), steps = new Map(), templates = new Map()
74
+ let recent = [], generation = 0
75
+ const storage = () => getStorage()
76
+ const safe = (operation, fallback) => { try { return operation() } catch { return fallback } }
77
+ function halt() {
78
+ generation++
79
+ for (const [controller, timer] of pending) { controller.abort(); clearTimer(timer) }
80
+ pending.clear(); finished.clear(); steps.clear(); templates.clear(); recent = []
81
+ opened = false; lastView = undefined; firstVisit = undefined
82
+ }
83
+ function cleanLocation() {
84
+ const url = new URL(getLocation())
85
+ return !url.searchParams.has('token') && !url.searchParams.has('handoff') && !/^#local-(connect|return)=/.test(url.hash)
86
+ && !(config.environment === 'development' && url.searchParams.has('fleet'))
87
+ }
88
+ function available() {
89
+ return safe(() => authorized && runtimeEnabled && !storageFailed && config.enabled === true && /^phc_[a-zA-Z0-9]+$/.test(config.projectToken ?? '')
90
+ && ['https://us.i.posthog.com', 'https://eu.i.posthog.com'].includes(config.host) && cleanLocation(), false)
91
+ }
92
+ function refresh() {
93
+ safe(() => {
94
+ let sharing = null
95
+ try {
96
+ const value = storage().getItem(USAGE_KEY); sharing = value === 'yes' ? true : value === 'no' ? false : null
97
+ }
98
+ catch { storageFailed = true; halt() }
99
+ if (sharing !== true && state.sharing === true) halt()
100
+ const next = { available: available(), sharing, destination }
101
+ if (Object.keys(next).some(key => next[key] !== state[key])) {
102
+ state = Object.freeze(next)
103
+ for (const notify of listeners) safe(notify)
104
+ }
105
+ })
106
+ }
107
+ function configure({ enabled = false, mode = 'local' } = {}) {
108
+ safe(() => { authorized = mode === 'local'; runtimeEnabled = enabled; if (!authorized || !enabled) halt(); refresh() })
109
+ }
110
+ function setSharing(value) {
111
+ safe(() => {
112
+ try {
113
+ storage().setItem(USAGE_KEY, value ? 'yes' : 'no')
114
+ if (!value) {
115
+ for (const key of [ID_KEY, SESSION_KEY, TERMINAL_KEY]) storage().removeItem(key)
116
+ halt()
117
+ }
118
+ } catch { storageFailed = true; halt() }
119
+ refresh()
120
+ })
121
+ }
122
+ function identity() {
123
+ const store = storage()
124
+ let id = uuid(store.getItem(ID_KEY)), session
125
+ const first = !id
126
+ firstVisit ??= first
127
+ if (!id) { id = randomUUID(); store.setItem(ID_KEY, id) }
128
+ try { session = JSON.parse(store.getItem(SESSION_KEY)) } catch { /* invalid old state */ }
129
+ if (!uuid(session?.id) || !Number.isFinite(session?.at) || now() - session.at > 1800000) session = { id: randomUUID() }
130
+ store.setItem(SESSION_KEY, JSON.stringify({ id: session.id, at: now() }))
131
+ return { id, session: session.id, first: firstVisit }
132
+ }
133
+ function capture(event, properties = {}, { explicitFeedback = false } = {}) {
134
+ return safe(() => {
135
+ refresh()
136
+ if (!state.available || (state.sharing !== true && !(explicitFeedback && event === 'feedback_submitted'))) return false
137
+ const sanitized = sanitizeEvent(event, properties)
138
+ if (!sanitized) return false
139
+ // Recheck the stored preference before every request, including in other tabs.
140
+ const regular = state.sharing === true
141
+ let who
142
+ try { who = regular ? identity() : { id: randomUUID() } } catch { storageFailed = true; halt(); refresh(); return false }
143
+ recent = recent.filter(at => now() - at < 60000)
144
+ if (pending.size >= 6 || recent.length >= 120) return false
145
+ const controller = new AbortController()
146
+ const timer = setTimer(() => { controller.abort(); pending.delete(controller) }, 3000)
147
+ pending.set(controller, timer); recent.push(now())
148
+ const payload = {
149
+ api_key: config.projectToken, event, distinct_id: who.id, timestamp: new Date(now()).toISOString(),
150
+ properties: { schema_version: 1, app_version: config.version, environment: config.environment,
151
+ // Feedback sent with sharing off carries only what the user typed.
152
+ ...(regular && who.session ? { $session_id: who.session } : {}),
153
+ ...(regular && currentView ? { view: currentView } : {}),
154
+ // Opted-in usage gets PostHog's approximate location (country, city);
155
+ // the project discards the IP itself. Feedback-only events get neither.
156
+ ...sanitized, $process_person_profile: false, ...(regular ? {} : { $geoip_disable: true }),
157
+ ...(event === 'console_opened' ? { first_observed_visit: who.first } : {}),
158
+ ...(explicitFeedback ? { feedback_only: !regular } : {}),
159
+ },
160
+ }
161
+ const done = () => { clearTimer(timer); pending.delete(controller) }
162
+ if (event === 'console_opened') firstVisit = false
163
+ try {
164
+ Promise.resolve(send(`${config.host}/i/v0/e/`, { method: 'POST', headers: { 'content-type': 'text/plain' },
165
+ body: JSON.stringify(payload), signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer',
166
+ // Survives the reload that follows a local gateway connection.
167
+ cache: 'no-store', redirect: 'error', keepalive: true,
168
+ })).then(done, done)
169
+ } catch { done() }
170
+ return true // Attempted, never a delivery acknowledgement.
171
+ }, false)
172
+ }
173
+ function observeConsole(connection) {
174
+ safe(() => {
175
+ if (opened || !['live', 'setup-required', 'gateway-down'].includes(connection)) return
176
+ if (capture('console_opened', { gateway_state: connection === 'live' ? 'available' : connection === 'setup-required' ? 'setup_required' : 'unavailable', connection_method: 'existing' })) opened = true
177
+ })
178
+ }
179
+ function observeView(next) {
180
+ safe(() => { currentView = view(next); if (currentView && currentView !== lastView && capture('view_opened', { previous_view: lastView })) lastView = currentView })
181
+ }
182
+ function startFlow(kind, properties = {}, previous = null) {
183
+ return safe(() => {
184
+ refresh()
185
+ if (!state.available || state.sharing !== true || !flow(kind)) return null
186
+ const installationId = identity().id
187
+ if (previous?.generation !== generation || previous?.installationId !== installationId || previous?.flow !== kind) previous = null
188
+ // Only a failed attempt, or the exploration before the first submit,
189
+ // continues. Anything still running is superseded by this new flow.
190
+ if (previous && previous.outcome !== 'failed' && previous.started !== null) {
191
+ if (!previous.outcome) finishFlow(previous, 'cancelled')
192
+ previous = null
193
+ }
194
+ const item = { generation, installationId, id: previous?.id ?? randomUUID(), flow: kind, attempt: (previous?.attempt ?? 0) + 1, started: now(), properties: sanitizeEvent('flow_started', properties) }
195
+ capture('flow_started', { ...item.properties, flow: kind, flow_id: item.id, attempt: item.attempt })
196
+ step(item, kind === 'session_launch' ? 'requesting' : kind === 'gateway_connection' ? 'connecting' : 'preparing_tools')
197
+ return item
198
+ }, null)
199
+ }
200
+ function exploreFlow(kind, properties = {}) {
201
+ return safe(() => {
202
+ refresh(); if (!state.available || state.sharing !== true || !flow(kind)) return null
203
+ return { generation, installationId: identity().id, id: randomUUID(), flow: kind, attempt: 0, started: null, properties: sanitizeEvent('flow_started', properties) }
204
+ }, null)
205
+ }
206
+ // Also bind to the stored identity: a suspended tab can miss the brief
207
+ // opt-out state if sharing is turned off and back on before it resumes.
208
+ const activeFlow = item => item && item.generation === generation && state.available && state.sharing === true
209
+ && uuid(item.installationId) && item.installationId === storage().getItem(ID_KEY)
210
+ const fields = item => ({ ...item.properties, flow: item.flow, flow_id: item.id, attempt: item.attempt })
211
+ function step(item, next, phaseState = 'entered', category) {
212
+ safe(() => {
213
+ refresh(); if (!activeFlow(item)) return
214
+ const key = `${item.id}:${item.attempt}`, value = `${next}:${phaseState}:${category}`
215
+ if (steps.get(key) === value) return
216
+ if (steps.size >= 100) steps.delete(steps.keys().next().value)
217
+ steps.set(key, value)
218
+ capture('flow_step_changed', { ...fields(item), step: next, state: phaseState, error_category: category })
219
+ })
220
+ }
221
+ function finishFlow(item, outcome, category) {
222
+ safe(() => {
223
+ refresh(); if (!activeFlow(item)) return
224
+ const key = `${item.id}:${item.attempt}`
225
+ if (finished.has(key)) return
226
+ if (finished.size >= 200) finished.delete(finished.values().next().value)
227
+ finished.add(key); steps.delete(key)
228
+ item.outcome = outcome
229
+ capture('flow_finished', { ...fields(item), outcome, start_observed: item.started !== null, duration_ms: item.started === null ? undefined : now() - item.started, error_category: category })
230
+ })
231
+ }
232
+ function ready(item) {
233
+ safe(() => {
234
+ refresh(); if (!activeFlow(item)) return
235
+ const key = `${item.id}:ready`
236
+ if (finished.has(key)) return
237
+ if (finished.size >= 200) finished.delete(finished.values().next().value)
238
+ finished.add(key)
239
+ capture('sandbox_ready', { flow_id: item.id, duration_ms: now() - item.started, location_type: item.properties.location_type })
240
+ })
241
+ }
242
+ function submitFeedback(properties) {
243
+ return capture('feedback_submitted', properties, { explicitFeedback: true })
244
+ }
245
+ // Only safe correlation metadata is stored, never a name, command, or host.
246
+ function terminalClick(event, properties = {}) {
247
+ safe(() => {
248
+ const item = startFlow('session_launch', { ...properties, launch_target: 'browser' })
249
+ refresh(); if (!activeFlow(item)) return
250
+ const url = new URL(event.currentTarget.href)
251
+ const [path, query = ''] = url.hash.split('?')
252
+ const params = new URLSearchParams(query); params.set('analyticsFlow', item.id)
253
+ url.hash = `${path}?${params}`
254
+ try {
255
+ const saved = readTerminalFlows()
256
+ saved.push(item); storage().setItem(TERMINAL_KEY, JSON.stringify(saved.slice(-20)))
257
+ event.currentTarget.href = url.href
258
+ } catch { /* Losing correlation cannot affect the link. */ }
259
+ })
260
+ }
261
+ // A restored flow is consumed, so a reload or a copied link starts a new one.
262
+ function forgetTerminalFlow(saved, id) {
263
+ try { storage().setItem(TERMINAL_KEY, JSON.stringify(saved.filter(item => item.id !== id))) } catch { /* stale state */ }
264
+ safe(() => {
265
+ const url = new URL(getLocation())
266
+ const [path, query = ''] = url.hash.split('?')
267
+ const params = new URLSearchParams(query); params.delete('analyticsFlow')
268
+ url.hash = params.toString() ? `${path}?${params}` : path
269
+ replaceLocation(url.href)
270
+ })
271
+ }
272
+ function readTerminalFlows() {
273
+ let values = []
274
+ try { values = JSON.parse(storage().getItem(TERMINAL_KEY) || '[]') } catch { /* stale state */ }
275
+ return Array.isArray(values) ? values.filter(item => uuid(item.id) && item.flow === 'session_launch' && Number.isFinite(item.started) && now() - item.started < 1800000).slice(-20) : []
276
+ }
277
+ function terminalFlow(properties, previous) {
278
+ return safe(() => {
279
+ refresh(); if (!state.available || state.sharing !== true) return null
280
+ if (previous) return startFlow('session_launch', properties, previous)
281
+ const id = new URLSearchParams(new URL(getLocation()).hash.split('?')[1]).get('analyticsFlow')
282
+ const installationId = identity().id
283
+ const saved = readTerminalFlows(), restored = saved.find(item => item.id === id && item.installationId === installationId)
284
+ if (id) forgetTerminalFlow(saved, id)
285
+ if (!restored) return startFlow('session_launch', { ...properties, launch_target: 'browser' })
286
+ return { generation, installationId, id: restored.id, flow: 'session_launch', attempt: number(restored.attempt) ?? 1, started: restored.started, properties: sanitizeEvent('flow_started', restored.properties) }
287
+ }, null)
288
+ }
289
+ function templateStarted(record, owner) {
290
+ safe(() => {
291
+ refresh(); if (!state.available || state.sharing !== true) return
292
+ if (record.status === 'ready') { capture('feature_used', { feature: 'template', action: 'built', location_type: analyticsLocation(owner) }); return }
293
+ if (record.status !== 'building') return
294
+ if (templates.size >= 100) templates.delete(templates.keys().next().value)
295
+ templates.set(JSON.stringify([owner?.context, record.name]), { installationId: identity().id, startedAt: record.startedAt, at: now(), location_type: analyticsLocation(owner) })
296
+ })
297
+ }
298
+ function observeTemplates(records) {
299
+ safe(() => {
300
+ for (const record of records) {
301
+ const key = JSON.stringify([record.location?.context, record.name]), tracked = templates.get(key)
302
+ if (!tracked) continue
303
+ if (tracked.installationId !== storage().getItem(ID_KEY) || now() - tracked.at > 1800000) { templates.delete(key); continue }
304
+ // A finished template record has no build start time; a different
305
+ // start time means a newer build replaced the tracked one.
306
+ if ((record.startedAt && record.startedAt !== tracked.startedAt) || record.location?.connected === false) continue
307
+ if (record.status === 'ready') { templates.delete(key); capture('feature_used', { feature: 'template', action: 'built', location_type: tracked.location_type }) }
308
+ else if (record.status === 'failed') templates.delete(key)
309
+ }
310
+ })
311
+ }
312
+ return { configure, setSharing, refresh, capture, observeConsole, observeView, startFlow, exploreFlow, step, finishFlow, ready, templateStarted, observeTemplates,
313
+ submitFeedback, terminalClick, terminalFlow,
314
+ subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) }, getSnapshot: () => state,
315
+ stop: () => { authorized = false; halt(); refresh() },
316
+ }
317
+ }
318
+
319
+ export const analytics = createAnalytics()
320
+ export const analyticsLocation = value => value?.target === 'cloud' ? 'cloud' : value?.remote ? 'ssh' : 'local'
321
+
322
+ // Capture successful product mutations at the shared API boundary. Only fixed
323
+ // method/path patterns select events; no path, response, or request body is sent.
324
+ export function analyticsFeatureRequest(suffix, method) {
325
+ if (method === 'GET' || method === undefined) return
326
+ const rules = [
327
+ [/^\/setups\/save$/, 'setup', 'imported'], [/^\/setups\/[^/]+\/enable$/, 'setup', 'activated'],
328
+ [/^\/egress\/policies$/, 'network', 'rule_saved'], [/^\/org\/members$/, 'group', 'membership_saved'],
329
+ [/^\/files\/[^/]+\/uploads\/[^/]+\/commit$/, 'files', 'uploaded'],
330
+ [/^\/sandboxes\/[^/]+\/start$/, 'sandbox', 'started'], [/^\/sandboxes\/[^/]+\/stop$/, 'sandbox', 'stopped'], [/^\/sandboxes\/[^/]+\/delete$/, 'sandbox', 'deleted'],
331
+ ]
332
+ const rule = rules.find(([pattern]) => pattern.test(suffix))
333
+ if (rule) analytics.capture('feature_used', { feature: rule[1], action: rule[2] })
334
+ }
package/src/lib/api.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // holds the gateway certificate, so nothing here carries a credential.
3
3
 
4
4
  import { computeApiPath, currentComputeTarget, currentCloudOwner } from './compute-target.js'
5
+ import { analyticsFeatureRequest } from './analytics.js'
5
6
 
6
7
  // The local server answers 401 with this code until the browser opens the
7
8
  // tokened link that `openrod` prints; the app then shows how to get back in.
@@ -15,7 +16,10 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
15
16
  const requestSignal = extra => signal && extra ? AbortSignal.any([signal, extra]) : signal ?? extra
16
17
  async function loadContext() {
17
18
  signal?.throwIfAborted()
18
- const response = await fetch(path('/context'), { signal, headers: selected() === 'cloud' && owner ? { 'x-openrod-local-owner': owner } : undefined })
19
+ const response = await fetch(path('/context'), { signal, headers: {
20
+ ...(selected() === 'cloud' && owner ? { 'x-openrod-local-owner': owner } : {}),
21
+ ...(locationContext ? { 'x-openshell-context': locationContext, 'x-openshell-location': '1' } : {}),
22
+ } })
19
23
  const context = await response.json()
20
24
  signal?.throwIfAborted()
21
25
  if (context.code === LINK_REQUIRED) window.dispatchEvent(new Event(LINK_REQUIRED))
@@ -52,7 +56,8 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
52
56
  currentSignal?.throwIfAborted()
53
57
  if (payload.code === LINK_REQUIRED) window.dispatchEvent(new Event(LINK_REQUIRED))
54
58
  else if (response.status === 401 || payload.code === 'CLOUD_OWNER_CHANGED') window.dispatchEvent(new Event('openrod-session-expired'))
55
- if (!response.ok) throw Object.assign(new Error(payload.error ?? `Request failed (${response.status})`), { code: payload.code, sandboxes: payload.sandboxes, fix: payload.fix })
59
+ if (!response.ok) throw Object.assign(new Error(payload.error ?? `Request failed (${response.status})`), { status: response.status, code: payload.code, sandboxes: payload.sandboxes, fix: payload.fix })
60
+ analyticsFeatureRequest(suffix, method)
56
61
  return payload
57
62
  }
58
63
  return {
@@ -65,17 +70,29 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
65
70
  inventory: extraSignal => request('/inventory', { signal: extraSignal, scoped: false }),
66
71
  context: loadContext,
67
72
  contextKey,
73
+ capabilities: () => request('/capabilities', { scoped: false }),
74
+ importCapabilities: () => request('/resource-imports/capabilities'),
75
+ exportResources: body => request('/resource-imports/export', { method: 'POST', body }),
76
+ planImport: body => request('/resource-imports/plan', { method: 'POST', body }),
77
+ importJobs: () => request('/resource-imports'),
78
+ importJob: id => request(`/resource-imports/${encodeURIComponent(id)}`),
79
+ executeImport: id => request(`/resource-imports/${encodeURIComponent(id)}/execute`, { method: 'POST', body: { acknowledged: true } }),
80
+ retryImport: id => request(`/resource-imports/${encodeURIComponent(id)}/retry`, { method: 'POST', body: { acknowledged: true } }),
81
+ cancelImport: id => request(`/resource-imports/${encodeURIComponent(id)}/cancel`, { method: 'POST', body: {} }),
68
82
  connections: extraSignal => request('/connections', { signal: extraSignal, scoped: false }),
69
83
  connectionJob: (id, extraSignal) => request(`/connections/jobs/${encodeURIComponent(id)}`, { signal: extraSignal, scoped: false }),
70
- connect: body => request('/connections/connect', { method: 'POST', body }),
71
- installConnectionDocker: id => request(`/connections/jobs/${encodeURIComponent(id)}/docker`, { method: 'POST', body: { approve: true } }),
72
- installConnectionRuntime: id => request(`/connections/jobs/${encodeURIComponent(id)}/install`, { method: 'POST', body: { method: 'download' } }),
73
- uploadConnectionPackage: (id, file) => request(`/connections/jobs/${encodeURIComponent(id)}/package`, { method: 'POST', body: file, raw: true }),
74
- scanSshHost: body => request('/connections/hosts/scan', { method: 'POST', body }),
75
- addSshHost: token => request('/connections/hosts', { method: 'POST', body: { token } }),
76
- removeSshHost: alias => request('/connections/hosts/remove', { method: 'POST', body: { alias } }),
77
- forgetRemote: () => request('/connections/forget', { method: 'POST', body: {} }),
78
- disconnectRemote: () => request('/connections/disconnect', { method: 'POST', body: {} }),
84
+ // These manage console connections, so an offline working location must
85
+ // never gate reconnecting, forgetting, or selecting a different gateway.
86
+ connect: body => request('/connections/connect', { method: 'POST', body, scoped: false }),
87
+ installConnectionDocker: id => request(`/connections/jobs/${encodeURIComponent(id)}/docker`, { method: 'POST', body: { approve: true }, scoped: false }),
88
+ installConnectionRuntime: id => request(`/connections/jobs/${encodeURIComponent(id)}/install`, { method: 'POST', body: { method: 'download' }, scoped: false }),
89
+ uploadConnectionPackage: (id, file) => request(`/connections/jobs/${encodeURIComponent(id)}/package`, { method: 'POST', body: file, raw: true, scoped: false }),
90
+ scanSshHost: body => request('/connections/hosts/scan', { method: 'POST', body, scoped: false }),
91
+ addSshHost: token => request('/connections/hosts', { method: 'POST', body: { token }, scoped: false }),
92
+ removeSshHost: alias => request('/connections/hosts/remove', { method: 'POST', body: { alias }, scoped: false }),
93
+ restoreSshHost: host => request('/connections/hosts/restore', { method: 'POST', body: { host }, scoped: false }),
94
+ forgetRemote: () => request('/connections/forget', { method: 'POST', body: {}, scoped: false }),
95
+ disconnectRemote: () => request('/connections/disconnect', { method: 'POST', body: {}, scoped: false }),
79
96
  syncLocalCatalog: () => request('/local-catalog', { method: 'POST', body: {} }),
80
97
  gatewayDocker: (fresh = false) => request(`/gateway-docker${fresh ? '?fresh=1' : ''}`, { scoped: false }),
81
98
  connectGatewayDocker: (seen = [], confirm = true) => request('/gateway-docker/connect', { method: 'POST', body: { confirm, seen }, scoped: false }),
@@ -0,0 +1,13 @@
1
+ const messages = {
2
+ 'auth/network-request-failed': 'Couldn’t reach Google. Check your connection and try again.',
3
+ 'auth/popup-closed-by-user': 'Sign-in cancelled.',
4
+ 'auth/cancelled-popup-request': 'Sign-in cancelled.',
5
+ 'auth/popup-blocked': 'Allow popups, then try again.',
6
+ 'auth/unauthorized-domain': 'Sign-in isn’t configured for this address. Contact your administrator.',
7
+ 'auth/user-disabled': 'This account is disabled. Contact your administrator.',
8
+ 'auth/too-many-requests': 'Too many attempts. Try again shortly.',
9
+ }
10
+
11
+ export function cloudSignInError(reason) {
12
+ return messages[reason?.code] || 'Couldn’t sign in. Try again.'
13
+ }
@@ -10,7 +10,7 @@ export function cloudHandoffUrl(origin, nonce, cloud = CLOUD_ORIGIN) {
10
10
  export function isCloudReadyMessage(event, popup, nonce, cloud = CLOUD_ORIGIN) {
11
11
  return Boolean(cloud && popup && event.source === popup && event.origin === cloud
12
12
  && event.data?.type === 'openrod-cloud-ready' && event.data.nonce === nonce
13
- && typeof event.data.ticket === 'string' && /^openrod-user-[a-f0-9]{24}\.[a-f0-9]{64}$/.test(event.data.ticket))
13
+ && typeof event.data.ticket === 'string' && /^[a-z][a-z0-9-]{0,62}\.[a-f0-9]{64}$/.test(event.data.ticket))
14
14
  }
15
15
 
16
16
  export const LOCAL_ORIGIN = 'http://127.0.0.1:4600'
@@ -0,0 +1,95 @@
1
+ import { activityRow, filterActivity, SEVERITY_RANK } from './activity-inventory.js'
2
+ import { locationLabel, resourceKey } from './locations.js'
3
+
4
+ // Each source pages in activity-store's ORDER BY, so the merge must use that exact
5
+ // order or page boundaries skip and repeat events. Keep these keys in step with it.
6
+ const SORTABLE = new Set(['time', 'sandbox', 'scope', 'sandboxId', 'agent', 'category', 'severity', 'logLevel', 'direction', 'verdict', 'outcome', 'process', 'action', 'destination', 'policy', 'session', 'correlation', 'why'])
7
+ // SQLite's BINARY collation compares UTF-8 bytes (code point order), not UTF-16 units.
8
+ const binary = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i < a.length && i < b.length ? a.codePointAt(i) - b.codePointAt(i) : a.length - b.length }
9
+ // json_extract yields NULL, a number (booleans as 0/1) or text, ordered in that sequence.
10
+ const sqlValue = value => typeof value === 'boolean' ? Number(value) : typeof value === 'number' ? (Number.isFinite(value) ? value : null) : value == null ? null : typeof value === 'object' ? JSON.stringify(value) : String(value)
11
+ const typeRank = value => value === null ? 0 : typeof value === 'number' ? 1 : 2
12
+ const compareSql = (a, b) => typeRank(a) - typeRank(b) || (typeof a === 'number' ? a - b : typeof a === 'string' ? binary(a, b) : 0)
13
+ const severityRank = value => { const name = String(value ?? '').replace(/[a-z]+/g, letters => letters.toUpperCase()); return Object.hasOwn(SEVERITY_RANK, name) ? SEVERITY_RANK[name] : -1 }
14
+ export function serverSort(rows, sort) {
15
+ const key = SORTABLE.has(sort?.key) ? sort.key : 'time', direction = sort?.direction === 'asc' ? 1 : -1
16
+ const value = row => key === 'time' ? (Number.isFinite(row.timestamp) ? row.timestamp : 0) : key === 'severity' ? severityRank(row.values.severity) : sqlValue(row.values[key])
17
+ return rows.map(row => [value(row), row]).sort(([av, a], [bv, b]) => direction * compareSql(av, bv) || binary(String(a.event.originalId ?? a.event.id ?? ''), String(b.event.originalId ?? b.event.id ?? '')) || binary(a.key, b.key)).map(([, row]) => row)
18
+ }
19
+
20
+ // Page each source at its own snapshot, then merge. Names and event ids are
21
+ // only unique within a source; mutation plans retain that source throughout.
22
+ export function combinedActivityApi(base, getLocations, apiFor) {
23
+ const cache = new Map(), plans = new Map()
24
+ let sequence = 0
25
+ return {
26
+ ...base,
27
+ async activity(options = {}) {
28
+ const { offset = 0, limit = 50, snapshot = {}, exportAll, ...query } = options
29
+ const now = query.now ?? Date.now()
30
+ const {now: queryNow, ...filters} = query
31
+ const key = JSON.stringify(filters)
32
+ const locations = getLocations(), unavailable = []
33
+ const entries = await Promise.all(locations.map(async (location, index) => {
34
+ const identity = location.id ?? location.context
35
+ const cacheKey = `${identity}:${key}`
36
+ // An export must be complete, so it never substitutes a cached page.
37
+ const prior = exportAll ? null : cache.get(cacheKey)
38
+ if (!location.connected) { unavailable[index] = {label: locationLabel(location), offline: true}; return prior ? {...prior, total:prior.events.length, events: prior.events.map(event => ({...event, location}))} : null }
39
+ try {
40
+ const events = []; let result, sourceOffset = 0, sourceSnapshot = snapshot?.[identity]
41
+ const needed = exportAll ? Infinity : offset + limit
42
+ do {
43
+ result = await apiFor(location).activity({...query, now, offset: sourceOffset, limit: Math.min(1000, needed - sourceOffset), ...(sourceSnapshot != null ? {snapshot: sourceSnapshot} : {})})
44
+ sourceSnapshot = result.snapshot
45
+ events.push(...result.events.map(event => ({...event, originalId: event.id, id: resourceKey({id: event.id, location}), location})))
46
+ sourceOffset += result.events.length
47
+ } while (sourceOffset < needed && sourceOffset < result.total && result.events.length)
48
+ const entry = {...result, events, identity, snapshot: sourceSnapshot}
49
+ if (!exportAll) cache.set(cacheKey, entry)
50
+ return entry
51
+ } catch (error) {
52
+ unavailable[index] = {label: locationLabel(location), error: error.message}
53
+ if (prior) return {...prior, total:prior.events.length, events: prior.events.map(event => ({...event, location: {...location, connected: false}}))}
54
+ return {error}
55
+ }
56
+ }))
57
+ const available = entries.filter(entry => entry && !entry.error)
58
+ if (!available.length && entries.some(entry => entry?.error)) throw entries.find(entry => entry?.error).error
59
+ // filterActivity only filters here; serverSort sets the order.
60
+ const merged = serverSort(filterActivity(available.flatMap(entry => entry.events).map(activityRow), {...query, now, sort: {key: 'time'}}), query.sort).map(row => row.event)
61
+ const total = available.reduce((sum, entry) => sum + entry.total, 0)
62
+ return {events: exportAll ? merged : merged.slice(offset, offset + limit), total, now,
63
+ snapshot: Object.fromEntries(available.map(entry => [entry.identity, entry.snapshot])),
64
+ sandboxes: [...new Set(available.flatMap(entry => entry.sandboxes ?? []))], agents: [...new Set(available.flatMap(entry => entry.agents ?? []))],
65
+ nextOffset: offset + limit < total ? offset + limit : null, unavailable: unavailable.filter(Boolean)}
66
+ },
67
+ async previewActivityDeletion({mode, ids = [], query}) {
68
+ const locations = getLocations()
69
+ const owners = locations.filter(location => mode !== 'selected' || ids.some(id => { try {return JSON.parse(id)[0][0] === (location.target ?? 'local') && JSON.parse(id)[0][1] === location.context} catch {return false} }))
70
+ if (mode === 'selected' && owners.some(location => !location.connected)) throw new Error('Reconnect the source of the selected logs before deleting them.')
71
+ const entries = []
72
+ for (const location of owners.filter(location => location.connected)) {
73
+ const originalIds = ids.filter(id => {try {const [owner] = JSON.parse(id);return owner[0] === (location.target ?? 'local') && owner[1] === location.context} catch {return false}}).map(id => JSON.parse(id)[1])
74
+ const plan = await apiFor(location).previewActivityDeletion({mode, ...(mode === 'selected' ? {ids: originalIds} : {}), query})
75
+ entries.push({location, plan})
76
+ }
77
+ const token = `combined:${++sequence}`
78
+ plans.set(token, {entries, deleted: 0})
79
+ return {token, mode, count: entries.reduce((sum, entry) => sum + entry.plan.count, 0), expiresAt: entries.length ? Math.min(...entries.map(entry => entry.plan.expiresAt)) : Date.now(),
80
+ sources: entries.map(entry => ({label: locationLabel(entry.location), count: entry.plan.count})), skipped: owners.filter(location => !location.connected).map(locationLabel)}
81
+ },
82
+ async deleteActivity(token) {
83
+ const plan = plans.get(token)
84
+ if (!plan) throw new Error('Review the log deletion again.')
85
+ // Remove successful entries so retrying a partial failure never repeats it.
86
+ while (plan.entries.length) {
87
+ const entry = plan.entries[0]
88
+ const result = await apiFor(entry.location).deleteActivity(entry.plan.token)
89
+ plan.deleted += result.deleted; plan.entries.shift()
90
+ }
91
+ plans.delete(token)
92
+ return {deleted: plan.deleted}
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,6 @@
1
+ // Opening a specific connection must never redirect the user to another host.
2
+ export function connectionHost(connections, initialHost, chosenHost) {
3
+ const pending = connections.job && ['working', 'needs-install', 'needs-docker'].includes(connections.job.status) ? connections.job : null
4
+ const host = chosenHost ?? initialHost ?? pending?.host ?? connections.active?.host ?? connections.hosts[0]?.name ?? ''
5
+ return { host, job: pending?.host === host ? pending : null }
6
+ }
@@ -0,0 +1,63 @@
1
+ import { sourceOf,hostOf,isIp,portOf } from './policy-sources.js'
2
+ import { blockPatterns,hostMatches } from './egress.js'
3
+ import { resourceKey } from './locations.js'
4
+
5
+ export function egressRows(sandboxes, events, org) {
6
+ const dest = new Map()
7
+ const allowedBy = new Map()
8
+ const perSandbox = new Map()
9
+ for (const s of sandboxes) {
10
+ const hosts = new Set()
11
+ const counts = {}
12
+ for (const r of s.rules) {
13
+ const src = sourceOf(r.key)
14
+ counts[src] = (counts[src] ?? 0) + 1
15
+ for (const e of r.endpoints) {
16
+ if (e.blocked) continue
17
+ hosts.add(e.host)
18
+ const d = dest.get(e.host) ?? { host: e.host, sources: new Set(), access: new Set(), ports: new Set(), sandboxes: new Set(), grants: [], hits: 0, hitsBySandbox: new Map() }
19
+ d.sources.add(src); d.access.add(e.access); e.ports.forEach((p) => d.ports.add(p)); d.sandboxes.add(s.name)
20
+ let grant = d.grants.find((g) => g.sandbox === s.name && g.key === r.key)
21
+ if (!grant) {
22
+ grant = { sandbox: s.name, key: r.key, source: src, access: [], ports: [] }
23
+ d.grants.push(grant)
24
+ }
25
+ grant.access.push(e.access)
26
+ grant.ports.push(...e.ports)
27
+ dest.set(e.host, d)
28
+ }
29
+ }
30
+ allowedBy.set(s.name, hosts)
31
+ perSandbox.set(s.name, { counts, blocked: 0 })
32
+ }
33
+ const block = new Map()
34
+ for (const e of events) {
35
+ if (e.kind !== "audit" || !e.verdict) continue
36
+ const host = hostOf(e.destination)
37
+ if (!host || isIp(host)) continue
38
+ if (e.verdict === "allowed") { const d = dest.get(host); if (d) { d.hits += 1; d.hitsBySandbox.set(e.sandbox, (d.hitsBySandbox.get(e.sandbox) ?? 0) + 1) }; continue }
39
+ if (e.verdict !== "denied" || allowedBy.get(e.sandbox)?.has(host)) continue
40
+ const b = block.get(host) ?? { host, port: portOf(e.destination), sandboxes: new Set(), attempts: 0, programs: new Set(), lastAt: null }
41
+ b.attempts += 1; b.sandboxes.add(e.sandbox)
42
+ if (e.binary) b.programs.add(e.binary)
43
+ if (e.at && (!b.lastAt || e.at > b.lastAt)) b.lastAt = e.at
44
+ block.set(host, b)
45
+ const p = perSandbox.get(e.sandbox); if (p) p.blocked += 1
46
+ }
47
+ // Hosts a block already decides are not waiting for anyone.
48
+ const decided = blockPatterns([...(org?.org?.blocked ?? []), ...(org?.policies ?? []).filter((p) => p.action === "block").flatMap((p) => p.destinations)])
49
+ return {
50
+ destinations: [...dest.values()].sort((a, b) => b.sandboxes.size - a.sandboxes.size || b.hits - a.hits || a.host.localeCompare(b.host)),
51
+ blocked: [...block.values()].filter((b) => !decided.some((pattern) => hostMatches(pattern, b.host))).sort((a, b) => b.sandboxes.size - a.sandboxes.size || b.attempts - a.attempts),
52
+ perSandbox,
53
+ }
54
+ }
55
+
56
+ export function combinedEgressInventory(sources) {
57
+ const inventories=sources.map(source=>({location:source.location,...egressRows(source.data?.fleetPolicy?.sandboxes ?? [],source.data?.activity?.events ?? [],source.data?.org)}))
58
+ return {
59
+ destinations:inventories.flatMap(source=>source.destinations.map(row=>({...row,location:source.location}))),
60
+ blocked:inventories.flatMap(source=>source.blocked.map(row=>({...row,location:source.location}))),
61
+ perSandbox:new Map(inventories.flatMap(source=>[...source.perSandbox].map(([name,row])=>[resourceKey({name,location:source.location}),row]))),
62
+ }
63
+ }