#!/usr/bin/env node import { readFile } from 'node:fs/promises' import { randomBytes } from 'node:crypto' import { existsSync } from 'node:fs' import { join } from 'node:path' import { JsonlLogManager } from '../../modules/log-manager/index.mjs' import { createManagementServer, listenManagement } from './server.mjs' import { LocalApiClient } from '../../modules/updater/lib/client.mjs' import { consumeInternalCapability } from '../../../platform/lib/access-capability.mjs' import { MetadataClient, NpmRegistryClient } from '../../modules/updater/lib/metadata.mjs' import { TargetPreparer } from '../../modules/updater/lib/preparer.mjs' import { UpdateCoordinator } from '../../modules/updater/lib/coordinator.mjs' import { UpdateScheduler } from '../../modules/updater/lib/scheduler.mjs' import { UpdateStateStore } from '../../modules/updater/lib/state.mjs' import { AutomaticCheckStateStore } from '../../modules/updater/lib/automatic-check.mjs' import { PlatformActivator } from '../../modules/updater/lib/activator.mjs' import { UpdateJournal } from '../../modules/updater/lib/journal.mjs' import { SnapshotClient } from '../../modules/updater/lib/snapshot-client.mjs' import { reconcileRecoveredState, resumeInterruptedReconcile } from '../../modules/updater/lib/recovery.mjs' import { ChannelStateStore } from '../../modules/updater/lib/channel-state.mjs' import { CompleteStateRecovery } from '../../modules/updater/lib/rollback.mjs' import { PlatformPaths } from '../../../platform/lib/paths.mjs' import { readDeploymentStatus } from '../../../platform/lib/deployment-status.mjs' import { parseImageInventory } from '../../../platform/lib/deployment-contracts.mjs' import { SettingsDocumentStore } from './settings-document.mjs' import { markUserPluginRestartState, UserPluginInventory } from '../../modules/plugin-manager/user-inventory.mjs' import { UserPluginJournal } from '../../modules/plugin-manager/user-journal.mjs' import { UserPluginSelectionStore } from '../../modules/plugin-manager/user-state.mjs' import { UserPluginTransactionManager } from '../../modules/plugin-manager/user-transaction.mjs' import { UserSkillManager } from './user-skills.mjs' import { createScopedFetch } from './scoped-fetch.mjs' import { OutboundProxyControlClient } from './outbound-proxy-client.mjs' import { ProviderInventory } from './provider-inventory.mjs' import { DshUpstreamAuthentication } from '../gateway/lib/dsh-upstream-auth.mjs' import { PROXY_SCOPE_CATALOG } from '../outbound-proxy/lib/scope-catalog.mjs' import { defaultProxyConfiguration } from '../outbound-proxy/lib/contracts.mjs' import { requestOrigin } from '../gateway/lib/browser-auth.mjs' import { createRestrictedCliServer, restrictedCliRoute } from './restricted-cli.mjs' import qrcode from 'qrcode-generator' const dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform' const runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform' const paths = new PlatformPaths(dataRoot, runRoot) const seedRoot = process.env.DSH_PLATFORM_SEED ?? '/opt/dsh-platform/seed' const imageInventory = parseImageInventory(await readFile(join(seedRoot, 'inventory.json'))) const trust = new LocalApiClient(paths.trustSocket) const bootstrap = new LocalApiClient(paths.bootstrapSocket) const access = new LocalApiClient(paths.accessSocket) const snapshotApi = new LocalApiClient(paths.snapshotSocket) const userSkillApi = new LocalApiClient(paths.userSkillSocket) const outboundProxy = new OutboundProxyControlClient(paths.proxyControlSocket) const dshHome = process.env.DSH_HOME ?? '/data/dsh' const logs = new JsonlLogManager({ root: paths.logsRoot, maxBytes: Number(process.env.DSH_LOG_MAX_BYTES ?? 104857600), retentionDays: Number(process.env.DSH_LOG_RETENTION_DAYS ?? 14), output: { stdout: process.stdout, stderr: process.stderr }, }) const updateFetch = createScopedFetch('updates') const dshUpstreamAuthentication = new DshUpstreamAuthentication({ socketPath: paths.dshLifecycleSocket }) const providerInventory = new ProviderInventory({ cachePath: paths.proxyProviderInventoryPath, authentication: dshUpstreamAuthentication, }) function qrCodeDataUri(value) { const code = qrcode(0, 'M') code.addData(value, 'Byte') code.make() const svg = code.createSvgTag({ cellSize: 5, margin: 10, scalable: true }) return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` } let lastProxyConfiguration function unavailableProxyConfiguration() { const fallback = lastProxyConfiguration ?? { ...defaultProxyConfiguration(), revision: null, routeHealth: {}, lastTest: null, } return Object.freeze({ ...fallback, componentReady: false, configurationAvailable: lastProxyConfiguration !== undefined, scopeCatalog: PROXY_SCOPE_CATALOG, }) } function proxySnapshot(view) { return Object.freeze({ revision: view.revision, configuration: Object.freeze({ schema: view.schema, enabled: view.enabled, proxy: view.proxy, scopes: view.scopes, environment: view.environment, modelApi: view.modelApi, noProxy: view.noProxy, bypass: view.bypass, }), }) } const proxyConfiguration = async () => { try { lastProxyConfiguration = await outboundProxy.configuration() return Object.freeze({ ...lastProxyConfiguration, configurationAvailable: true, scopeCatalog: PROXY_SCOPE_CATALOG, }) } catch (error) { if (error?.code === 'PROXY_MANAGER_UNAVAILABLE') return unavailableProxyConfiguration() throw error } } void proxyConfiguration().catch(error => logs.diagnostic('platform-management', 'proxy.configuration.cache.failed', { error })) logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) }) await logs.diagnostic('platform-management', 'management.starting', { imageBuildId: imageInventory.imageBuildId, targetSequence: imageInventory.targetSequence, }) const metadata = new MetadataClient({ baseUrl: process.env.DSH_UPDATE_METADATA_URL, trust, fetchImpl: updateFetch, retryRequestTimeoutMs: 15_000, }) const preparer = new TargetPreparer({ untrustedRoot: paths.downloadsRoot, trust, fetchImpl: updateFetch }) const activator = new PlatformActivator({ dataRoot, runRoot, stage0: trust }) const journal = new UpdateJournal(join(paths.updaterStateRoot, 'transaction.json')) const snapshots = new SnapshotClient(snapshotApi, 'dsh') const completeRecovery = new CompleteStateRecovery({ journal, snapshots, activator }) const automaticChecks = new AutomaticCheckStateStore(join(paths.updaterStateRoot, 'automatic-check.json')) const coordinator = new UpdateCoordinator({ metadata, preparer, activator, state: new UpdateStateStore(join(paths.updaterStateRoot, 'status.json')), npm: new NpmRegistryClient({ fetchImpl: updateFetch, retryRequestTimeoutMs: 15_000 }), journal, snapshots, probationSeconds: Number(process.env.DSH_EXPERIMENTAL_PROBATION_SECONDS ?? 120), channelState: new ChannelStateStore(join(paths.updaterStateRoot, 'channel.json')), completeRecovery, automaticChecks, allowUnavailableMetadata: imageInventory.authority === 'development', report: (message, fields) => logs.diagnostic('updater', message, fields), }) const settingsDocument = new SettingsDocumentStore(dshHome) const listBundledPlugins = async () => (await bootstrap.request('GET', '/v1/system-plugins')).plugins const listSystemSkills = async () => (await bootstrap.request('GET', '/v1/system-skills')).skills const userSkills = new UserSkillManager({ dshHome, agentsHome: process.env.DSH_AGENTS_HOME ?? '/home/node/.agents', }) const userPluginInventory = new UserPluginInventory({ dshHome, selectionPath: paths.userPluginStatePath, systemPluginNames: async () => (await listBundledPlugins()).map(plugin => `@dsh-docker/${plugin.id}`), }) const userPluginTransactions = new UserPluginTransactionManager({ inventory: userPluginInventory, selectionStore: new UserPluginSelectionStore(paths.userPluginStatePath), snapshots: new SnapshotClient(snapshotApi, 'user-plugin'), journal: new UserPluginJournal(paths.userPluginJournalPath), pauseDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'), restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'), report: (message, fields) => logs.diagnostic('user-plugin-manager', message, fields), }) const initialUserPluginTransaction = await userPluginTransactions.recoverBeforeDshStart() if (initialUserPluginTransaction !== undefined) { await logs.diagnostic('user-plugin-manager', 'user-plugin.startup-state.reconciled', { taskId: initialUserPluginTransaction.taskId, phase: initialUserPluginTransaction.phase, recoveryResult: initialUserPluginTransaction.recoveryResult, }) } let loadedUserPluginInventory const markUserPluginsLoaded = async () => { loadedUserPluginInventory = await userPluginInventory.read() return loadedUserPluginInventory } const listUserPlugins = async () => markUserPluginRestartState( await userPluginInventory.read(), loadedUserPluginInventory, ) const waitForBootstrapStartup = async () => { for (;;) { try { const status = await bootstrap.status() if (status.startupComplete === true) return status } catch (error) { await logs.diagnostic('user-plugin-manager', 'bootstrap.startup-status.retrying', { error, level: 'warning', }) } await new Promise(resolve => setTimeout(resolve, 100)) } } const scheduler = new UpdateScheduler({ check: () => coordinator.check('automatic'), onError: error => logs.diagnostic('updater', 'update.automatic-check.failed', { error }), }) let server const restrictedCliToken = randomBytes(32).toString('base64url') server = createManagementServer({ coordinator, logs, authorizeInternal: async request => { const token = request.headers['x-dsh-internal-capability'] const audience = request.headers['x-dsh-internal-capability-audience'] === 'plugin' ? 'plugin' : 'management' const restrictedToken = request.headers['x-dsh-restricted-cli'] if (restrictedToken === restrictedCliToken && restrictedCliRoute(request.method ?? 'GET', request.url ?? '/')) return true if (typeof token !== 'string') return false if (['/_dsh_platform/api/v1/auth-settings', '/_dsh_platform/api/v1/auth-totp/enrollments', '/_dsh_platform/api/v1/auth-totp/enrollments/confirm', '/_dsh_platform/api/v1/auth-totp/enrollments/cancel', '/_dsh_platform/api/v1/auth-totp/disable-confirmations', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/confirm', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/cancel', '/_dsh_platform/api/v1/management-origin/transitions', '/_dsh_platform/api/v1/management-origin/transitions/commit', '/_dsh_platform/api/v1/management-origin/transitions/failure', '/_dsh_platform/api/v1/auth-sessions/revoke'].includes(request.url)) return true return consumeInternalCapability(access, { token, audience, method: request.method ?? 'GET', target: request.url ?? '/', }) }, createManagementOriginTransition: async (value, request) => { return access.request('POST', '/v1/management/transitions', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, commitManagementOriginTransition: async (value, request) => { return access.request('POST', '/v1/management/transitions/commit', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, reportManagementOriginTransitionFailure: async value => { if (value?.stage !== 'browser-probe' || !['compat', 'isolated'].includes(value?.mode)) { throw Object.assign(new Error('Management origin transition failure report is invalid'), { statusCode: 400 }) } await logs.diagnosticRateLimited('management-origin.probe.failed', 'platform-management', 'management-origin.probe.failed', { level: 'warning', stage: value.stage, mode: value.mode, }) return { reported: true } }, getAuthenticationSettings: async request => { return access.request('POST', '/v1/management/settings', { internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, origin: requestOrigin(request, { requireHeader: false }), }) }, updateAuthenticationSettings: async (value, request) => { return access.request('POST', '/v1/management/auth-settings', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, beginTotpEnrollment: async (value, request) => { const enrollment = await access.request('POST', '/v1/management/totp/enrollments', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) return { ...enrollment, qrCode: qrCodeDataUri(enrollment.uri), uri: undefined } }, confirmTotpEnrollment: async (value, request) => { return access.request('POST', '/v1/management/totp/enrollments/confirm', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, cancelTotpEnrollment: async (value, request) => { return access.request('POST', '/v1/management/totp/enrollments/cancel', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, beginTotpDisableConfirmation: async (value, request) => { return access.request('POST', '/v1/management/totp/disable-confirmations', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, confirmTotpDisable: async (value, request) => { return access.request('POST', '/v1/management/totp/disable-confirmations/confirm', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, cancelTotpDisableConfirmation: async (value, request) => { return access.request('POST', '/v1/management/totp/disable-confirmations/cancel', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, revokeAuthenticationSessions: async (value, request) => { return access.request('POST', '/v1/management/sessions/revoke', { ...value, internalCapability: request.headers['x-dsh-internal-capability'], method: request.method, target: request.url, }) }, platformStatus: async () => ({ ...await readDeploymentStatus(paths.deploymentStatusPath), trust: await trust.status(), dshLifecycle: (await bootstrap.status()).dshLifecycle, }), startDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/resume'), stopDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'), restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'), resetRuntime: () => bootstrap.request('POST', '/v1/deployments/runtime/reset'), listBundledPlugins, configureBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/action', { id, action }), recoverBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/recovery-action', { id, action }), discardBundledPluginChanges: () => bootstrap.request('POST', '/v1/system-plugins/discard'), listSystemSkills, configureSystemSkill: (skillId, action) => bootstrap.request('POST', '/v1/system-skills/action', { skillId, action }), listUserSkills: () => userSkills.list(), validateUserSkillAction: value => userSkills.validate(value), configureUserSkill: value => userSkillApi.request('POST', '/v1/action', value), listUserPlugins, markUserPluginsLoaded, validateUserPluginActions: value => userPluginTransactions.validate(value), applyUserPluginActions: value => userPluginTransactions.apply(value), initialUserPluginTransaction, recoverUserPluginTransaction: initialUserPluginTransaction?.phase === 'restarting' ? async () => { const status = await waitForBootstrapStartup() try { const health = await bootstrap.request('GET', '/v1/health') const dsh = health.components?.find(component => component.id === 'dsh-runtime') return userPluginTransactions.completeDshStartup({ healthy: status.recoveryMode === null && dsh?.healthy === true, error: status.recoveryMode, }) } catch (error) { return userPluginTransactions.completeDshStartup({ healthy: false, error: error instanceof Error ? error.message : String(error), }) } } : undefined, settingsDocument, privilegedMutationActive: () => existsSync(paths.maintenanceLeasePath), updateAutomaticCheck: async value => { const state = await automaticChecks.configure(value) scheduler.configure(state.automaticCheck) return state.automaticCheck }, getProxyConfiguration: proxyConfiguration, updateProxyConfiguration: async value => Object.freeze({ ...await outboundProxy.updateConfiguration(value), scopeCatalog: PROXY_SCOPE_CATALOG, }), listProxyProviders: async () => { const configuration = await proxyConfiguration() return providerInventory.list(proxySnapshot(configuration)) }, startProxyTest: value => outboundProxy.startTest(value), getProxyTest: taskId => outboundProxy.test(taskId), cancelProxyTest: taskId => outboundProxy.cancelTest(taskId), }) await listenManagement(server, paths.managementSocket) const restrictedCliServer = createRestrictedCliServer({ managementSocketPath: paths.managementSocket, token: restrictedCliToken, }) await listenManagement(restrictedCliServer, paths.managementCliSocket) void waitForBootstrapStartup() .then(markUserPluginsLoaded) .catch(error => logs.diagnostic('user-plugin-manager', 'user-plugin.loaded-state.capture.failed', { error })) scheduler.configure((await automaticChecks.read()).automaticCheck) let recovered try { recovered = await reconcileRecoveredState({ journal, state: coordinator.state }) } catch (error) { await logs.diagnostic('updater', 'update.recovery-state.failed', { error }) throw error } const { transaction, persisted } = recovered const journalOwnsState = transaction !== undefined && persisted.taskId === transaction.transactionId const resumeUpdate = !journalOwnsState && !['idle', 'success', 'failed'].includes(persisted.status) if (resumeUpdate) { setImmediate(() => { try { resumeInterruptedReconcile({ coordinator, persisted, report: (message, fields) => logs.diagnostic('updater', message, fields), audit: (message, fields) => logs.diagnostic('audit', message, { stream: 'audit', ...fields }), }) } catch (error) { void logs.diagnostic('updater', 'update.resume.failed', { error }) } }) } await logs.diagnostic('platform-management', 'management.ready', { resumedUpdate: resumeUpdate }) let stopping = false const stop = signal => { if (stopping) return stopping = true scheduler.stop() void logs.diagnostic('platform-management', 'management.stopping', { signal }).then(async () => { await updateFetch.close().catch(error => logs.diagnostic('platform-management', 'proxy.dispatcher.close.failed', { error })) let closed = 0 const finish = () => { closed += 1 if (closed === 2) void logs.diagnostic('platform-management', 'management.stopped').finally(() => process.exit(0)) } restrictedCliServer.close(finish) server.close(finish) }) } process.once('SIGINT', () => stop('SIGINT')) process.once('SIGTERM', () => stop('SIGTERM'))