dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x

This commit is contained in:
hermes committed 2026-10-01 19:21:19 +00:00
commit e6dcef9bf5
302 files changed
+75178

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# Container source layout
The container source tree follows runtime ownership boundaries:
```text
container/
├── platform/ Stage-0, Bootstrap, shared contracts, and release tools
├── control-plane/ Bootstrap-owned services and platform managers
├── environment/ Reloadable definition, workloads, and packaged resources
└── test/ Container and Compose integration checks
```
`platform/` contains the trust boundary, Bootstrap, and shared protocol code. Persistent services and managers belong under `control-plane/`; the complete Container Environment source belongs under `environment/`. A Component Manifest lives with the implementation it starts. Environment inputs which are not processes live under `environment/resources/`: DSH overlays under `resources/plugins/<plugin>/`, exact source patches under `resources/patches/`, and Agent guidance under `resources/skills/<skill>/`. Environment-owned components, patches, System Plugins, and System Skills are identified by `id` and SHA-256 rather than independent release versions.
Bootstrap archives preserve the same `platform/` and `control-plane/` top-level layout. Image and Store References therefore resolve through the same `/run/dsh-platform/views/bootstrap` runtime view, without compatibility links or duplicate component copies.
@@ -0,0 +1,25 @@
{
"schema": 1,
"components": [
{
"id": "gateway",
"source": "services/gateway/component.json"
},
{
"id": "access-manager",
"source": "services/access-manager/component.json"
},
{
"id": "outbound-proxy",
"source": "services/outbound-proxy/component.json"
},
{
"id": "platform-recovery",
"source": "hooks/recovery/component.json"
},
{
"id": "platform-management",
"source": "services/management/component.json"
}
]
}
@@ -0,0 +1,201 @@
#!/usr/bin/env node
import { request } from 'node:http'
import { realpathSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const RUN_ROOT = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'
function fetchResponse(host, port, path, options = {}) {
return new Promise((resolve, reject) => {
const outgoing = request({ hostname: host, port, path, ...options }, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.once('end', () => {
resolve({
body: Buffer.concat(chunks),
headers: response.headers,
status: response.statusCode,
})
})
})
outgoing.setTimeout(2_000, () => outgoing.destroy(new Error('DSH Web request timed out')))
outgoing.once('error', reject)
outgoing.end(options.body)
})
}
async function fetch(host, port, path, options = {}) {
const response = await fetchResponse(host, port, path, options)
if (response.status !== 200) throw new Error(`${path} returned HTTP ${String(response.status)}`)
return response.body
}
function delay(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
function lifecycleReadiness() {
const body = Buffer.from('{}')
return new Promise((resolve, reject) => {
const outgoing = request({
socketPath: `${RUN_ROOT}/dsh-lifecycle.sock`,
path: '/v1/runtime/readiness',
method: 'POST',
headers: {
'content-length': body.byteLength,
'content-type': 'application/json',
},
}, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.once('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`DSH lifecycle readiness returned HTTP ${String(response.statusCode)}`))
return
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
resolve({
generation: typeof value?.generation === 'string' ? value.generation : null,
ready: value?.ready === true,
readyUrl: typeof value?.readyUrl === 'string' ? value.readyUrl : null,
})
} catch (error) {
reject(error)
}
})
})
outgoing.setTimeout(2_000, () => outgoing.destroy(new Error('DSH lifecycle readiness timed out')))
outgoing.once('error', reject)
outgoing.end(body)
})
}
async function waitForManagedReady(timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const readiness = await lifecycleReadiness()
if (readiness.ready) return readiness
await delay(100)
}
throw new Error('DSH managed Web boot did not complete')
}
async function authenticationHeaders(host, port, readyUrl) {
if (readyUrl === null) return {}
const url = new URL(readyUrl)
if (url.protocol !== 'http:' || url.hostname !== host || Number(url.port) !== port
|| url.pathname !== '/' || url.username !== '' || url.password !== '' || url.hash !== ''
|| url.searchParams.getAll('token').length !== 1) {
throw new Error('DSH Web readiness URL is invalid')
}
const exchange = await fetchResponse(host, port, `${url.pathname}${url.search}`, {
headers: { 'cache-control': 'no-store' },
})
const cookie = exchange.headers['set-cookie']?.[0]?.split(';', 1)[0]
// dsh 0.2.x redirects to './' instead of '/'
if (exchange.status !== 303 || !['/', './'].includes(exchange.headers.location) || typeof cookie !== 'string' || cookie === '') {
throw new Error('DSH Web authentication exchange failed')
}
return { cookie }
}
async function verifyBootManifest(host, port, headers) {
const html = (await fetch(host, port, '/', { headers })).toString('utf8')
const match = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*(\{.*?\})<\/script>/s.exec(html)
if (match === null) throw new Error('DSH boot manifest is unavailable')
const manifest = JSON.parse(match[1])
if (!Array.isArray(manifest.entries)) throw new Error('DSH boot manifest entries are invalid')
if (manifest.batches !== undefined && !Array.isArray(manifest.batches)) {
throw new Error('DSH boot manifest batches are invalid')
}
const resources = [
...manifest.entries.map(entry => entry?.url),
...(manifest.batches ?? []).map(batch => batch?.url),
]
await Promise.all([...new Set(resources)].map(async resource => {
// dsh 0.2.x emits plugin URLs relative to the root ('plugins/…'), 0.1.x absolute ('/plugins/…')
const url = typeof resource === 'string' ? `/${resource.replace(/^\/+/, '')}` : resource
if (typeof resource !== 'string' || !url.startsWith('/plugins/')) {
throw new Error('DSH boot manifest contains an invalid Plugin URL')
}
await fetch(host, port, url, { headers })
}))
return JSON.stringify({
batches: manifest.batches ?? [],
entries: manifest.entries,
rev: manifest.rev ?? null,
})
}
async function verifyPluginInventory(host, port, headers) {
const body = JSON.stringify({
type: 'client-request',
rpcId: 'dsh-platform-readiness',
method: 'pluginInventory/list',
payload: { args: {} },
})
const response = JSON.parse((await fetch(host, port, '/api/pluginInventory/list', {
method: 'POST',
headers: {
...headers,
'content-length': Buffer.byteLength(body),
'content-type': 'application/json',
},
body,
})).toString('utf8'))
if (response?.rpcId !== 'dsh-platform-readiness' || response?.result?.ok !== true) {
const message = response?.result?.error?.message
throw new Error(`DSH Plugin inventory is unavailable${typeof message === 'string' ? `: ${message}` : ''}`)
}
const entries = response.result.value?.entries
if (!Array.isArray(entries)) throw new Error('DSH Plugin inventory entries are invalid')
const unhealthy = entries.filter(entry => entry?.enabled === true && entry?.fiberPhase !== 'active')
if (unhealthy.length > 0) {
const detail = unhealthy.map(entry => `${String(entry?.moduleName ?? entry?.entryId ?? 'unknown')} (${String(entry?.fiberPhase)})`).join(', ')
throw new Error(`DSH Plugins are not active: ${detail}`)
}
return JSON.stringify(entries.map(entry => ({
enabled: entry?.enabled === true,
entryId: entry?.entryId ?? null,
fiberPhase: entry?.fiberPhase ?? null,
moduleName: entry?.moduleName ?? null,
})).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))
}
function sameLifecycle(left, right) {
return left?.generation === right?.generation && left?.readyUrl === right?.readyUrl
}
export async function verifyDshWebReady({
host = '127.0.0.1',
port = 3079,
managedReady = waitForManagedReady,
} = {}) {
const firstReadiness = await managedReady()
const headers = await authenticationHeaders(host, port, firstReadiness?.readyUrl ?? null)
const firstManifest = await verifyBootManifest(host, port, headers)
const firstInventory = await verifyPluginInventory(host, port, headers)
const middleReadiness = await managedReady()
if (!sameLifecycle(firstReadiness, middleReadiness)) {
throw new Error('DSH lifecycle changed during Web readiness verification')
}
const secondManifest = await verifyBootManifest(host, port, headers)
const secondInventory = await verifyPluginInventory(host, port, headers)
const finalReadiness = await managedReady()
if (!sameLifecycle(firstReadiness, finalReadiness)) {
throw new Error('DSH lifecycle changed during Web readiness verification')
}
if (firstManifest !== secondManifest) {
throw new Error('DSH boot manifest changed during Web readiness verification')
}
if (firstInventory !== secondInventory) {
throw new Error('DSH Plugin inventory changed during Web readiness verification')
}
}
if (process.argv[1] !== undefined
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
await verifyDshWebReady()
}
@@ -0,0 +1,21 @@
{
"schema": 1,
"id": "platform-recovery",
"type": "oneshot",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/hooks/recovery/index.mjs"],
"timeoutSeconds": 120
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": null,
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { realpathSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PlatformActivator } from '../../modules/updater/lib/activator.mjs'
import { UpdateJournal } from '../../modules/updater/lib/journal.mjs'
import { recoverInterruptedUpdate } from '../../modules/updater/lib/recovery.mjs'
import { PersistentStateSnapshots } from '../../modules/updater/lib/snapshots.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
export async function recoverPlatformUpdateBeforeDshStart({
dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform',
runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
dshHome = process.env.DSH_HOME ?? '/data/dsh',
} = {}) {
const paths = new PlatformPaths(dataRoot, runRoot)
return recoverInterruptedUpdate({
journal: new UpdateJournal(join(paths.updaterStateRoot, 'transaction.json')),
snapshots: new PersistentStateSnapshots({ root: paths.snapshotsRoot, sourceRoot: dshHome }),
activator: new PlatformActivator({ dataRoot, runRoot }),
resume: false,
})
}
if (process.argv[1] !== undefined
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
await recoverPlatformUpdateBeforeDshStart()
}
@@ -0,0 +1,110 @@
import { spawn } from 'node:child_process'
import { lstat, mkdir, readdir, readlink, rm } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { FileManagerError } from './index.mjs'
export const ARCHIVE_FORMATS = Object.freeze({
zip: { extension: '.zip', command: 'zip' },
'7z': { extension: '.7z', command: '7z' },
'tar.gz': { extension: '.tar.gz', command: 'tar' },
})
export function normalizeArchiveFormat(value) {
if (!Object.hasOwn(ARCHIVE_FORMATS, value)) throw new FileManagerError('archive format is invalid')
return value
}
function run(command, args, { cwd, signal, capture = false } = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, { cwd, signal, stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] })
let errorOutput = ''
let output = ''
if (capture) {
child.stdout.setEncoding('utf8')
child.stdout.on('data', chunk => { if (output.length < 8 * 1024 * 1024) output += chunk })
}
child.stderr.setEncoding('utf8')
child.stderr.on('data', chunk => { if (errorOutput.length < 16_384) errorOutput += chunk })
child.once('error', reject)
child.once('exit', (code, signalName) => {
if (code === 0) resolvePromise(output)
else reject(new FileManagerError(
`archive tool failed${errorOutput.trim() === '' ? '' : `: ${errorOutput.trim()}`}`,
400,
signalName === 'SIGTERM' ? 'FILE_TASK_CANCELLED' : 'ARCHIVE_TOOL_FAILED',
))
})
})
}
function validateEntryNames(names) {
for (const original of names) {
const name = original.replace(/^(\.\/)+/u, '')
if (name === '') continue
if (name.includes('\0') || name.startsWith('/') || name.split('/').includes('..')) {
throw new FileManagerError('archive contains an unsafe path', 400, 'ARCHIVE_UNSAFE')
}
}
}
function inside(root, path) {
const value = relative(root, path)
return value === '' || (!value.startsWith(`..${sep}`) && value !== '..')
}
export async function validateExtractedTree(root) {
const pending = [root]
let entries = 0
let bytes = 0
while (pending.length > 0) {
const path = pending.pop()
const details = await lstat(path)
if (details.isDirectory() && !details.isSymbolicLink()) {
for (const name of await readdir(path)) pending.push(join(path, name))
} else if (details.isSymbolicLink()) {
const target = await readlink(path)
if (target.startsWith('/') || !inside(root, resolve(dirname(path), target))) {
throw new FileManagerError('archive contains a symbolic link outside its destination', 400, 'ARCHIVE_UNSAFE')
}
} else if (!details.isFile()) {
throw new FileManagerError('archive contains an unsupported special file', 415, 'ARCHIVE_UNSAFE')
}
entries += 1
if (details.isFile()) bytes += details.size
}
return { entries: Math.max(0, entries - 1), bytes }
}
export async function createArchive({ format, sourceRoot, output, entries = ['.'], signal }) {
normalizeArchiveFormat(format)
if (!Array.isArray(entries) || entries.length === 0 || entries.some(entry => typeof entry !== 'string' || entry === '' || entry.includes('\0') || entry.includes('/'))) {
throw new FileManagerError('archive entry names are invalid')
}
if (format === 'zip') await run('zip', ['-q', '-r', output, '--', ...entries], { cwd: sourceRoot, signal })
else if (format === '7z') await run('7z', ['a', '-bd', '-y', '-t7z', output, '--', ...entries], { cwd: sourceRoot, signal })
else await run('tar', ['-czf', output, '-C', sourceRoot, '--', ...entries], { signal })
}
export async function extractArchive({ format, archive, output, signal }) {
normalizeArchiveFormat(format)
await mkdir(output, { recursive: true })
try {
if (format === 'zip') {
validateEntryNames((await run('unzip', ['-Z1', archive], { signal, capture: true })).split('\n'))
await run('unzip', ['-qq', archive, '-d', output], { signal })
} else if (format === '7z') {
const listed = await run('7z', ['l', '-slt', '--', archive], { signal, capture: true })
validateEntryNames(listed.split('\n').filter(line => line.startsWith('Path = ')).slice(1).map(line => line.slice(7)))
await run('7z', ['x', '-bd', '-y', `-o${output}`, '--', archive], { signal })
} else {
validateEntryNames((await run('tar', ['-tzf', archive], { signal, capture: true })).split('\n'))
await run('tar', ['--extract', '--gzip', '--file', archive, '--directory', output, '--no-same-owner', '--no-same-permissions'], { signal })
}
return await validateExtractedTree(output)
} catch (error) {
await rm(output, { recursive: true, force: true }).catch(() => {})
throw error
}
}
export const archiveInternals = Object.freeze({ inside, run, validateEntryNames })
@@ -0,0 +1,78 @@
import { link, open, rename, rm, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import {
FileManagerError, FileRevisionConflictError, MAX_TEXT_BYTES, fileError,
fileManagerInternals, isManagedPath, normalizeAbsolutePath,
} from './index.mjs'
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
function bytes(value) {
if (typeof value !== 'string') throw new FileManagerError('content must be a string')
const result = Buffer.from(value, 'utf8')
if (result.byteLength > MAX_TEXT_BYTES) throw new FileManagerError('file is too large to edit', 413, 'FILE_TOO_LARGE')
if (result.includes(0)) throw new FileManagerError('content contains NUL', 415, 'FILE_TYPE_UNSUPPORTED')
return result
}
export class AtomicFileEditor {
constructor({ isManaged = isManagedPath } = {}) {
this.isManaged = isManaged
}
async write(value, content, revision, { create = false } = {}) {
const path = normalizeAbsolutePath(value)
const body = bytes(content)
const parent = dirname(path)
let original
try {
original = await stat(path, { bigint: true })
if (!original.isFile()) throw new FileManagerError('path is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED')
if (create) throw new FileManagerError('file already exists', 409, 'FILE_EXISTS')
if (typeof revision !== 'string' || revision !== fileManagerInternals.revisionFor(path, original)) {
throw new FileRevisionConflictError()
}
} catch (error) {
if (error?.code !== 'ENOENT') throw fileError(error)
if (!create) throw fileError(error)
if (revision !== null && revision !== undefined) throw new FileManagerError('new file revision must be null')
}
const temporary = join(parent, `.dsh-edit-${randomUUID()}.tmp`)
let handle
try {
handle = await open(temporary, 'wx', original === undefined ? 0o644 : Number(original.mode & 0o7777n))
await handle.writeFile(body)
await handle.sync()
if (original !== undefined) {
await handle.chmod(Number(original.mode & 0o7777n))
const current = await handle.stat({ bigint: true })
if (current.uid !== original.uid || current.gid !== original.gid) {
try { await handle.chown(Number(original.uid), Number(original.gid)) } catch {
throw new FileManagerError('cannot preserve file ownership; use the terminal', 403, 'OWNERSHIP_PRESERVE_FAILED')
}
}
}
await handle.close()
handle = undefined
if (original === undefined) {
await link(temporary, path)
await rm(temporary)
} else await rename(temporary, path)
await syncDirectory(parent)
const saved = await stat(path, { bigint: true })
return {
path, revision: fileManagerInternals.revisionFor(path, saved), size: body.byteLength,
mode: Number(saved.mode & 0o7777n), modifiedAt: new Date(Number(saved.mtimeNs / 1_000_000n)).toISOString(),
managed: this.isManaged(path),
}
} catch (error) {
await handle?.close().catch(() => {})
await rm(temporary, { force: true }).catch(() => {})
throw fileError(error)
}
}
}
@@ -0,0 +1,518 @@
import { createHash, randomUUID } from 'node:crypto'
import { constants } from 'node:fs'
import { access, lstat, readFile, readdir, readlink, realpath, stat } from 'node:fs/promises'
import { basename, dirname, join, normalize, resolve } from 'node:path'
export const MAX_PATH_BYTES = 4096
export const MAX_TEXT_BYTES = 2 * 1024 * 1024
export const DEFAULT_LIST_LIMIT = 200
export const MAX_LIST_LIMIT = 1000
export const SEARCH_MAX_DEPTH = 16
export const SEARCH_MAX_SCANNED = 10_000
export const SEARCH_MAX_RESULTS = 1000
const SORTS = new Set(['name', 'size', 'type', 'modifiedAt'])
const ORDERS = new Set(['asc', 'desc'])
export class FileManagerError extends Error {
constructor(message, statusCode = 400, code = 'FILE_INVALID') {
super(message)
this.name = 'FileManagerError'
this.statusCode = statusCode
this.code = code
}
}
export class FileRevisionConflictError extends FileManagerError {
constructor(message = 'file revision changed') {
super(message, 409, 'REVISION_CONFLICT')
this.name = 'FileRevisionConflictError'
}
}
function statusCode(error) {
if (['EACCES', 'EPERM', 'EROFS'].includes(error?.code)) return 403
if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return 404
if (error?.code === 'EFBIG') return 413
return 400
}
export function fileError(error, fallback = 'file operation failed') {
if (error instanceof FileManagerError) return error
const wrapped = new FileManagerError(error instanceof Error ? error.message : fallback, statusCode(error), error?.code ?? 'FILE_ERROR')
if (error instanceof Error) wrapped.cause = error
return wrapped
}
export function normalizeAbsolutePath(value) {
if (typeof value !== 'string' || value.length === 0) throw new FileManagerError('path must be a non-empty string')
if (!value.startsWith('/')) throw new FileManagerError('path must be absolute')
if (Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES) throw new FileManagerError('path is too long')
if(/[\u0000-\u001f\u007f]/u.test(value)) throw new FileManagerError('path contains control characters')
const path = normalize(value)
if (!path.startsWith('/')) throw new FileManagerError('path must be absolute')
return path
}
export function createManagedPathMatcher(platformData = '/data/platform') {
const data = normalizeAbsolutePath(platformData)
const roots = Object.freeze(['/opt/dsh-platform', '/run/dsh-platform', join(data, 'state'), join(data, 'store')])
return value => {
const path = normalizeAbsolutePath(value)
return roots.some(root => path === root || path.startsWith(`${root}/`))
}
}
export const isManagedPath = createManagedPathMatcher()
export function fileManagerLocations({
platformData = '/data/platform', dshHome = '/data/dsh', defaultWorkspace = '/workspace',
} = {}) {
const defaultPath = normalizeAbsolutePath(defaultWorkspace)
const shortcuts = [...new Set([defaultPath, normalizeAbsolutePath(dshHome), normalizeAbsolutePath(platformData), '/'])]
return Object.freeze({ defaultPath, shortcuts: Object.freeze(shortcuts) })
}
function parseIdentityFile(value, idIndex) {
const result = new Map()
for (const line of value.split('\n')) {
if (line === '' || line.startsWith('#')) continue
const fields = line.split(':')
const id = Number(fields[idIndex])
if (fields[0] !== '' && Number.isSafeInteger(id) && id >= 0 && !result.has(id)) result.set(id, fields[0])
}
return result
}
export class UnixIdentityResolver {
constructor({ passwdPath = '/etc/passwd', groupPath = '/etc/group' } = {}) {
this.passwdPath = passwdPath
this.groupPath = groupPath
this.loading = undefined
}
async #identities() {
this.loading ??= Promise.all([
readFile(this.passwdPath, 'utf8').then(value => parseIdentityFile(value, 2), () => new Map()),
readFile(this.groupPath, 'utf8').then(value => parseIdentityFile(value, 2), () => new Map()),
])
return this.loading
}
async resolve(uid, gid) {
const [users, groups] = await this.#identities()
return { user: users.get(uid) ?? String(uid), group: groups.get(gid) ?? String(gid) }
}
async userId(value) { return this.#identityId(value, 0, 'user') }
async groupId(value) { return this.#identityId(value, 1, 'group') }
async #identityId(value, index, label) {
if (typeof value !== 'string' || value === '' || value.length > 256 || /[:\u0000-\u001f\u007f]/u.test(value)) {
throw new FileManagerError(`${label} is invalid`, 400, 'FILE_IDENTITY_INVALID')
}
if (/^[0-9]+$/u.test(value)) {
const id = Number(value)
if (Number.isSafeInteger(id) && id >= 0 && id < 0xffff_ffff) return id
throw new FileManagerError(`${label} id is invalid`, 400, 'FILE_IDENTITY_INVALID')
}
const identities = (await this.#identities())[index]
for (const [id, name] of identities) if (name === value) return id
throw new FileManagerError(`${label} was not found`, 400, 'FILE_IDENTITY_INVALID')
}
}
function kind(stats) {
if (stats.isFile()) return 'file'
if (stats.isDirectory()) return 'directory'
if (stats.isSymbolicLink()) return 'symlink'
if (stats.isBlockDevice()) return 'block-device'
if (stats.isCharacterDevice()) return 'character-device'
if (stats.isFIFO()) return 'fifo'
if (stats.isSocket()) return 'socket'
return 'unknown'
}
function metadata(stats) {
return {
type: kind(stats),
size: Number(stats.size),
mode: Number(stats.mode & 0o7777n),
uid: Number(stats.uid),
gid: Number(stats.gid),
modifiedAt: new Date(Number(stats.mtimeNs / 1_000_000n)).toISOString(),
createdAt: new Date(Number(stats.birthtimeNs / 1_000_000n)).toISOString(),
}
}
function revisionFor(path, stats, extra = '') {
return `sha256:${createHash('sha256').update(JSON.stringify([
path, stats.dev.toString(), stats.ino.toString(), kind(stats), stats.size.toString(),
stats.mode.toString(), stats.uid.toString(), stats.gid.toString(), stats.mtimeNs.toString(), stats.ctimeNs.toString(), extra,
])).digest('hex')}`
}
async function permissions(path) {
const allowed = async mode => access(path, mode).then(() => true, () => false)
const [readable, writable] = await Promise.all([allowed(constants.R_OK), allowed(constants.W_OK)])
return { readable, writable }
}
async function symlinkDetails(path, stats) {
if (!stats.isSymbolicLink()) return {}
const linkTarget = await readlink(path)
try {
const target = await stat(path, { bigint: true })
return { linkTarget, targetExists: true, targetType: kind(target), realPath: await realpath(path) }
} catch (error) {
if (error?.code === 'ENOENT' || error?.code === 'ELOOP') return { linkTarget, targetExists: false, targetType: null, realPath: null }
throw error
}
}
async function describe(path, stats = undefined, managed = isManagedPath, identity = new UnixIdentityResolver()) {
const value = stats ?? await lstat(path, { bigint: true })
const link = await symlinkDetails(path, value)
const owner = await identity.resolve(Number(value.uid), Number(value.gid))
return {
name: basename(path) || '/',
path,
...metadata(value),
...owner,
...await permissions(path),
managed: managed(path),
revision: revisionFor(path, value, link.linkTarget ?? ''),
...link,
}
}
function compareEntries(left, right, sort, order) {
if (left.type === 'directory' && right.type !== 'directory') return -1
if (left.type !== 'directory' && right.type === 'directory') return 1
let result
if (sort === 'size') result = left.size - right.size
else if (sort === 'modifiedAt') result = left.modifiedAt.localeCompare(right.modifiedAt)
else if (sort === 'type') result = left.type.localeCompare(right.type)
else result = left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' })
if (result === 0) result = left.name.localeCompare(right.name)
return order === 'desc' ? -result : result
}
function encodeCursor(revision, offset) {
return Buffer.from(JSON.stringify({ revision, offset })).toString('base64url')
}
function decodeCursor(value, revision) {
if (value === undefined || value === null || value === '') return 0
try {
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))
if (parsed.revision !== revision) throw new FileRevisionConflictError('directory changed while paging')
if (!Number.isSafeInteger(parsed.offset) || parsed.offset < 0) throw new Error('invalid offset')
return parsed.offset
} catch (error) {
if (error instanceof FileRevisionConflictError) throw error
throw new FileManagerError('cursor is invalid')
}
}
function listRevision(path, stats, entries) {
const digest = createHash('sha256')
for (const entry of entries) digest.update(`${entry.name}\0${entry.revision ?? entry.type}\0`)
return revisionFor(path, stats, digest.digest('hex'))
}
function directoryEntryType(entry) {
if (entry.isDirectory()) return 'directory'
if (entry.isFile()) return 'file'
if (entry.isSymbolicLink()) return 'symlink'
if (entry.isFIFO()) return 'fifo'
if (entry.isSocket()) return 'socket'
if (entry.isBlockDevice()) return 'block-device'
if (entry.isCharacterDevice()) return 'character-device'
return 'unknown'
}
function compareNames(left, right, order) {
if (left.type === 'directory' && right.type !== 'directory') return -1
if (left.type !== 'directory' && right.type === 'directory') return 1
const result = left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' })
|| left.name.localeCompare(right.name)
return order === 'desc' ? -result : result
}
function parseLimit(value, fallback = DEFAULT_LIST_LIMIT) {
if (value === undefined || value === null || value === '') return fallback
const limit = Number(value)
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) throw new FileManagerError(`limit must be between 1 and ${MAX_LIST_LIMIT}`)
return limit
}
function parseOffset(value) {
if (value === undefined || value === null || value === '') return undefined
const offset = Number(value)
if (!Number.isSafeInteger(offset) || offset < 0) throw new FileManagerError('offset must be a non-negative integer')
return offset
}
export class FileInventory {
constructor({ isManaged = isManagedPath, identity = new UnixIdentityResolver() } = {}) {
this.isManaged = isManaged
this.identity = identity
}
async stat(value) {
const path = normalizeAbsolutePath(value)
try {
const item = await describe(path, undefined, this.isManaged, this.identity)
return { ...item, realPath: item.realPath ?? await realpath(path).catch(() => path), parent: dirname(path) }
} catch (error) {
throw fileError(error)
}
}
async list(value, { cursor, offset: requestedOffset, limit, sort = 'name', order = 'asc' } = {}) {
const path = normalizeAbsolutePath(value)
if (!SORTS.has(sort)) throw new FileManagerError('sort is invalid')
if (!ORDERS.has(order)) throw new FileManagerError('order is invalid')
try {
const root = await lstat(path, { bigint: true })
if (!root.isDirectory()) throw new FileManagerError('path is not a directory', 415, 'FILE_TYPE_UNSUPPORTED')
const pageSize = parseLimit(limit)
const directOffset = parseOffset(requestedOffset)
if (directOffset !== undefined && cursor !== undefined && cursor !== null && cursor !== '') throw new FileManagerError('cursor and offset cannot be combined')
const names = await readdir(path, { withFileTypes: true })
let entries
let revision
let offset
if (sort === 'name') {
const summary = names.map(entry => ({ name: entry.name, type: directoryEntryType(entry) }))
summary.sort((left, right) => compareNames(left, right, order))
revision = listRevision(path, root, summary)
offset = directOffset ?? decodeCursor(cursor, revision)
entries = await Promise.all(summary.slice(offset, offset + pageSize).map(entry => (
describe(resolve(path, entry.name), undefined, this.isManaged, this.identity)
)))
} else {
entries = await Promise.all(names.map(entry => describe(resolve(path, entry.name), undefined, this.isManaged, this.identity)))
entries.sort((left, right) => compareEntries(left, right, sort, order))
revision = listRevision(path, root, entries)
offset = directOffset ?? decodeCursor(cursor, revision)
}
const page = sort === 'name' ? entries : entries.slice(offset, offset + pageSize)
const nextOffset = offset + page.length
return {
path,
realPath: await realpath(path),
revision,
entries: page,
nextCursor: nextOffset < names.length ? encodeCursor(revision, nextOffset) : null,
total: names.length,
managed: this.isManaged(path),
}
} catch (error) {
throw fileError(error)
}
}
async content(value) {
const path = normalizeAbsolutePath(value)
try {
const before = await stat(path, { bigint: true })
if (!before.isFile()) throw new FileManagerError('path is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED')
if (before.size > BigInt(MAX_TEXT_BYTES)) throw new FileManagerError('file is too large to edit', 413, 'FILE_TOO_LARGE')
const bytes = await readFile(path)
const after = await stat(path, { bigint: true })
const beforeRevision = revisionFor(path, before)
if (beforeRevision !== revisionFor(path, after)) throw new FileRevisionConflictError('file changed while reading')
if (bytes.includes(0)) throw new FileManagerError('file is not UTF-8 text', 415, 'FILE_TYPE_UNSUPPORTED')
const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return {
path,
realPath: await realpath(path),
content,
encoding: 'utf-8',
newline: content.includes('\r\n') ? 'crlf' : 'lf',
revision: beforeRevision,
mode: Number(before.mode & 0o7777n),
size: bytes.byteLength,
modifiedAt: new Date(Number(before.mtimeNs / 1_000_000n)).toISOString(),
managed: this.isManaged(path),
}
} catch (error) {
throw fileError(error)
}
}
}
export class FileSearchManager {
constructor({ inventory = new FileInventory(), onState = () => {} } = {}) {
this.inventory = inventory
this.onState = onState
this.tasks = new Map()
}
start({ path: value, revision, query }) {
const path = normalizeAbsolutePath(value)
if (typeof query !== 'string' || query.trim().length === 0 || query.length > 256) throw new FileManagerError('search query is invalid')
const task = {
taskId: randomUUID(), operation: 'search', status: 'running', path, revision, query,
scanned: 0, matches: [], truncated: false, error: null, cancelRequested: false,
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}
this.tasks.set(task.taskId, task)
this.#publish(task)
task.completion = this.#run(task).catch(error => {
task.status = task.cancelRequested ? 'cancelled' : 'failed'
task.error = error instanceof Error ? error.message : String(error)
this.#publish(task)
})
return this.public(task)
}
get(taskId, { cursor, limit } = {}) {
const task = this.tasks.get(taskId)
if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND')
const offset = cursor === undefined || cursor === '' ? 0 : Number(cursor)
if (!Number.isSafeInteger(offset) || offset < 0) throw new FileManagerError('task cursor is invalid')
const size = parseLimit(limit)
return { ...this.public(task), results: task.matches.slice(offset, offset + size), nextCursor: offset + size < task.matches.length ? String(offset + size) : null }
}
list() {
return [...this.tasks.values()].map(task => this.public(task)).reverse()
}
cancel(taskId) {
const task = this.tasks.get(taskId)
if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND')
if (task.status !== 'running') throw new FileManagerError('file task is already complete', 409, 'FILE_TASK_COMPLETE')
task.cancelRequested = true
this.#publish(task)
return this.public(task)
}
public(task) {
const { completion, matches, cancelRequested, ...value } = task
return { ...value, resultCount: matches.length, cancelRequested }
}
#publish(task) {
task.updatedAt = new Date().toISOString()
this.onState(this.public(task))
}
async #run(task) {
const root = await this.inventory.list(task.path, { limit: 1 })
if (task.revision !== undefined && task.revision !== null && task.revision !== root.revision) throw new FileRevisionConflictError('search root changed')
task.revision = root.revision
const needle = task.query.toLocaleLowerCase()
const pending = [{ path: task.path, depth: 0 }]
while (pending.length > 0 && task.scanned < SEARCH_MAX_SCANNED && task.matches.length < SEARCH_MAX_RESULTS) {
if (task.cancelRequested) {
task.status = 'cancelled'
this.#publish(task)
return
}
const current = pending.shift()
let names
try { names = await readdir(current.path, { withFileTypes: true }) } catch (error) {
if (['EACCES', 'EPERM', 'ENOENT'].includes(error?.code)) continue
throw error
}
for (const entry of names) {
task.scanned += 1
const child = resolve(current.path, entry.name)
const relative = child.slice(task.path === '/' ? 1 : task.path.length + 1)
if (relative.toLocaleLowerCase().includes(needle)) task.matches.push(await describe(child, undefined, this.inventory.isManaged, this.inventory.identity))
if (entry.isDirectory() && !entry.isSymbolicLink() && current.depth < SEARCH_MAX_DEPTH) pending.push({ path: child, depth: current.depth + 1 })
if (task.scanned >= SEARCH_MAX_SCANNED || task.matches.length >= SEARCH_MAX_RESULTS) break
}
this.#publish(task)
await new Promise(resolve => setImmediate(resolve))
}
task.truncated = pending.length > 0 || task.scanned >= SEARCH_MAX_SCANNED || task.matches.length >= SEARCH_MAX_RESULTS
task.status = 'success'
this.#publish(task)
}
}
export class FileSizeManager {
constructor({ inventory = new FileInventory(), onState = () => {} } = {}) {
this.inventory = inventory
this.onState = onState
this.tasks = new Map()
}
start({ path: value, revision } = {}) {
const path = normalizeAbsolutePath(value)
const task = {
taskId: randomUUID(), operation: 'size', status: 'running', path, revision,
bytes: 0, entries: 0, error: null, cancelRequested: false,
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}
this.tasks.set(task.taskId, task)
this.#publish(task)
task.completion = this.#run(task).catch(error => {
task.status = task.cancelRequested ? 'cancelled' : 'failed'
task.error = error instanceof Error ? error.message : String(error)
this.#publish(task)
})
return this.public(task)
}
get(taskId) {
const task = this.tasks.get(taskId)
if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND')
return this.public(task)
}
list() { return [...this.tasks.values()].map(task => this.public(task)).reverse() }
cancel(taskId) {
const task = this.tasks.get(taskId)
if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND')
if (task.status !== 'running') throw new FileManagerError('file task is already complete', 409, 'FILE_TASK_COMPLETE')
task.cancelRequested = true
this.#publish(task)
return this.public(task)
}
public(task) {
const { completion, cancelRequested, ...value } = task
return { ...value, cancelRequested }
}
#publish(task) {
task.updatedAt = new Date().toISOString()
this.onState(this.public(task))
}
async #run(task) {
const root = await this.inventory.stat(task.path)
if (root.type !== 'directory') throw new FileManagerError('size target is not a directory', 415, 'FILE_TYPE_UNSUPPORTED')
if (typeof task.revision !== 'string' || task.revision !== root.revision) throw new FileRevisionConflictError('size target changed')
const pending = [task.path]
while (pending.length > 0) {
if (task.cancelRequested) {
task.status = 'cancelled'
this.#publish(task)
return
}
const directory = pending.pop()
for (const name of await readdir(directory)) {
const path = resolve(directory, name)
const details = await lstat(path, { bigint: true })
task.entries += 1
if (details.isDirectory() && !details.isSymbolicLink()) pending.push(path)
else if (details.isFile()) task.bytes += Number(details.size)
}
this.#publish(task)
await new Promise(resolveTask => setImmediate(resolveTask))
}
task.status = 'success'
this.#publish(task)
}
}
export const fileManagerInternals = Object.freeze({ describe, revisionFor, parseIdentityFile, parseLimit })
@@ -0,0 +1,645 @@
import { randomUUID } from 'node:crypto'
import { chmod, cp, lchown, lstat, mkdir, open, readdir, readFile, readlink, rename, rm } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { createArchive, extractArchive, normalizeArchiveFormat } from './archives.mjs'
import {
FileInventory, FileManagerError, FileRevisionConflictError, FileSearchManager, FileSizeManager, UnixIdentityResolver,
fileError, isManagedPath, normalizeAbsolutePath,
} from './index.mjs'
const OPERATIONS = new Set(['mkdir', 'touch', 'rename', 'copy', 'move', 'delete', 'attributes', 'archive', 'extract'])
const DEFAULT_PROTECTED_DELETE_ROOTS = ['/', '/data', '/data/dsh', '/data/platform', '/workspace', '/run/dsh-platform/deployment']
function now() { return new Date().toISOString() }
async function exists(path) {
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
function publicTask(task) {
const { completion, persisted, executor, resolveResult, rejectResult, cancelRequested, abortController, ...value } = task
return { ...value, cancelRequested: cancelRequested === true }
}
function cleanName(path) {
const name = basename(path)
if (name === '' || name === '.' || name === '..') throw new FileManagerError('destination name is invalid')
return name
}
function renamedPath(path, index) {
const dot = basename(path).lastIndexOf('.')
const directory = dirname(path)
const name = basename(path)
const stem = dot > 0 ? name.slice(0, dot) : name
const extension = dot > 0 ? name.slice(dot) : ''
return join(directory, `${stem} (${String(index)})${extension}`)
}
async function uniqueDestination(path, conflict) {
if (!await exists(path)) return path
if (conflict === 'reject') throw new FileManagerError('destination already exists', 409, 'FILE_EXISTS')
if (conflict === 'overwrite') return path
for (let index = 1; index < 10_000; index += 1) {
const candidate = renamedPath(path, index)
if (!await exists(candidate)) return candidate
}
throw new FileManagerError('could not choose a unique destination', 409, 'FILE_EXISTS')
}
async function copyEntry(source, staging) {
const sourceStats = await lstat(source)
if (sourceStats.isDirectory()) {
await cp(source, staging, { recursive: true, dereference: false, errorOnExist: true, force: false, preserveTimestamps: true })
} else if (sourceStats.isSymbolicLink()) {
const target = await readlink(source)
await import('node:fs/promises').then(fs => fs.symlink(target, staging))
} else if (sourceStats.isFile()) {
await cp(source, staging, { dereference: false, errorOnExist: true, force: false, preserveTimestamps: true })
} else throw new FileManagerError('special files cannot be copied', 415, 'FILE_TYPE_UNSUPPORTED')
}
async function treeMetrics(path) {
const value = await lstat(path)
if (!value.isDirectory() || value.isSymbolicLink()) return { entries: 1, bytes: value.isFile() ? value.size : 0 }
let entries = 1
let bytes = 0
for (const name of await readdir(path)) {
const child = await treeMetrics(join(path, name))
entries += child.entries
bytes += child.bytes
}
return { entries, bytes }
}
function normalizeAttributes(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).some(key => !['user', 'group', 'mode', 'recursive'].includes(key))
|| typeof value.user !== 'string' || typeof value.group !== 'string'
|| typeof value.mode !== 'string' || !/^[0-7]{3,4}$/u.test(value.mode)
|| typeof value.recursive !== 'boolean') throw new FileManagerError('file attributes are invalid')
return { user: value.user, group: value.group, mode: value.mode, recursive: value.recursive }
}
function assertAttributesApplied(path, details, { uid, gid, mode }, symbolicLink) {
const ownershipMatches = details.uid === uid && details.gid === gid
const modeMatches = symbolicLink || (details.mode & 0o7777) === mode
if (ownershipMatches && modeMatches) return
throw new FileManagerError(
`filesystem did not apply Unix ownership or mode for ${path}; use a filesystem mounted with Unix metadata support`,
403,
'FILE_ATTRIBUTES_UNSUPPORTED',
)
}
export class FileTaskManager {
constructor({
root, inventory = new FileInventory(), isManaged = isManagedPath,
protectedRoots = DEFAULT_PROTECTED_DELETE_ROOTS,
onState = () => {}, platformBusy = () => false, report = async () => {}, identity,
} = {}) {
if (typeof root !== 'string') throw new Error('file task root is required')
this.root = root
this.inventory = inventory
this.isManaged = isManaged
this.protectedRoots = new Set(protectedRoots.map(normalizeAbsolutePath))
this.onState = onState
this.platformBusy = platformBusy
this.report = report
this.identity = identity ?? inventory.identity ?? new UnixIdentityResolver()
this.tasks = new Map()
this.activeMutation = undefined
this.queue = []
this.queueTail = Promise.resolve()
this.search = new FileSearchManager({ inventory, onState: state => onState(state) })
this.sizes = new FileSizeManager({ inventory, onState: state => onState(state) })
}
get hasManagedMutation() { return this.queue.some(task => ['queued', 'running'].includes(task.status) && task.managed === true) }
wouldManage(input) {
if (['search', 'size'].includes(input?.operation)) return false
const sourceManaged = Array.isArray(input?.sources) && input.sources.some(source => typeof source?.path === 'string' && this.isManaged(source.path))
return sourceManaged || (typeof input?.destination === 'string' && this.isManaged(input.destination))
}
async initialize() {
await mkdir(this.root, { recursive: true })
const files = (await readdir(this.root)).filter(name => name.endsWith('.json')).sort()
for (const name of files) {
try {
const task = JSON.parse(await readFile(join(this.root, name), 'utf8'))
if (task.schema !== 1 || typeof task.taskId !== 'string') continue
this.tasks.set(task.taskId, task)
if (task.status === 'running') await this.#recover(task)
else if (task.status === 'queued' && ['upload', 'download'].includes(task.operation)) {
task.status = 'running'
await this.#recover(task)
} else if (task.status === 'queued') this.#schedule(task)
} catch (error) {
this.onState({ operation: 'recovery', status: 'failed', error: error instanceof Error ? error.message : String(error) })
}
}
await this.#prune()
}
start(input) {
if (input?.operation === 'search') return this.search.start(input)
if (input?.operation === 'size') return this.sizes.start(input)
if (input === null || typeof input !== 'object' || !OPERATIONS.has(input.operation)) throw new FileManagerError('file task operation is invalid')
const sources = Array.isArray(input.sources) ? input.sources.map(source => ({ path: normalizeAbsolutePath(source.path), revision: source.revision })) : []
const destination = input.destination === undefined ? null : normalizeAbsolutePath(input.destination)
const conflict = input.conflict ?? 'reject'
if (!['reject', 'overwrite', 'rename'].includes(conflict)) throw new FileManagerError('file task conflict mode is invalid')
if (['copy', 'move', 'delete', 'rename', 'archive', 'extract'].includes(input.operation) && sources.length === 0) throw new FileManagerError('file task sources are required')
if (input.operation === 'extract' && sources.length !== 1) throw new FileManagerError('archive extraction requires one source')
if (input.operation === 'attributes' && sources.length !== 1) throw new FileManagerError('attribute changes require one source')
if (['mkdir', 'touch', 'copy', 'move', 'rename', 'archive', 'extract'].includes(input.operation) && destination === null) throw new FileManagerError('file task destination is required')
if (input.operation === 'delete' && sources.some(source => this.protectedRoots.has(source.path))) throw new FileManagerError('protected root cannot be deleted', 403, 'PROTECTED_ROOT')
const managed = sources.some(source => this.isManaged(source.path)) || (destination !== null && this.isManaged(destination))
if (managed && this.platformBusy()) throw new FileManagerError('a platform operation is already running', 409, 'FILE_TASK_CONFLICT')
const task = {
schema: 1, taskId: randomUUID(), operation: input.operation,
status: this.queue.length === 0 ? 'running' : 'queued', phase: this.queue.length === 0 ? 'validating' : 'queued',
sources, destination, destinationRevision: input.destinationRevision ?? null, conflict, managed,
attributes: input.operation === 'attributes' ? normalizeAttributes(input.attributes) : null,
archiveFormat: ['archive', 'extract'].includes(input.operation) ? normalizeArchiveFormat(input.archiveFormat) : null,
processedBytes: 0, totalBytes: 0, processedEntries: 0, totalEntries: 0, currentPath: null,
published: [], staging: [], hidden: [], currentSource: null, currentDestination: null,
error: null, errorCode: null, queuePosition: null, createdAt: now(), updatedAt: now(), cancelRequested: false,
}
this.tasks.set(task.taskId, task)
this.#schedule(task)
return publicTask(task)
}
startTransfer({ operation, path, totalBytes = null, managed = false }, executor) {
if (!['upload', 'download'].includes(operation) || typeof executor !== 'function') {
throw new FileManagerError('file transfer task is invalid')
}
const normalizedPath = normalizeAbsolutePath(path)
if (managed && this.platformBusy()) throw new FileManagerError('a platform operation is already running', 409, 'FILE_TASK_CONFLICT')
let resolveResult
let rejectResult
const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject })
const task = {
schema: 1, taskId: randomUUID(), operation,
status: this.queue.length === 0 ? 'running' : 'queued', phase: this.queue.length === 0 ? 'transferring' : 'queued',
path: normalizedPath, sources: [], destination: operation === 'upload' ? normalizedPath : null,
destinationRevision: null, conflict: null, managed, attributes: null, archiveFormat: null,
processedBytes: 0, totalBytes, processedEntries: 0, totalEntries: 1, currentPath: normalizedPath,
published: [], staging: [], hidden: [], currentSource: null, currentDestination: null,
error: null, errorCode: null, queuePosition: null, createdAt: now(), updatedAt: now(), cancelRequested: false,
executor, resolveResult, rejectResult,
}
this.tasks.set(task.taskId, task)
this.#schedule(task)
return { task: publicTask(task), result }
}
list() {
return [...this.search.list(), ...this.sizes.list(), ...[...this.tasks.values()].map(publicTask)].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100)
}
get(taskId, options = {}) {
if (this.tasks.has(taskId)) return publicTask(this.tasks.get(taskId))
try { return this.search.get(taskId, options) } catch (error) {
if (error?.statusCode !== 404) throw error
return this.sizes.get(taskId)
}
}
completion(taskId) {
const task = this.tasks.get(taskId) ?? this.search.tasks.get(taskId) ?? this.sizes.tasks.get(taskId)
if (task === undefined) throw new FileManagerError('file task was not found', 404, 'FILE_TASK_NOT_FOUND')
return Promise.resolve(task.completion).then(() => this.get(taskId))
}
cancel(taskId) {
const task = this.tasks.get(taskId)
if (task === undefined) {
try { return this.search.cancel(taskId) } catch (error) {
if (error?.statusCode !== 404) throw error
return this.sizes.cancel(taskId)
}
}
if (!['queued', 'running'].includes(task.status)) throw new FileManagerError('file task is already complete', 409, 'FILE_TASK_COMPLETE')
if (task.status === 'queued') {
task.cancelRequested = true
task.status = 'cancelled'
task.phase = 'cancelled'
task.updatedAt = now()
this.queue = this.queue.filter(value => value !== task)
void Promise.resolve(task.persisted).then(() => this.#terminal(task, 'cancelled', 'cancelled'))
task.rejectResult?.(Object.assign(new Error('file transfer cancelled'), { code: 'FILE_TASK_CANCELLED' }))
this.#publishQueue()
return publicTask(task)
}
if (['destination-committed', 'source-hidden', 'cleaning'].includes(task.phase)) throw new FileManagerError('file task passed its cancellation boundary', 409, 'FILE_TASK_COMMITTED')
task.cancelRequested = true
task.abortController?.abort()
this.#publish(task)
return publicTask(task)
}
#schedule(task) {
this.queue.push(task)
this.#publishQueue()
const previous = this.queueTail
task.persisted = this.#persist(task)
task.completion = Promise.all([previous.catch(() => {}), task.persisted]).then(async () => {
if (task.cancelRequested || task.status === 'cancelled') return
task.status = 'running'
task.phase = 'validating'
task.queuePosition = 0
this.activeMutation = task
await this.#phase(task, task.executor === undefined ? 'validating' : 'transferring')
if (task.executor === undefined) await this.#run(task).catch(() => {})
else await this.#runTransfer(task)
}).finally(() => {
if (this.activeMutation === task) this.activeMutation = undefined
this.queue = this.queue.filter(value => value !== task)
this.#publishQueue()
})
this.queueTail = task.completion
}
async #runTransfer(task) {
const controller = new AbortController()
task.abortController = controller
const controls = {
signal: controller.signal,
progress: ({ processedBytes, totalBytes, currentPath } = {}) => {
if (Number.isSafeInteger(processedBytes) && processedBytes >= 0) task.processedBytes = processedBytes
if (Number.isSafeInteger(totalBytes) && totalBytes >= 0) task.totalBytes = totalBytes
if (typeof currentPath === 'string') task.currentPath = currentPath
task.updatedAt = now()
this.#publish(task)
},
}
try {
const result = await task.executor(controls)
task.processedEntries = 1
if (task.totalBytes !== null) task.processedBytes = task.totalBytes
await this.#terminal(task, 'success', 'completed')
await this.report(`file-task.${task.operation}.completed`, publicTask(task))
task.resolveResult(result)
} catch (error) {
const status = task.cancelRequested ? 'cancelled' : 'failed'
task.error = error instanceof Error ? error.message : String(error)
task.errorCode = typeof error?.code === 'string' ? error.code : null
await this.#terminal(task, status, status)
await this.report(`file-task.${task.operation}.${status}`, { ...publicTask(task), error })
task.rejectResult(error)
} finally {
task.abortController = undefined
await this.#prune()
}
}
#publishQueue() {
let position = 0
for (const task of this.queue) {
if (task.status === 'queued') position += 1
task.queuePosition = task.status === 'queued' ? position : 0
this.#publish(task)
}
}
async #validate(task) {
for (const source of task.sources) {
const item = await this.inventory.stat(source.path)
if (source.revision !== item.revision) throw new FileRevisionConflictError(`source changed: ${source.path}`)
if (task.operation === 'attributes' && !['file', 'directory'].includes(item.type)) {
throw new FileManagerError('attributes can be changed only for files and directories', 415, 'FILE_TYPE_UNSUPPORTED')
}
const metrics = task.operation === 'attributes' && task.attributes.recursive === false
? { entries: 1, bytes: item.type === 'file' ? item.size : 0 }
: await treeMetrics(source.path)
task.totalEntries += metrics.entries
task.totalBytes += metrics.bytes
}
if (task.destinationRevision !== null) {
const destinationRoot = task.operation === 'rename' || task.operation === 'mkdir' || task.operation === 'touch'
? dirname(task.destination) : task.destination
const listed = await this.inventory.list(destinationRoot, { limit: 1 })
if (listed.revision !== task.destinationRevision) throw new FileRevisionConflictError('destination directory changed')
}
}
async #run(task) {
try {
await this.#validate(task)
await this.#phase(task, 'mutating')
if (task.operation === 'mkdir') await mkdir(task.destination)
else if (task.operation === 'touch') {
const handle = await open(task.destination, 'wx', 0o644)
await handle.sync(); await handle.close(); await syncDirectory(dirname(task.destination))
} else if (task.operation === 'rename') await this.#rename(task)
else if (task.operation === 'copy') await this.#copy(task, false)
else if (task.operation === 'move') await this.#copy(task, true)
else if (task.operation === 'delete') await this.#delete(task)
else if (task.operation === 'attributes') await this.#attributes(task)
else if (task.operation === 'archive') await this.#archive(task)
else if (task.operation === 'extract') await this.#extract(task)
await this.#terminal(task, 'success', 'completed')
await this.report(`file-task.${task.operation}.completed`, publicTask(task))
} catch (error) {
const status = task.cancelRequested ? 'cancelled' : 'failed'
task.error = error instanceof Error ? error.message : String(error)
task.errorCode = typeof error?.code === 'string' ? error.code : null
await this.#cleanupStaging(task)
await this.#terminal(task, status, status)
await this.report(`file-task.${task.operation}.${status}`, { ...publicTask(task), error })
throw error
} finally {
await this.#prune()
}
}
async #rename(task) {
if (task.sources.length !== 1) throw new FileManagerError('rename requires one source')
const source = task.sources[0].path
if (dirname(source) !== dirname(task.destination)) throw new FileManagerError('rename destination must use the same directory')
const destination = await uniqueDestination(task.destination, task.conflict)
task.currentSource = source
task.currentDestination = destination
await this.#phase(task, 'move-prepared')
if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true })
await rename(source, destination)
task.published.push(destination)
await this.#phase(task, 'destination-committed')
await syncDirectory(dirname(destination))
task.currentSource = null
task.currentDestination = null
await this.#phase(task, 'mutating')
}
async #copy(task, move) {
for (const source of task.sources) {
if (task.cancelRequested) throw new Error('file task cancelled')
const destination = await uniqueDestination(join(task.destination, cleanName(source.path)), task.conflict)
if (source.path === destination || destination.startsWith(`${source.path}/`)) throw new FileManagerError('directory cannot be copied into itself')
if (move) {
task.currentSource = source.path
task.currentDestination = destination
await this.#phase(task, 'move-prepared')
try {
if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true })
await rename(source.path, destination)
task.published.push(destination)
await this.#phase(task, 'destination-committed')
task.processedEntries += 1
task.currentSource = null
task.currentDestination = null
await this.#phase(task, 'mutating')
continue
} catch (error) {
if (error?.code !== 'EXDEV') throw error
}
}
const staging = join(dirname(destination), `.dsh-file-task-${task.taskId}-${randomUUID()}.tmp`)
task.staging.push(staging)
task.currentPath = source.path
task.currentSource = source.path
task.currentDestination = destination
await this.#persist(task)
await copyEntry(source.path, staging)
if (task.cancelRequested) throw new Error('file task cancelled')
if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true })
await rename(staging, destination)
task.staging = task.staging.filter(path => path !== staging)
task.published.push(destination)
await this.#phase(task, move ? 'destination-committed' : 'mutating')
if (move) await rm(source.path, { recursive: true })
task.processedEntries += 1
task.processedBytes = Math.min(task.totalBytes, task.processedBytes + (await treeMetrics(destination)).bytes)
task.currentSource = null
task.currentDestination = null
await this.#phase(task, 'mutating')
}
}
async #delete(task) {
for (const source of task.sources) {
if (task.cancelRequested) throw new Error('file task cancelled')
const hidden = join(dirname(source.path), `.dsh-delete-${task.taskId}-${randomUUID()}.tmp`)
await rename(source.path, hidden)
task.hidden.push(hidden)
await this.#phase(task, 'source-hidden')
const removed = await treeMetrics(hidden)
await rm(hidden, { recursive: true })
task.hidden = task.hidden.filter(path => path !== hidden)
task.processedEntries += removed.entries
task.processedBytes = Math.min(task.totalBytes, task.processedBytes + removed.bytes)
await this.#phase(task, 'cleaning')
}
}
async #attributes(task) {
const [uid, gid] = await Promise.all([
this.identity.userId(task.attributes.user),
this.identity.groupId(task.attributes.group),
])
const mode = Number.parseInt(task.attributes.mode, 8)
const pending = [task.sources[0].path]
task.resolvedAttributes = { uid, gid, mode }
while (pending.length > 0) {
if (task.cancelRequested) throw new Error('file task cancelled')
const path = pending.pop()
const details = await lstat(path)
if (task.attributes.recursive && details.isDirectory() && !details.isSymbolicLink()) {
for (const name of await readdir(path)) pending.push(join(path, name))
}
await lchown(path, uid, gid)
if (!details.isSymbolicLink()) await chmod(path, mode)
assertAttributesApplied(path, await lstat(path), { uid, gid, mode }, details.isSymbolicLink())
task.currentPath = path
task.processedEntries += 1
if (details.isFile()) task.processedBytes += details.size
if (task.processedEntries % 100 === 0) await this.#phase(task, 'mutating')
}
task.currentPath = null
}
async #archive(task) {
const output = await uniqueDestination(task.destination, task.conflict)
const sourceRoot = join(dirname(output), `.dsh-archive-input-${task.taskId}`)
const staging = join(dirname(output), `.dsh-archive-${task.taskId}.tmp`)
task.staging.push(sourceRoot, staging)
await mkdir(sourceRoot)
const names = new Set()
for (const source of task.sources) {
const name = cleanName(source.path)
if (names.has(name)) throw new FileManagerError('archive source names must be unique', 409, 'FILE_EXISTS')
names.add(name)
task.currentPath = source.path
await copyEntry(source.path, join(sourceRoot, name))
task.processedEntries += 1
await this.#phase(task, 'staging')
}
if (task.cancelRequested) throw new Error('file task cancelled')
const controller = new AbortController()
task.abortController = controller
try {
await createArchive({ format: task.archiveFormat, sourceRoot, output: staging, signal: controller.signal })
} finally {
task.abortController = undefined
}
if (task.cancelRequested) throw new Error('file task cancelled')
if (task.conflict === 'overwrite' && await exists(output)) await rm(output, { recursive: true })
await rename(staging, output)
task.staging = task.staging.filter(path => path !== staging)
task.published.push(output)
task.processedBytes = task.totalBytes
await rm(sourceRoot, { recursive: true, force: true })
task.staging = task.staging.filter(path => path !== sourceRoot)
task.currentPath = null
await this.#phase(task, 'destination-committed')
}
async #extract(task) {
const archive = task.sources[0].path
const staging = join(task.destination, `.dsh-extract-${task.taskId}.tmp`)
task.staging.push(staging)
task.currentPath = archive
await this.#persist(task)
const controller = new AbortController()
task.abortController = controller
let metrics
try {
metrics = await extractArchive({ format: task.archiveFormat, archive, output: staging, signal: controller.signal })
} finally {
task.abortController = undefined
}
task.totalEntries = metrics.entries
task.totalBytes = metrics.bytes
await this.#publishExtraction(task, staging)
}
async #publishExtraction(task, staging) {
const plan = []
for (const name of await readdir(staging)) {
plan.push({ source: join(staging, name), destination: await uniqueDestination(join(task.destination, name), task.conflict) })
}
for (const { source, destination } of plan) {
if (task.cancelRequested) throw new Error('file task cancelled')
if (task.conflict === 'overwrite' && await exists(destination)) await rm(destination, { recursive: true })
await rename(source, destination)
task.published.push(destination)
const item = await treeMetrics(destination)
task.processedEntries += item.entries
task.processedBytes += item.bytes
await this.#phase(task, 'publishing')
}
await rm(staging, { recursive: true, force: true })
task.staging = task.staging.filter(path => path !== staging)
task.currentPath = null
}
async #cleanupStaging(task) {
for (const path of task.staging) await rm(path, { recursive: true, force: true }).catch(() => {})
task.staging = []
}
async #recover(task) {
if (task.operation === 'attributes') {
try {
task.cancelRequested = false
task.processedEntries = 0
task.processedBytes = 0
await this.#phase(task, 'mutating')
await this.#attributes(task)
task.status = 'success'
await this.#phase(task, 'completed')
await this.report('file-task.attributes.recovered', publicTask(task))
} catch (error) {
task.status = 'failed'
task.error = error instanceof Error ? error.message : String(error)
task.errorCode = typeof error?.code === 'string' ? error.code : null
await this.#phase(task, 'failed')
await this.report('file-task.attributes.recovery-failed', { ...publicTask(task), error })
}
} else if (task.operation === 'archive' && task.phase === 'destination-committed') {
task.status = 'success'
await this.#phase(task, 'completed')
} else if (task.operation === 'extract' && task.phase === 'publishing' && task.staging?.length === 1 && await exists(task.staging[0])) {
try {
task.cancelRequested = false
await this.#publishExtraction(task, task.staging[0])
task.status = 'success'
await this.#phase(task, 'completed')
await this.report('file-task.extract.recovered', publicTask(task))
} catch (error) {
task.status = 'failed'
task.error = error instanceof Error ? error.message : String(error)
task.errorCode = typeof error?.code === 'string' ? error.code : null
await this.#phase(task, 'failed')
await this.report('file-task.extract.recovery-failed', { ...publicTask(task), error })
}
} else if (task.operation === 'delete' || task.phase === 'source-hidden' || task.phase === 'cleaning') {
for (const path of task.hidden ?? []) await rm(path, { recursive: true, force: true })
task.hidden = []
task.status = 'success'
await this.#phase(task, 'completed')
} else if (['move', 'rename'].includes(task.operation) && task.phase === 'destination-committed') {
if (task.currentSource !== null && await exists(task.currentSource)) await rm(task.currentSource, { recursive: true })
task.status = 'success'
await this.#phase(task, 'completed')
} else if (['move', 'rename'].includes(task.operation) && task.phase === 'move-prepared'
&& task.currentSource !== null && task.currentDestination !== null
&& !await exists(task.currentSource) && await exists(task.currentDestination)) {
task.published ??= []
if (!task.published.includes(task.currentDestination)) task.published.push(task.currentDestination)
task.status = 'success'
await this.#phase(task, 'completed')
} else {
await this.#cleanupStaging(task)
task.status = 'interrupted'
task.error = 'file operation was interrupted before a recoverable commit'
await this.#phase(task, 'interrupted')
}
}
async #phase(task, phase) {
task.phase = phase
task.updatedAt = now()
await this.#persist(task)
this.#publish(task)
}
async #terminal(task, status, phase) {
task.status = status
task.phase = phase
task.updatedAt = now()
await this.#persist(task)
if (this.activeMutation === task) this.activeMutation = undefined
this.queue = this.queue.filter(value => value !== task)
this.#publishQueue()
this.#publish(task)
}
async #persist(task) {
await mkdir(this.root, { recursive: true })
const value = publicTask(task)
await durableReplace(join(this.root, `${task.taskId}.json`), Buffer.from(`${JSON.stringify(value)}\n`))
}
#publish(task) { this.onState(publicTask(task)) }
async #prune() {
const completed = [...this.tasks.values()].filter(task => !['queued', 'running'].includes(task.status)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
for (const task of completed.slice(100)) {
this.tasks.delete(task.taskId)
await rm(join(this.root, `${task.taskId}.json`), { force: true })
}
}
}
export const fileTaskInternals = Object.freeze({ protectedDeleteRoots: DEFAULT_PROTECTED_DELETE_ROOTS, uniqueDestination, treeMetrics, normalizeAttributes, assertAttributesApplied })
@@ -0,0 +1,205 @@
import { createReadStream } from 'node:fs'
import { link, lstat, mkdir, mkdtemp, open, readlink, rename, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, extname, join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'
import { randomUUID } from 'node:crypto'
import {
FileManagerError, FileRevisionConflictError, fileError, fileManagerInternals,
isManagedPath, normalizeAbsolutePath,
} from './index.mjs'
import { createArchive } from './archives.mjs'
function parseRange(value, size) {
if (value === undefined) return { start: 0, end: Math.max(0, size - 1), partial: false }
const match = /^bytes=(\d*)-(\d*)$/.exec(value)
if (match === null || (match[1] === '' && match[2] === '')) throw new FileManagerError('range is invalid', 416, 'RANGE_INVALID')
let start
let end
if (match[1] === '') {
const suffix = Number(match[2])
if (!Number.isSafeInteger(suffix) || suffix < 1) throw new FileManagerError('range is invalid', 416, 'RANGE_INVALID')
start = Math.max(0, size - suffix)
end = size - 1
} else {
start = Number(match[1])
end = match[2] === '' ? size - 1 : Number(match[2])
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start) {
throw new FileManagerError('range is not satisfiable', 416, 'RANGE_INVALID')
}
return { start, end: Math.min(end, size - 1), partial: true }
}
function disposition(name) {
const fallback = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') || 'download'
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
async function existingMetadata(path, { requireFile = false } = {}) {
try {
const value = await stat(path, { bigint: true })
if (requireFile && !value.isFile()) throw new FileManagerError('upload target is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED')
return value
} catch (error) {
if (error?.code === 'ENOENT') return undefined
throw error
}
}
function renamedPath(path, index) {
const extension = extname(path)
const stem = extension === '' ? path : path.slice(0, -extension.length)
return `${stem} (${String(index)})${extension}`
}
export class FileTransferManager {
constructor({ isManaged = isManagedPath, stagingRoot = tmpdir() } = {}) {
this.isManaged = isManaged
this.stagingRoot = stagingRoot
}
async openDownload(value, { revision, range, signal } = {}) {
const path = normalizeAbsolutePath(value)
let handle
let cleanup
try {
const pathStats = await lstat(path, { bigint: true })
if (pathStats.isDirectory()) {
if (range !== undefined) throw new FileManagerError('directory downloads do not support ranges', 416, 'RANGE_INVALID')
const actualRevision = fileManagerInternals.revisionFor(path, pathStats)
if (revision !== undefined && revision !== '' && revision !== actualRevision) throw new FileRevisionConflictError('download revision changed')
await mkdir(this.stagingRoot, { recursive: true })
const staging = await mkdtemp(join(this.stagingRoot, '.dsh-directory-download-'))
cleanup = () => rm(staging, { recursive: true, force: true })
const name = basename(path) || 'root'
const archive = join(staging, `${name}.zip`)
await createArchive({ format: 'zip', sourceRoot: path === '/' ? path : dirname(path), output: archive, entries: [path === '/' ? '.' : name], signal })
const download = await this.openDownload(archive, { signal })
download.path = path
download.revision = actualRevision
download.headers['content-disposition'] = disposition(`${name}.zip`)
download.cleanup = cleanup
cleanup = undefined
return download
}
handle = await open(path, 'r')
const before = await handle.stat({ bigint: true })
if (!before.isFile()) throw new FileManagerError('path is not a regular file', 415, 'FILE_TYPE_UNSUPPORTED')
const actualRevision = fileManagerInternals.revisionFor(path, before, await readlink(path).catch(() => ''))
if (revision !== undefined && revision !== '' && revision !== actualRevision) throw new FileRevisionConflictError('download revision changed')
const size = Number(before.size)
if (!Number.isSafeInteger(size)) throw new FileManagerError('file is too large', 413, 'FILE_TOO_LARGE')
if (size === 0 && range !== undefined) throw new FileManagerError('range is not satisfiable', 416, 'RANGE_INVALID')
const selected = size === 0 ? { start: 0, end: -1, partial: false } : parseRange(range, size)
const length = selected.end < selected.start ? 0 : selected.end - selected.start + 1
const stream = length === 0 ? Readable.from([]) : createReadStream(path, {
fd: handle.fd, autoClose: false, start: selected.start, end: selected.end,
})
return {
path, handle, stream, revision: actualRevision,
status: selected.partial ? 206 : 200,
headers: {
'accept-ranges': 'bytes',
'cache-control': 'no-store',
'content-disposition': disposition(basename(path)),
'content-length': String(length),
'content-type': 'application/octet-stream',
etag: `"${actualRevision}"`,
...(selected.partial ? { 'content-range': `bytes ${String(selected.start)}-${String(selected.end)}/${String(size)}` } : {}),
},
}
} catch (error) {
await handle?.close().catch(() => {})
await cleanup?.().catch(() => {})
throw fileError(error)
}
}
async sendDownload(response, download, { onProgress = () => {}, signal } = {}) {
response.writeHead(download.status, download.headers)
let sent = 0
download.stream.on('data', chunk => {
sent += chunk.byteLength
onProgress(sent, Number(download.headers['content-length']))
})
try {
await pipeline(download.stream, response, { signal })
} finally {
await download.handle.close().catch(() => {})
await download.cleanup?.().catch(() => {})
}
}
async upload(input, value, { conflict = 'reject', contentLength, onProgress = () => {}, signal } = {}) {
const requestedPath = normalizeAbsolutePath(value)
if (!['reject', 'overwrite', 'rename'].includes(conflict)) throw new FileManagerError('upload conflict mode is invalid')
const parent = dirname(requestedPath)
const parentStats = await stat(parent).catch(error => { throw fileError(error) })
if (!parentStats.isDirectory()) throw new FileManagerError('upload parent is not a directory', 415, 'FILE_TYPE_UNSUPPORTED')
const staging = join(parent, `.dsh-upload-${randomUUID()}.tmp`)
let handle
let received = 0
try {
const original = await existingMetadata(requestedPath, { requireFile: conflict === 'overwrite' })
if (original !== undefined && conflict === 'reject') throw new FileManagerError('upload target already exists', 409, 'FILE_EXISTS')
handle = await open(staging, 'wx', 0o600)
for await (const chunk of input) {
signal?.throwIfAborted()
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
received += bytes.byteLength
await handle.write(bytes)
onProgress(received, contentLength)
}
if (contentLength !== undefined && received !== contentLength) throw new FileManagerError('upload size does not match Content-Length')
await handle.sync()
if (original === undefined) await handle.chmod(0o644)
else {
await handle.chmod(Number(original.mode & 0o7777n))
const current = await handle.stat({ bigint: true })
if (current.uid !== original.uid || current.gid !== original.gid) await handle.chown(Number(original.uid), Number(original.gid))
}
await handle.close()
handle = undefined
let path = requestedPath
if (conflict === 'overwrite') await rename(staging, path)
else if (conflict === 'reject') {
await link(staging, path)
await rm(staging)
} else {
let index = 0
for (;;) {
path = index === 0 ? requestedPath : renamedPath(requestedPath, index)
try {
await link(staging, path)
await rm(staging)
break
} catch (error) {
if (error?.code !== 'EEXIST') throw error
index += 1
}
}
}
await syncDirectory(parent)
const result = await stat(path, { bigint: true })
return {
path,
size: received,
revision: fileManagerInternals.revisionFor(path, result),
managed: this.isManaged(path),
}
} catch (error) {
await handle?.close().catch(() => {})
await rm(staging, { force: true }).catch(() => {})
throw fileError(error)
}
}
}
export const fileTransferInternals = Object.freeze({ parseRange, disposition, renamedPath })
@@ -0,0 +1,473 @@
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { appendFile, chmod, chown, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
const SOURCE_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/
const MAX_ENTRY_BYTES = 64 * 1024
const CONSOLE_MARKER = 'dsh-platform-log-v1'
const LOG_LEVELS = new Set(['debug', 'info', 'warning', 'error'])
const MAX_DIAGNOSTIC_STRING = 16 * 1024
const MAX_ERROR_DEPTH = 3
const MAX_AGGREGATE_ERRORS = 8
const SUPPRESSION_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,255}$/
function boundedString(value) {
const text = String(value)
return text.length <= MAX_DIAGNOSTIC_STRING ? text : `${text.slice(0, MAX_DIAGNOSTIC_STRING)}...[truncated]`
}
export function errorDetails(error, depth = 0) {
if (error === undefined || error === null) return {}
if (!(error instanceof Error)) return { error: boundedString(error), errorType: typeof error }
const details = {
error: boundedString(error.message || error.name),
errorName: boundedString(error.name),
}
if (typeof error.code === 'string' || typeof error.code === 'number') details.errorCode = error.code
if (typeof error.stack === 'string') details.errorStack = boundedString(error.stack)
if (depth >= MAX_ERROR_DEPTH) return details
if (error.cause !== undefined) details.errorCause = errorDetails(error.cause, depth + 1)
if (error instanceof AggregateError) {
details.errors = [...error.errors].slice(0, MAX_AGGREGATE_ERRORS).map(value => errorDetails(value, depth + 1))
if (error.errors.length > MAX_AGGREGATE_ERRORS) details.errorsTruncated = error.errors.length - MAX_AGGREGATE_ERRORS
}
return details
}
function validateSource(source) {
if (typeof source !== 'string' || !SOURCE_PATTERN.test(source)) throw new Error('log source is invalid')
return source
}
function validateFilter(value, label) {
if (value === undefined) return undefined
if (typeof value !== 'string' || value.length === 0 || value.length > 128 || /[\0\r\n]/u.test(value)) {
throw new Error(`${label} filter is invalid`)
}
return value
}
function matches(entry, filters) {
return (filters.taskId === undefined || entry.taskId === filters.taskId)
&& (filters.operation === undefined || entry.operation === filters.operation)
&& (filters.phase === undefined || entry.phase === filters.phase)
}
function parseLine(line) {
try { return JSON.parse(line) } catch { return undefined }
}
function parseForwardedLine(line) {
const entry = parseLine(line)
if (
entry?.platformLog !== CONSOLE_MARKER
|| typeof entry.timestamp !== 'string'
|| typeof entry.message !== 'string'
|| !['stdout', 'stderr', 'audit', 'platform'].includes(entry.stream)
) return undefined
try { validateSource(entry.source) } catch { return undefined }
return entry
}
export function capturedLogLevel(stream, message) {
if (stream !== 'stderr') return 'info'
const text = String(message).trim()
const lines = text.split('\n')
if (lines.some(line => /^(?:\s*at\s+|.*\b(?:error|fatal|failed|failure|exception|panic|unhandled)\b)|错误|失败|异常|致命/iu.test(line))) {
return 'error'
}
if (lines.some(line => /\b(?:warn|warning|deprecated|deprecation)\b|警告|已弃用/iu.test(line))) return 'warning'
return 'info'
}
const NODE_ERROR_LINE = /^\s*(?:AggregateError|Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError):/u
const NODE_LOCATION_LINE = /^(?:file:\/\/|\/).*:\d+(?::\d+)?$/u
function isNodeErrorContinuation(line) {
return line === ''
|| /^\s/u.test(line)
|| NODE_ERROR_LINE.test(line)
|| /^Node\.js v\d/u.test(line)
|| /^\s*(?:\[cause\]|\[errors\]|Caused by:)/u.test(line)
|| /^\s*[{}\[\]},]+$/u.test(line)
}
function capturedMessages(source, stream, lines) {
if (source !== 'dsh-runtime' || stream !== 'stderr') return lines
const messages = []
let cursor = 0
while (cursor < lines.length) {
const relativeError = lines.slice(cursor).findIndex(line => NODE_ERROR_LINE.test(line))
if (relativeError < 0) {
messages.push(...lines.slice(cursor))
break
}
const errorIndex = cursor + relativeError
let start = errorIndex
for (let index = errorIndex - 1; index >= cursor && index >= errorIndex - 6; index -= 1) {
if (NODE_LOCATION_LINE.test(lines[index])) {
start = index
break
}
}
messages.push(...lines.slice(cursor, start))
let end = errorIndex + 1
while (end < lines.length && isNodeErrorContinuation(lines[end])) {
const terminal = /^Node\.js v\d/u.test(lines[end])
end += 1
if (terminal) break
}
messages.push(boundedString(lines.slice(start, end).join('\n')))
cursor = end
}
return messages
}
export class JsonlLogManager extends EventEmitter {
constructor({
root,
maxBytes = 100 * 1024 * 1024,
retentionDays = 14,
rotateBytes = 10 * 1024 * 1024,
now = () => new Date(),
output,
fileMode = 0o660,
fileUid,
fileGid,
}) {
super()
if (!Number.isSafeInteger(maxBytes) || maxBytes < MAX_ENTRY_BYTES) throw new Error('log maxBytes is invalid')
if (!Number.isSafeInteger(retentionDays) || retentionDays < 1) throw new Error('log retentionDays is invalid')
if (!Number.isSafeInteger(rotateBytes) || rotateBytes < 1_024) throw new Error('log rotateBytes is invalid')
if (!Number.isSafeInteger(fileMode) || fileMode < 0 || fileMode > 0o777) throw new Error('log fileMode is invalid')
this.root = root
this.maxBytes = maxBytes
this.retentionMs = retentionDays * 86_400_000
this.rotateBytes = rotateBytes
this.now = now
this.output = output
this.fileMode = fileMode
this.fileUid = fileUid
this.fileGid = fileGid
this.queue = Promise.resolve()
this.suppressedDiagnostics = new Map()
}
prepare() {
return this.serialized(async () => {
await mkdir(this.root, { recursive: true })
const names = (await readdir(this.root)).filter(name => name.endsWith('.jsonl'))
for (const name of names) {
const path = join(this.root, name)
if (this.fileGid !== undefined) await chown(path, -1, this.fileGid)
await chmod(path, this.fileMode)
}
})
}
writeOutput(stream, line) {
const destination = stream === 'stderr' ? this.output?.stderr : this.output?.stdout
destination?.write(`${line}\n`)
}
mirror(entry) {
if (this.output === undefined) return
this.writeOutput(entry.stream, JSON.stringify({ ...entry, platformLog: CONSOLE_MARKER }))
}
serialized(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
currentPath(source) {
return join(this.root, `${validateSource(source)}.jsonl`)
}
append(source, stream, message, fields = {}) {
return this.serialized(async () => {
validateSource(source)
if (!['stdout', 'stderr', 'audit', 'platform'].includes(stream)) throw new Error('log stream is invalid')
if (typeof message !== 'string') throw new Error('log message must be a string')
const level = fields.level ?? (stream === 'stderr' ? 'error' : 'info')
if (!LOG_LEVELS.has(level)) throw new Error('log level is invalid')
const entry = { ...fields, timestamp: this.now().toISOString(), source, stream, level, message }
const line = Buffer.from(`${JSON.stringify(entry)}\n`)
if (line.byteLength > MAX_ENTRY_BYTES) throw new Error('log entry exceeds 64 KiB')
await mkdir(this.root, { recursive: true })
const path = this.currentPath(source)
let exists = true
const size = await stat(path).then(value => value.size, error => {
if (error?.code !== 'ENOENT') throw error
exists = false
return 0
})
if (size > 0 && size + line.byteLength > this.rotateBytes) {
await rename(path, join(this.root, `${source}.${Date.now()}.${randomUUID()}.jsonl`))
exists = false
}
await appendFile(path, line, { mode: this.fileMode })
if (!exists) {
if (this.fileUid !== undefined || this.fileGid !== undefined) await chown(path, this.fileUid ?? -1, this.fileGid ?? -1)
await chmod(path, this.fileMode)
}
this.emit('entry', Object.freeze(entry))
this.mirror(entry)
await this.pruneUnlocked()
return Object.freeze(entry)
})
}
audit(action, fields = {}) {
return this.append('audit', 'audit', action, fields)
}
async diagnostic(source, message, { error, level = error === undefined || error === null ? 'info' : 'error', stream = 'platform', ...fields } = {}) {
const details = { ...fields, ...errorDetails(error), level }
try {
return await this.append(source, stream, message, details)
} catch (loggingError) {
try {
this.writeOutput('stderr', JSON.stringify({
timestamp: this.now().toISOString(),
source: 'log-manager',
stream: 'stderr',
level: 'error',
message: 'diagnostic.write.failed',
diagnostic: { source, stream, message, ...details },
loggingError: errorDetails(loggingError),
platformLog: CONSOLE_MARKER,
}))
} catch {}
return undefined
}
}
diagnosticRateLimited(key, source, message, fields = {}, { intervalMs = 30_000 } = {}) {
if (typeof key !== 'string' || !SUPPRESSION_KEY_PATTERN.test(key)) {
throw new Error('diagnostic suppression key is invalid')
}
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1_000 || intervalMs > 86_400_000) {
throw new Error('diagnostic suppression interval is invalid')
}
const timestamp = this.now().getTime()
const previous = this.suppressedDiagnostics.get(key)
if (previous !== undefined && timestamp - previous.lastReportedAt < intervalMs) {
previous.suppressedCount += 1
return Promise.resolve(undefined)
}
const suppressedCount = previous?.suppressedCount ?? 0
this.suppressedDiagnostics.set(key, { lastReportedAt: timestamp, suppressedCount: 0 })
return this.diagnostic(source, message, {
...fields,
...(suppressedCount === 0 ? {} : { suppressedCount }),
})
}
async files() {
try {
const names = (await readdir(this.root)).filter(name => name.endsWith('.jsonl'))
return Promise.all(names.map(async name => ({ name, path: join(this.root, name), details: await stat(join(this.root, name)) })))
} catch (error) {
if (error?.code === 'ENOENT') return []
throw error
}
}
async pruneUnlocked() {
const cutoff = this.now().getTime() - this.retentionMs
let files = await this.files()
for (const file of files) {
if (file.details.mtimeMs < cutoff) await rm(file.path, { force: true })
}
files = (await this.files()).sort((left, right) => left.details.mtimeMs - right.details.mtimeMs)
let total = files.reduce((sum, file) => sum + file.details.size, 0)
for (const file of files) {
if (total <= this.maxBytes) break
await rm(file.path, { force: true })
total -= file.details.size
}
}
prune() {
return this.serialized(() => this.pruneUnlocked())
}
async query({ sources, since, taskId, operation, phase, limit = 200 } = {}) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) throw new Error('log query limit is invalid')
const selected = sources === undefined ? undefined : new Set(sources.map(validateSource))
const threshold = since === undefined ? undefined : new Date(since).toISOString()
const filters = {
taskId: validateFilter(taskId, 'taskId'),
operation: validateFilter(operation, 'operation'),
phase: validateFilter(phase, 'phase'),
}
const entries = []
for (const file of await this.files()) {
const lines = (await readFile(file.path, 'utf8')).split('\n')
for (const line of lines) {
if (line === '') continue
const entry = parseLine(line)
if (
entry !== undefined
&& (selected === undefined || selected.has(entry.source))
&& (threshold === undefined || entry.timestamp >= threshold)
&& matches(entry, filters)
) entries.push(entry)
}
}
entries.sort((left, right) => left.timestamp.localeCompare(right.timestamp))
return Object.freeze(entries.slice(-limit).map(Object.freeze))
}
async sources() {
const result = new Set()
for (const file of await this.files()) {
for (const line of (await readFile(file.path, 'utf8')).split('\n')) {
const entry = parseLine(line)
if (entry?.source !== undefined) {
try { result.add(validateSource(entry.source)) } catch {}
}
}
}
return [...result].sort()
}
async follow({ sources, since, taskId, operation, phase } = {}, listener, { intervalMs = 250, onError = () => {} } = {}) {
if (typeof listener !== 'function') throw new Error('log follow listener is required')
if (!Number.isSafeInteger(intervalMs) || intervalMs < 50) throw new Error('log follow interval is invalid')
const selected = sources === undefined ? undefined : new Set(sources.map(validateSource))
const threshold = since === undefined ? undefined : new Date(since).toISOString()
const filters = {
taskId: validateFilter(taskId, 'taskId'),
operation: validateFilter(operation, 'operation'),
phase: validateFilter(phase, 'phase'),
}
await mkdir(this.root, { recursive: true })
let states = new Map()
for (const file of await this.files()) {
states.set(file.name, { identity: `${String(file.details.dev)}:${String(file.details.ino)}`, offset: file.details.size, pending: Buffer.alloc(0) })
}
let closed = false
let scanning = false
let scanAgain = false
const scan = async () => {
if (closed) return
if (scanning) {
scanAgain = true
return
}
scanning = true
try {
const files = await this.files()
const byIdentity = new Map([...states.values()].map(state => [state.identity, state]))
const nextStates = new Map()
const entries = []
for (const file of files) {
const identity = `${String(file.details.dev)}:${String(file.details.ino)}`
let state = states.get(file.name)
if (state?.identity !== identity) state = byIdentity.get(identity)
if (state === undefined || file.details.size < state.offset) state = { identity, offset: 0, pending: Buffer.alloc(0) }
if (file.details.size > state.offset) {
const chunks = []
const end = file.details.size - 1
for await (const chunk of createReadStream(file.path, { start: state.offset, end })) chunks.push(chunk)
const content = Buffer.concat([state.pending, ...chunks])
let lineStart = 0
for (let index = 0; index < content.length; index += 1) {
if (content[index] !== 0x0a) continue
const line = content.subarray(lineStart, index).toString('utf8')
lineStart = index + 1
if (line === '') continue
const entry = parseLine(line)
if (entry !== undefined
&& (selected === undefined || selected.has(entry.source))
&& (threshold === undefined || entry.timestamp >= threshold)
&& matches(entry, filters)) entries.push(entry)
}
state = { ...state, identity, offset: file.details.size, pending: content.subarray(lineStart) }
}
nextStates.set(file.name, state)
}
states = nextStates
entries.sort((left, right) => left.timestamp.localeCompare(right.timestamp))
for (const entry of entries) listener(Object.freeze(entry))
} catch (error) {
onError(error)
} finally {
scanning = false
if (scanAgain) {
scanAgain = false
void scan()
}
}
}
const timer = setInterval(() => { void scan() }, intervalMs)
timer.unref()
return Object.freeze({
close: () => {
closed = true
clearInterval(timer)
},
})
}
capture(child, source, declaration = { stdout: true, stderr: true }, { acceptForwarded = false } = {}) {
for (const streamName of ['stdout', 'stderr']) {
if (!declaration[streamName]) continue
const stream = child[streamName]
if (stream === undefined || stream === null) continue
let pending = ''
let completeLines = []
let flushScheduled = false
const appendLines = lines => {
if (lines.length === 0) return
const messages = capturedMessages(source, streamName, lines)
for (const message of messages) {
void this.append(source, streamName, message, { level: capturedLogLevel(streamName, message) })
.catch(error => this.emit('error', error))
}
}
const flush = () => {
flushScheduled = false
const lines = completeLines
completeLines = []
let unforwarded = []
const flushUnforwarded = () => {
appendLines(unforwarded)
unforwarded = []
}
for (const line of lines) {
const forwarded = acceptForwarded && this.output !== undefined ? parseForwardedLine(line) : undefined
if (forwarded === undefined) unforwarded.push(line)
else {
flushUnforwarded()
this.writeOutput(forwarded.stream, line)
}
}
flushUnforwarded()
}
const scheduleFlush = () => {
if (flushScheduled) return
flushScheduled = true
setImmediate(flush)
}
stream.setEncoding?.('utf8')
stream.on('data', chunk => {
pending += String(chunk)
const lines = pending.split('\n')
pending = lines.pop()
completeLines.push(...lines)
scheduleFlush()
})
stream.once('end', () => {
if (pending !== '') completeLines.push(pending)
pending = ''
flush()
})
}
}
}
@@ -0,0 +1,215 @@
import { createHash, randomUUID } from 'node:crypto'
import { spawn } from 'node:child_process'
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { TrustError } from '../../../platform/lib/validation.mjs'
import { artifactForReference, parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs'
async function patchModule(path) {
return import(`${pathToFileURL(resolve(path)).href}?runtime=${randomUUID()}`)
}
export async function verifyNpmIntegrity(path, integrity) {
if (typeof integrity !== 'string' || !integrity.startsWith('sha512-')) {
throw new TrustError('npm integrity must use SHA-512')
}
const expected = integrity.slice('sha512-'.length)
const bytes = await readFile(path)
const actual = createHash('sha512').update(bytes).digest('base64')
if (actual !== expected) throw new TrustError('npm tarball integrity mismatch', 'TRUST_ARTIFACT_MISMATCH')
return bytes.byteLength
}
export async function applyPatchSet(dshRoot, patchPaths) {
for (const path of patchPaths) {
const module = await patchModule(path)
if (typeof module.applyPatch !== 'function') {
throw new Error(`Patch ${path} does not export applyPatch(dshRoot)`)
}
await module.applyPatch(dshRoot)
}
}
export async function verifyPatchSet(dshRoot, patchPaths) {
for (const path of patchPaths) {
const module = await patchModule(path)
if (typeof module.verifyPatch !== 'function') {
throw new Error(`Patch ${path} does not export verifyPatch(dshRoot)`)
}
await module.verifyPatch(resolve(dshRoot))
}
}
export async function verifyRuntimePatches({ runtimeRoot, environmentRoot }) {
const patchSet = await loadEnvironmentPatchSet(environmentRoot)
await verifyPatchSet(join(resolve(runtimeRoot), 'package'), patchSet.paths)
return patchSet.ids
}
export async function loadEnvironmentPatchSet(environmentRoot) {
const environment = resolve(environmentRoot)
const manifest = parseEnvironmentManifest(await readFile(join(environment, 'environment.manifest.json')))
const paths = []
for (const reference of manifest.patches) {
const descriptor = artifactForReference(manifest, reference)
const path = join(environment, 'artifacts', descriptor.id)
const bytes = await readFile(path)
if (bytes.byteLength !== descriptor.size
|| createHash('sha256').update(bytes).digest('hex') !== descriptor.sha256) {
throw new TrustError(`Patch Artifact ${descriptor.id} differs from the Environment Manifest`)
}
paths.push(path)
}
return Object.freeze({
ids: Object.freeze(manifest.patches.map(reference => reference.id)),
paths: Object.freeze(paths),
})
}
async function measureTree(path) {
const details = await lstat(path)
if (details.isSymbolicLink()) return { bytes: 0, items: 1 }
if (!details.isDirectory()) return { bytes: details.size, items: 1 }
let bytes = 0
let items = 0
for (const entry of await readdir(path)) {
const measured = await measureTree(join(path, entry))
bytes += measured.bytes
items += measured.items
}
return { bytes, items }
}
async function copyTree(source, destination, onProgress = async () => {}) {
const details = await lstat(source)
if (details.isSymbolicLink()) {
await symlink(await readlink(source), destination)
await onProgress({ processedBytes: 0, processedItems: 1 })
return
}
if (!details.isDirectory()) {
await cp(source, destination, { preserveTimestamps: true, verbatimSymlinks: true })
await onProgress({ processedBytes: details.size, processedItems: 1 })
return
}
await mkdir(destination, { recursive: true })
for (const entry of await readdir(source)) {
await copyTree(join(source, entry), join(destination, entry), onProgress)
}
}
export async function buildRuntime({ pristineRoot, versionsRoot, runtimeId, patchPaths, onProgress = async () => {} }) {
if (typeof runtimeId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(runtimeId)) {
throw new Error('Runtime ID is invalid')
}
const versions = resolve(versionsRoot)
const destination = join(versions, runtimeId)
try {
await lstat(destination)
throw new Error(`Runtime ${runtimeId} already exists`)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
await mkdir(versions, { recursive: true })
const staging = join(versions, `.${runtimeId}.${randomUUID()}.tmp`)
try {
const packageRoot = join(staging, 'package')
await mkdir(staging)
const total = await measureTree(pristineRoot)
let processedBytes = 0
let processedItems = 0
await copyTree(pristineRoot, packageRoot, async update => {
processedBytes += update.processedBytes
processedItems += update.processedItems
await onProgress({ processedBytes, totalBytes: total.bytes, processedItems, totalItems: total.items })
})
await applyPatchSet(packageRoot, patchPaths)
const binTarget = join(packageRoot, 'lib', 'bin.js')
const details = await lstat(binTarget)
if (!details.isFile()) throw new Error('DSH Runtime has no lib/bin.js entrypoint')
await mkdir(join(staging, 'bin'))
await symlink('../package/lib/bin.js', join(staging, 'bin', 'dsh'))
await rename(staging, destination)
return destination
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async function optionalLink(path) {
try {
return basename(await import('node:fs/promises').then(({ readlink }) => readlink(path)))
} catch (error) {
if (error?.code === 'ENOENT') return undefined
throw error
}
}
async function replaceLink(root, name, runtimeId) {
const temporary = join(root, `.${name}.${randomUUID()}.tmp`)
await symlink(join('versions', runtimeId), temporary)
await rename(temporary, join(root, name))
}
export function cloneTree(source, destination) {
return new Promise((resolveClone, reject) => {
const child = spawn('cp', ['-a', '--reflink=auto', '--', resolve(source), resolve(destination)], {
stdio: ['ignore', 'ignore', 'pipe'],
})
const stderr = []
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolveClone(destination)
: reject(new Error(`Platform tree copy failed: ${Buffer.concat(stderr).toString('utf8').trim()}`)))
})
}
export class RuntimeSlots {
constructor(root) {
this.root = resolve(root)
}
async state() {
return Object.freeze({
current: await optionalLink(join(this.root, 'current')),
previous: await optionalLink(join(this.root, 'previous')),
})
}
async promote(runtimeId) {
await lstat(join(this.root, 'versions', runtimeId))
const state = await this.state()
if (state.current !== undefined && state.current !== runtimeId) await replaceLink(this.root, 'previous', state.current)
await replaceLink(this.root, 'current', runtimeId)
return this.state()
}
async rollback() {
const state = await this.state()
if (state.previous === undefined) throw new Error('no previous Runtime exists')
await replaceLink(this.root, 'current', state.previous)
if (state.current !== undefined) await replaceLink(this.root, 'previous', state.current)
return this.state()
}
async prune() {
const state = await this.state()
const retained = new Set([state.current, state.previous].filter(Boolean))
let versions
try { versions = await readdir(join(this.root, 'versions')) } catch (error) {
if (error?.code === 'ENOENT') return Object.freeze([])
throw error
}
const removed = []
for (const version of versions) {
if (!version.startsWith('.') && !retained.has(version)) {
await rm(join(this.root, 'versions', version), { recursive: true, force: true })
removed.push(version)
}
}
return Object.freeze(removed.sort())
}
}
@@ -0,0 +1,441 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { artifactForReference, parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs'
import { hashTree } from '../../../platform/lib/tree-hash.mjs'
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/
const SELECTION_SCHEMA = 1
export const PROTECTED_SYSTEM_PLUGIN_IDS = Object.freeze(['platform-management'])
function assertPluginId(pluginId) {
if (typeof pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(pluginId)) {
throw new Error('System Plugin ID is invalid')
}
return pluginId
}
function run(command, args) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => {
if (code === 0) resolveRun(Buffer.concat(stdout).toString('utf8'))
else reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`))
})
})
}
async function extractPackage(archive, destination) {
const listing = (await run('tar', ['-tzf', archive])).split('\n').filter(Boolean)
if (listing.length === 0) throw new Error('System Plugin archive is empty')
for (const name of listing) {
if (!name.startsWith('package/') || name.startsWith('/') || name.split('/').includes('..')) {
throw new Error(`System Plugin archive path is unsafe: ${name}`)
}
}
await mkdir(destination, { recursive: true })
await run('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '--no-same-permissions', '-C', destination])
}
function validatePackage(value, pluginId) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`System Plugin ${pluginId} package.json must be an object`)
}
const expectedName = `@dsh-docker/${pluginId}`
if (value.name !== expectedName) throw new Error(`System Plugin ${pluginId} package name must be ${expectedName}`)
if (typeof value.main !== 'string' || value.main.length === 0
|| value.main.startsWith('/') || value.main.includes('\\') || value.main.split('/').includes('..')) {
throw new Error(`System Plugin ${pluginId} package main must be a safe relative path`)
}
return expectedName
}
function validatePatch(value, pluginId, packageName) {
if (!Array.isArray(value)) throw new Error(`System Plugin ${pluginId} cordis.patch.json must be an array`)
return value.map((entry, index) => {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`System Plugin ${pluginId} patch ${String(index)} must be an object`)
}
if (Object.keys(entry).length !== 1 || !Array.isArray(entry.insert) || entry.insert.length === 0) {
throw new Error(`System Plugin ${pluginId} patches must contain only a non-empty insert list`)
}
for (const row of entry.insert) {
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
throw new Error(`System Plugin ${pluginId} inserted rows must be objects`)
}
if (typeof row.id !== 'string' || !row.id.startsWith(`dsh-docker.${pluginId}.`)) {
throw new Error(`System Plugin ${pluginId} patch IDs must use its dsh-docker namespace`)
}
if (typeof row.name !== 'string' || row.name.length === 0) {
throw new Error(`System Plugin ${pluginId} inserted rows must name a package`)
}
if (row.name !== packageName) {
throw new Error(`System Plugin ${pluginId} inserted rows must load its own package`)
}
}
return {
insert: entry.insert.map(row => ({ ...row })),
}
})
}
async function replaceLink(root, name, version) {
const temporary = join(root, `.${name}.${randomUUID()}.tmp`)
await symlink(join('versions', version), temporary)
await rename(temporary, join(root, name))
}
export async function reconcileSystemPlugins({ root, environmentVersion, plugins, artifactPath }) {
const managedRoot = resolve(root)
const versions = join(managedRoot, 'versions')
const destination = join(versions, environmentVersion)
const staging = join(versions, `.${environmentVersion}.${randomUUID()}.tmp`)
await mkdir(staging, { recursive: true })
const patches = []
try {
for (const plugin of plugins) {
const packageRoot = join(staging, 'packages', plugin.id)
await extractPackage(artifactPath(plugin), packageRoot)
const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
const packageName = validatePackage(metadata, plugin.id)
const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8'))
patches.push(...validatePatch(patch, plugin.id, packageName))
}
await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' })
await rename(staging, destination)
const current = await import('node:fs/promises').then(({ readlink }) => readlink(join(managedRoot, 'current')).catch(error => error.code === 'ENOENT' ? undefined : Promise.reject(error)))
if (current !== undefined) await replaceLink(managedRoot, 'previous', current.split('/').at(-1))
await replaceLink(managedRoot, 'current', environmentVersion)
return destination
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async function readOverlay(root) {
const patch = JSON.parse(await readFile(join(root, 'cordis.patch.yml'), 'utf8'))
if (!Array.isArray(patch)) throw new Error('System Plugin overlay patch must be an array')
return patch
}
function defaultSelection(pluginIds, protectedIds) {
return Object.fromEntries(pluginIds.map(id => [id, {
installed: true,
enabled: true,
protected: protectedIds.has(id),
}]))
}
function validateSelectionDocument(value, pluginIds, protectedIds) {
if (value === null || typeof value !== 'object' || Array.isArray(value) || value.schema !== SELECTION_SCHEMA
|| value.plugins === null || typeof value.plugins !== 'object' || Array.isArray(value.plugins)) {
throw new Error('System Plugin selection state is invalid')
}
const selection = defaultSelection(pluginIds, protectedIds)
for (const id of pluginIds) {
const saved = value.plugins[id]
if (saved === undefined) continue
if (saved === null || typeof saved !== 'object' || Array.isArray(saved)
|| typeof saved.installed !== 'boolean' || typeof saved.enabled !== 'boolean') {
throw new Error(`System Plugin ${id} selection state is invalid`)
}
if (saved.enabled && !saved.installed) {
throw new Error(`System Plugin ${id} cannot be enabled while it is not installed`)
}
selection[id] = { installed: saved.installed, enabled: saved.enabled, protected: protectedIds.has(id) }
}
return selection
}
export class SystemPluginSelectionStore {
constructor(path, { protectedIds = PROTECTED_SYSTEM_PLUGIN_IDS } = {}) {
this.path = resolve(path)
this.protectedIds = new Set(protectedIds)
}
async read(pluginIds) {
const ids = pluginIds.map(assertPluginId)
let value
try {
value = JSON.parse(await readFile(this.path, 'utf8'))
} catch (error) {
if (error?.code === 'ENOENT') return defaultSelection(ids, this.protectedIds)
throw error
}
return validateSelectionDocument(value, ids, this.protectedIds)
}
async write(pluginIds, selection) {
const ids = pluginIds.map(assertPluginId)
const normalized = validateSelectionDocument({ schema: SELECTION_SCHEMA, plugins: selection }, ids, this.protectedIds)
const document = {
schema: SELECTION_SCHEMA,
plugins: Object.fromEntries(ids.map(id => [id, {
installed: normalized[id].installed,
enabled: normalized[id].enabled,
}])),
}
await mkdir(dirname(this.path), { recursive: true })
const temporary = `${this.path}.${randomUUID()}.tmp`
try {
await writeFile(temporary, `${canonicalJson(document)}\n`, { flag: 'wx', mode: 0o600 })
await rename(temporary, this.path)
} finally {
await rm(temporary, { force: true })
}
return normalized
}
async configure(pluginIds, pluginId, action) {
const ids = pluginIds.map(assertPluginId)
assertPluginId(pluginId)
if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`)
if (this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is managed by the platform and cannot be changed`)
return this.configureSelection(ids, pluginId, action)
}
async recover(pluginIds, pluginId, action) {
const ids = pluginIds.map(assertPluginId)
assertPluginId(pluginId)
if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`)
if (!this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is not a platform recovery target`)
return this.configureSelection(ids, pluginId, action)
}
async configureSelection(ids, pluginId, action) {
if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) {
throw new Error('System Plugin action is invalid')
}
const selection = await this.read(ids)
const current = selection[pluginId]
if (action === 'install') selection[pluginId] = { ...current, installed: true, enabled: true }
else if (action === 'uninstall') selection[pluginId] = { ...current, installed: false, enabled: false }
else if (action === 'enable') {
if (!current.installed) throw new Error(`System Plugin ${pluginId} must be installed before it can be enabled`)
selection[pluginId] = { ...current, enabled: true }
} else selection[pluginId] = { ...current, enabled: false }
return this.write(ids, selection)
}
}
async function pluginCatalog(environmentRoot) {
const root = resolve(environmentRoot)
const manifest = parseEnvironmentManifest(await readFile(join(root, 'environment.manifest.json')))
return {
root,
plugins: manifest.systemPlugins.map(reference => ({
id: reference.id,
descriptor: artifactForReference(manifest, reference),
})),
}
}
async function readActiveSelection(root, plugins) {
if (root === undefined) return undefined
const overlay = await readOverlay(resolve(root))
const enabledPackages = new Set(overlay.flatMap(entry => (
Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : []
)))
return Object.fromEntries(await Promise.all(plugins.map(async ({ id }) => {
const installed = await lstat(join(resolve(root), 'packages', id)).then(
() => true,
error => error?.code === 'ENOENT' ? false : Promise.reject(error),
)
return [id, { installed, enabled: installed && enabledPackages.has(`@dsh-docker/${id}`) }]
})))
}
export async function listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot }) {
const catalog = await pluginCatalog(environmentRoot)
const selection = await selectionStore.read(catalog.plugins.map(plugin => plugin.id))
const active = await readActiveSelection(activeRoot, catalog.plugins)
return Object.freeze(await Promise.all(catalog.plugins.map(async ({ id, descriptor }) => {
const metadata = JSON.parse(await readFile(join(resolve(sourceRoot), 'packages', id, 'package.json'), 'utf8'))
validatePackage(metadata, id)
let description = null
const value = metadata.dshDocker?.description
if (value !== undefined) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| typeof value.zh !== 'string' || typeof value.en !== 'string'
|| value.zh.length === 0 || value.en.length === 0 || value.zh.length > 240 || value.en.length > 240) {
throw new Error(`System Plugin ${id} description is invalid`)
}
description = Object.freeze({ zh: value.zh, en: value.en })
}
const activeInstalled = active?.[id].installed ?? selection[id].installed
const activeEnabled = active?.[id].enabled ?? selection[id].enabled
return Object.freeze({
id,
artifactId: descriptor.id,
sha256: descriptor.sha256,
description,
installed: selection[id].installed,
enabled: selection[id].enabled,
activeInstalled,
activeEnabled,
pendingRestart: activeInstalled !== selection[id].installed || activeEnabled !== selection[id].enabled,
protected: selection[id].protected,
reason: null,
})
})))
}
export async function discardSystemPluginSelection({ environmentRoot, sourceRoot, selectionStore, activeRoot }) {
const catalog = await pluginCatalog(environmentRoot)
const pluginIds = catalog.plugins.map(plugin => plugin.id)
const active = await readActiveSelection(activeRoot, catalog.plugins)
if (active === undefined) throw new Error('active System Plugin view is unavailable')
await selectionStore.write(pluginIds, active)
return listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot })
}
export async function materializeSystemPluginSelection({
environmentRoot,
sourceRoot,
outputRoot,
selectionStore,
}) {
const catalog = await pluginCatalog(environmentRoot)
const pluginIds = catalog.plugins.map(plugin => plugin.id)
const selection = await selectionStore.read(pluginIds)
const root = resolve(outputRoot)
const staging = join(root, `.selection.${randomUUID()}.tmp`)
const destination = join(root, `selection-${randomUUID()}`)
const patches = []
await mkdir(join(staging, 'packages'), { recursive: true })
try {
for (const { id } of catalog.plugins) {
if (!selection[id].installed) continue
const packageRoot = join(resolve(sourceRoot), 'packages', id)
const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
const packageName = validatePackage(metadata, id)
const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8'))
const validatedPatch = validatePatch(patch, id, packageName)
await symlink(packageRoot, join(staging, 'packages', id), 'dir')
if (selection[id].enabled) patches.push(...validatedPatch)
}
await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' })
await rename(staging, destination)
return Object.freeze({ path: destination, plugins: await listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore }) })
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
export async function pruneSystemPluginSelectionViews({ outputRoot, keepPath }) {
const root = resolve(outputRoot)
const keep = resolve(keepPath)
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.name.startsWith('selection-')) continue
const candidate = join(root, entry.name)
if (candidate !== keep) await rm(candidate, { recursive: true, force: true })
}
}
export async function listBundledSystemPlugins({ environmentRoot, viewRoot }) {
const manifest = parseEnvironmentManifest(await readFile(join(resolve(environmentRoot), 'environment.manifest.json')))
const overlay = await readOverlay(resolve(viewRoot)).catch(() => [])
const moduleNames = new Set(overlay.flatMap(entry => (
Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : []
)))
const result = []
for (const reference of manifest.systemPlugins) {
const descriptor = artifactForReference(manifest, reference)
let installed = false
let reason = null
try {
const metadata = JSON.parse(await readFile(join(resolve(viewRoot), 'packages', reference.id, 'package.json'), 'utf8'))
const packageName = validatePackage(metadata, reference.id)
installed = moduleNames.has(packageName)
if (!installed) reason = 'plugin loader entry is absent'
} catch (error) {
reason = error instanceof Error ? error.message : 'plugin package is unavailable'
}
result.push(Object.freeze({
id: reference.id,
artifactId: descriptor.id,
sha256: descriptor.sha256,
installed,
reason,
}))
}
return Object.freeze(result)
}
export async function rebuildBundledSystemPluginView({
environmentRoot,
outputRoot,
expectedSha256,
requestedPluginId,
}) {
const source = resolve(environmentRoot)
const manifest = parseEnvironmentManifest(await readFile(join(source, 'environment.manifest.json')))
if (!manifest.systemPlugins.some(plugin => plugin.id === requestedPluginId)) {
throw new Error(`System Plugin ${requestedPluginId} is not bundled by the current Environment`)
}
const root = resolve(outputRoot)
const destination = join(root, expectedSha256)
try {
const details = await lstat(destination)
if (!details.isDirectory() || details.isSymbolicLink() || await hashTree(destination) !== expectedSha256) {
await rm(destination, { recursive: true, force: true })
} else {
return destination
}
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
const buildRoot = join(root, `.build.${randomUUID()}.tmp`)
await mkdir(root, { recursive: true })
try {
const built = await reconcileSystemPlugins({
root: buildRoot,
environmentVersion: expectedSha256,
plugins: manifest.systemPlugins,
artifactPath: reference => join(source, 'artifacts', artifactForReference(manifest, reference).id),
})
if (await hashTree(built) !== expectedSha256) {
throw new Error('rebuilt System Plugin view differs from the current Deployment Record')
}
await rename(built, destination)
return destination
} finally {
await rm(buildRoot, { recursive: true, force: true })
}
}
export async function linkSystemPluginScope({
dshHome,
viewRoot = '/run/dsh-platform/views/system-plugins',
}) {
const modulesRoot = join(resolve(dshHome), 'profiles', 'node_modules')
const scopeLink = join(modulesRoot, '@dsh-docker')
const target = join(resolve(viewRoot), 'packages')
await mkdir(modulesRoot, { recursive: true })
const existing = await lstat(scopeLink).catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error))
if (existing !== undefined) {
if (!existing.isSymbolicLink()) {
throw new Error(`System Plugin scope ${scopeLink} exists and is not a symlink`)
}
if (await readlink(scopeLink) === target) return scopeLink
await rm(scopeLink)
}
const temporary = join(modulesRoot, `.@dsh-docker.${randomUUID()}.tmp`)
try {
await symlink(target, temporary, 'dir')
await rename(temporary, scopeLink)
} finally {
await rm(temporary, { force: true })
}
return scopeLink
}
@@ -0,0 +1,203 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]{0,126}\/[a-z0-9][a-z0-9._-]{0,126}|[a-z0-9][a-z0-9._-]{0,213})$/i
const SELECTION_SCHEMA = 1
function packagePath(root, packageName) {
if (!PACKAGE_NAME_PATTERN.test(packageName)) return undefined
return join(root, 'node_modules', ...packageName.split('/'), 'package.json')
}
async function optionalBytes(path) {
return readFile(path).then(
bytes => ({ present: true, bytes }),
error => error?.code === 'ENOENT' ? { present: false, bytes: Buffer.alloc(0) } : Promise.reject(error),
)
}
function parseObject(bytes, label) {
let value
try {
value = JSON.parse(bytes.toString('utf8'))
} catch (error) {
throw new Error(`${label} is not valid JSON`, { cause: error })
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must contain a JSON object`)
}
return value
}
function profileFields(manifest) {
const dependencies = manifest.dependencies ?? {}
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
throw new Error('Web Profile dependencies must be an object')
}
for (const [name, spec] of Object.entries(dependencies)) {
if (typeof spec !== 'string' || spec.length === 0) {
throw new Error(`Web Profile dependency ${name} must have a non-empty string spec`)
}
}
const bundles = manifest.dsh?.profile?.bundles ?? []
if (!Array.isArray(bundles) || bundles.some(name => typeof name !== 'string')) {
throw new Error('Web Profile dsh.profile.bundles must be a string array')
}
return { dependencies, bundles }
}
function disabledPositions(value) {
if (value === undefined) return new Map()
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| value.schema !== SELECTION_SCHEMA || !Array.isArray(value.disabled)) {
throw new Error('User Plugin selection state is invalid')
}
const positions = new Map()
for (const entry of value.disabled) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|| !PACKAGE_NAME_PATTERN.test(entry.name) || !Number.isSafeInteger(entry.index) || entry.index < 0) {
throw new Error('User Plugin disabled-order entry is invalid')
}
if (positions.has(entry.name)) throw new Error(`User Plugin ${entry.name} has duplicate disabled-order entries`)
positions.set(entry.name, entry.index)
}
return positions
}
function sourceForSpec(spec) {
if (/^(?:file|link):/i.test(spec) || /^(?:\.{1,2}\/|\/)/.test(spec)) return 'file'
if (/^(?:git(?:\+[^:]+)?|github|gitlab|bitbucket):/i.test(spec) || /\.git(?:#|$)/i.test(spec)) return 'git'
if (/^https?:/i.test(spec)) return 'url'
if (/^npm:/i.test(spec) || /^(?:[~^<>=*]|\d|v\d)/i.test(spec) || spec === 'latest' || spec === 'next') return 'registry'
return 'other'
}
function updateRevision(hash, label, file) {
hash.update(`${label}:${file.present ? '1' : '0'}:${String(file.bytes.length)}:`)
hash.update(file.bytes)
hash.update('\0')
}
function metadataFailure(error) {
if (error?.code === 'ENOENT') return 'installed package metadata is missing'
if (error instanceof SyntaxError) return 'installed package metadata is not valid JSON'
return error instanceof Error ? error.message : String(error)
}
async function inspectDependency(profileRoot, name, spec, enabledBundles, disabled, reservedNames) {
const path = packagePath(profileRoot, name)
let metadata
let metadataError = null
if (path === undefined) {
metadataError = 'dependency name is invalid'
} else {
try {
metadata = JSON.parse(await readFile(path, 'utf8'))
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
throw new Error('installed package metadata must be an object')
}
if (metadata.name !== name) throw new Error(`installed package name is ${String(metadata.name)}`)
} catch (error) {
metadataError = metadataFailure(error)
}
}
if (metadataError === null && metadata.version !== undefined
&& (typeof metadata.version !== 'string' || metadata.version.length === 0)) {
metadataError = 'installed package version is invalid'
}
const bundleDeclaration = metadata?.dsh?.bundle
const patch = bundleDeclaration?.patch
const isBundle = typeof patch === 'string' && patch.length > 0
const reservedNameConflict = reservedNames.has(name)
if (metadataError === null && !isBundle
&& (bundleDeclaration !== undefined || enabledBundles.has(name))) {
metadataError = 'installed package Bundle metadata is invalid'
}
if (!isBundle && metadataError === null && !reservedNameConflict) return undefined
return Object.freeze({
name,
spec,
source: sourceForSpec(spec),
version: typeof metadata?.version === 'string' ? metadata.version : null,
description: typeof metadata?.description === 'string' && metadata.description.trim() !== '' ? metadata.description.trim() : null,
enabled: !reservedNameConflict && enabledBundles.has(name),
previousIndex: disabled.get(name) ?? null,
damaged: metadataError !== null,
metadataError,
reservedNameConflict,
})
}
function loadedIdentity(plugin) {
return JSON.stringify([
plugin.name, plugin.spec, plugin.version, plugin.enabled, plugin.damaged,
plugin.metadataError, plugin.reservedNameConflict,
])
}
export function markUserPluginRestartState(current, loaded) {
if (loaded === undefined) return current
const loadedPlugins = new Map(loaded.plugins.map(plugin => [plugin.name, loadedIdentity(plugin)]))
const plugins = current.plugins.map(plugin => Object.freeze({
...plugin,
pendingRestart: loadedPlugins.get(plugin.name) !== loadedIdentity(plugin),
}))
const currentNames = new Set(current.plugins.map(plugin => plugin.name))
const removedLoadedPlugin = loaded.plugins.some(plugin => !currentNames.has(plugin.name))
return Object.freeze({
...current,
plugins: Object.freeze(plugins),
restartRequired: removedLoadedPlugin || plugins.some(plugin => plugin.pendingRestart),
})
}
export class UserPluginInventory {
constructor({ dshHome = '/data/dsh', profile = 'web', selectionPath, systemPluginNames = async () => [] } = {}) {
if (profile !== 'web') throw new Error('Only the web DSH Profile is supported')
this.profile = profile
this.profileRoot = join(resolve(dshHome), 'profiles', profile)
this.selectionPath = selectionPath === undefined
? join(resolve(dshHome), '..', 'platform', 'state', 'management', 'user-plugins.json')
: resolve(selectionPath)
this.systemPluginNames = systemPluginNames
}
async read() {
const manifestPath = join(this.profileRoot, 'package.json')
const lockfilePath = join(this.profileRoot, 'pnpm-lock.yaml')
const [manifestBytes, lockfile, selectionFile, names] = await Promise.all([
readFile(manifestPath),
optionalBytes(lockfilePath),
optionalBytes(this.selectionPath),
this.systemPluginNames(),
])
const manifest = parseObject(manifestBytes, 'Web Profile package.json')
const { dependencies, bundles } = profileFields(manifest)
const selection = !selectionFile.present
? undefined
: parseObject(selectionFile.bytes, 'User Plugin selection state')
const disabled = disabledPositions(selection)
const reservedNames = new Set(names)
if ([...reservedNames].some(name => typeof name !== 'string' || !PACKAGE_NAME_PATTERN.test(name))) {
throw new Error('Verified System Plugin names are invalid')
}
const enabledBundles = new Set(bundles)
const inspected = await Promise.all(Object.entries(dependencies).map(([name, spec]) => (
inspectDependency(this.profileRoot, name, spec, enabledBundles, disabled, reservedNames)
)))
const revisionHash = createHash('sha256')
updateRevision(revisionHash, 'manifest', { present: true, bytes: manifestBytes })
updateRevision(revisionHash, 'lockfile', lockfile)
updateRevision(revisionHash, 'selection', selectionFile)
const revision = revisionHash.digest('hex')
return Object.freeze({
schema: 1,
profile: this.profile,
revision: `sha256:${revision}`,
plugins: Object.freeze(inspected.filter(Boolean)),
})
}
}
export const userPluginInternals = Object.freeze({ PACKAGE_NAME_PATTERN, sourceForSpec, loadedIdentity })
@@ -0,0 +1,116 @@
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { userPluginInternals } from './user-inventory.mjs'
const PHASES = new Set([
'validated', 'paused', 'snapshotted', 'mutating', 'committed', 'restarting',
'restoring', 'completed', 'failed',
])
const TERMINAL = new Set(['completed', 'failed'])
const TRANSITIONS = new Map([
['validated', new Set(['paused', 'snapshotted', 'failed'])],
['paused', new Set(['snapshotted', 'restoring', 'failed'])],
['snapshotted', new Set(['mutating', 'restoring'])],
['mutating', new Set(['committed', 'restoring'])],
['committed', new Set(['restarting', 'completed', 'failed'])],
['restarting', new Set(['completed', 'failed'])],
['restoring', new Set(['failed'])],
['completed', new Set()],
['failed', new Set()],
])
function parseAction(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'action,name'
|| typeof value.name !== 'string' || !userPluginInternals.PACKAGE_NAME_PATTERN.test(value.name)
|| !['enable', 'disable', 'uninstall'].includes(value.action)) {
throw new Error('User Plugin journal action is invalid')
}
return Object.freeze({ name: value.name, action: value.action })
}
function parse(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'actions,error,phase,previousDisabled,recoveryResult,revision,schema,selectionPresent,snapshotId,taskId,updatedAt'
|| value.schema !== 1 || typeof value.taskId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(value.taskId)
|| !PHASES.has(value.phase) || typeof value.revision !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value.revision)
|| !Array.isArray(value.actions) || value.actions.length === 0
|| typeof value.selectionPresent !== 'boolean' || !Array.isArray(value.previousDisabled)
|| (value.snapshotId !== null && typeof value.snapshotId !== 'string')
|| (value.error !== null && typeof value.error !== 'string')
|| ![null, 'success', 'failed'].includes(value.recoveryResult)
|| typeof value.updatedAt !== 'string' || new Date(value.updatedAt).toISOString() !== value.updatedAt) {
throw new Error('User Plugin transaction journal is invalid')
}
const names = new Set()
const previousDisabled = value.previousDisabled.map(entry => {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|| Object.keys(entry).sort().join(',') !== 'index,name'
|| typeof entry.name !== 'string' || !userPluginInternals.PACKAGE_NAME_PATTERN.test(entry.name)
|| !Number.isSafeInteger(entry.index) || entry.index < 0
|| names.has(entry.name)) throw new Error('User Plugin journal disabled-order state is invalid')
names.add(entry.name)
return Object.freeze({ name: entry.name, index: entry.index })
})
return Object.freeze({
...value,
actions: Object.freeze(value.actions.map(parseAction)),
previousDisabled: Object.freeze(previousDisabled),
})
}
export class UserPluginJournal {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async read() {
try { return parse(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
if (error?.code === 'ENOENT') return undefined
throw error
}
}
begin({ taskId, revision, actions, selection }) {
return this.exclusive(async () => {
const current = await this.read()
if (current !== undefined && !TERMINAL.has(current.phase)) {
throw new Error('a User Plugin transaction is already active')
}
const next = parse({
schema: 1, taskId, revision, actions,
selectionPresent: selection.present,
previousDisabled: selection.state.disabled,
phase: 'validated', snapshotId: null,
error: null, recoveryResult: null, updatedAt: this.now().toISOString(),
})
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
}
transition(phase, fields = {}) {
return this.exclusive(async () => {
const current = await this.read()
if (current === undefined || !TRANSITIONS.get(current.phase)?.has(phase)) {
throw new Error(`User Plugin journal cannot transition to ${phase}`)
}
const next = parse({ ...current, ...fields, phase, updatedAt: this.now().toISOString() })
if (next.taskId !== current.taskId || next.revision !== current.revision) {
throw new Error('User Plugin journal identity cannot change')
}
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
}
}
export const userPluginJournalInternals = Object.freeze({ parse, TERMINAL })
@@ -0,0 +1,143 @@
import { spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
function validId(id) {
if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw new Error('User Plugin snapshot ID is invalid')
return id
}
function run(command, args) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolveRun(Buffer.concat(stdout).toString('utf8'))
: reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`)))
})
}
async function measure(path) {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
try {
for await (const chunk of handle.createReadStream({ autoClose: false })) {
hash.update(chunk)
size += chunk.byteLength
}
} finally {
await handle.close()
}
return { sha256: hash.digest('hex'), size }
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
function parseManifest(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'archiveSha256,archiveSize,createdAt,id,schema,source'
|| value.schema !== 1 || !ID_PATTERN.test(value.id)
|| value.source !== '/data/dsh/profiles/web'
|| typeof value.createdAt !== 'string' || new Date(value.createdAt).toISOString() !== value.createdAt
|| typeof value.archiveSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.archiveSha256)
|| !Number.isSafeInteger(value.archiveSize) || value.archiveSize < 0) {
throw new Error('User Plugin snapshot manifest is invalid')
}
return Object.freeze({ ...value })
}
export class UserPluginSnapshots {
constructor({ root, profileRoot = '/data/dsh/profiles/web', now = () => new Date(), runImpl = run }) {
this.root = resolve(root)
this.profileRoot = resolve(profileRoot)
this.now = now
this.run = runImpl
}
path(id) { return join(this.root, validId(id)) }
async create(id = randomUUID()) {
validId(id)
const details = await lstat(this.profileRoot)
if (!details.isDirectory() || details.isSymbolicLink()) throw new Error('Web Profile must be a real directory')
await mkdir(this.root, { recursive: true })
const destination = this.path(id)
await lstat(destination).then(
() => { throw new Error(`User Plugin snapshot ${id} already exists`) },
error => { if (error?.code !== 'ENOENT') throw error },
)
const staging = join(this.root, `.${id}.${randomUUID()}.tmp`)
await mkdir(staging)
const archive = join(staging, 'profile.tar')
try {
await this.run('tar', ['-cpf', archive, '-C', this.profileRoot, '.'])
const measured = await measure(archive)
const manifest = parseManifest({
schema: 1,
id,
source: '/data/dsh/profiles/web',
createdAt: this.now().toISOString(),
archiveSha256: measured.sha256,
archiveSize: measured.size,
})
await durableReplace(join(staging, 'manifest.json'), canonicalJson(manifest))
await rename(staging, destination)
await syncDirectory(this.root)
return Object.freeze({ ...manifest, path: destination })
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async inspect(id) {
const root = this.path(id)
const manifest = parseManifest(JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')))
if (manifest.id !== id) throw new Error('User Plugin snapshot ID does not match its directory')
const archive = await lstat(join(root, 'profile.tar')).then(
() => join(root, 'profile.tar'),
error => error?.code === 'ENOENT' ? join(root, 'profile.tar.gz') : Promise.reject(error),
)
const measured = await measure(archive)
if (measured.sha256 !== manifest.archiveSha256 || measured.size !== manifest.archiveSize) {
throw new Error('User Plugin snapshot archive does not match its manifest')
}
const entries = (await this.run('tar', [archive.endsWith('.gz') ? '-tzf' : '-tf', archive])).split('\n').filter(Boolean)
if (entries.length === 0 || entries.some(entry => entry.startsWith('/') || entry.split('/').includes('..'))) {
throw new Error('User Plugin snapshot archive contains unsafe paths')
}
return Object.freeze({ ...manifest, archive })
}
async restore(id) {
const snapshot = await this.inspect(id)
await mkdir(this.profileRoot, { recursive: true })
for (const entry of await readdir(this.profileRoot)) {
await rm(join(this.profileRoot, entry), { recursive: true, force: true })
}
const ownership = process.getuid?.() === 0 ? [] : ['--no-same-owner']
await this.run('tar', [snapshot.archive.endsWith('.gz') ? '-xzpf' : '-xpf', snapshot.archive, ...ownership, '-C', this.profileRoot])
await syncDirectory(this.profileRoot)
return snapshot
}
async remove(id) {
validId(id)
await rm(this.path(id), { recursive: true, force: true })
}
}
export const userPluginSnapshotInternals = Object.freeze({ parseManifest })
@@ -0,0 +1,67 @@
import { open, readFile, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { userPluginInternals } from './user-inventory.mjs'
function parse(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'disabled,schema'
|| value.schema !== 1 || !Array.isArray(value.disabled)) {
throw new Error('User Plugin selection state is invalid')
}
const names = new Set()
const disabled = value.disabled.map(entry => {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|| Object.keys(entry).some(key => !['index', 'name'].includes(key))
|| !userPluginInternals.PACKAGE_NAME_PATTERN.test(entry.name)
|| !Number.isSafeInteger(entry.index) || entry.index < 0 || names.has(entry.name)) {
throw new Error('User Plugin disabled-order entry is invalid')
}
names.add(entry.name)
return Object.freeze({ name: entry.name, index: entry.index })
})
return Object.freeze({ schema: 1, disabled: Object.freeze(disabled) })
}
export class UserPluginSelectionStore {
constructor(path) {
this.path = path
}
async read() {
return (await this.snapshot()).state
}
async snapshot() {
try {
return Object.freeze({ present: true, state: parse(JSON.parse(await readFile(this.path, 'utf8'))) })
} catch (error) {
if (error?.code === 'ENOENT') {
return Object.freeze({ present: false, state: parse({ schema: 1, disabled: [] }) })
}
throw error
}
}
async write(value) {
const state = parse(value)
await durableReplace(this.path, `${canonicalJson(state).toString('utf8')}\n`)
return state
}
async restore(snapshot) {
if (snapshot === null || typeof snapshot !== 'object' || typeof snapshot.present !== 'boolean') {
throw new Error('User Plugin selection snapshot is invalid')
}
if (snapshot.present) return this.write(snapshot.state)
await rm(this.path, { force: true })
const directory = await open(dirname(this.path), 'r').catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error))
if (directory !== undefined) {
try { await directory.sync() } finally { await directory.close() }
}
return parse({ schema: 1, disabled: [] })
}
}
export const userPluginStateInternals = Object.freeze({ parse })
@@ -0,0 +1,414 @@
import { spawn } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { userPluginInternals } from './user-inventory.mjs'
const PACKAGE_PATH_ENVIRONMENT = new Set([
'npm_config_cache',
'npm_config_prefix',
'npm_config_store_dir',
'npm_config_userconfig',
'pnpm_home',
])
function pluginEnvironment(dshHome, home = '/home/node') {
const environment = {
...process.env,
HOME: home,
XDG_CACHE_HOME: join(home, '.cache'),
XDG_CONFIG_HOME: join(home, '.config'),
XDG_DATA_HOME: join(home, '.local', 'share'),
XDG_STATE_HOME: join(home, '.local', 'state'),
DSH_HOME: dshHome,
}
for (const name of Object.keys(environment)) {
if (PACKAGE_PATH_ENVIRONMENT.has(name.toLowerCase())) delete environment[name]
}
return environment
}
function runDshPlugin({ dshHome, profile, name }, timeoutMs = 300_000) {
return new Promise((resolveRun, reject) => {
const child = spawn('/usr/local/bin/dsh', ['plugin', '--profile', profile, 'remove', name], {
env: pluginEnvironment(dshHome),
stdio: ['ignore', 'pipe', 'pipe'],
})
const stdout = []
const stderr = []
let size = 0
let settled = false
let timeoutError
let killTimer
const finish = (error) => {
if (settled) return
settled = true
clearTimeout(timer)
clearTimeout(killTimer)
if (error === undefined) resolveRun()
else reject(error)
}
const timer = setTimeout(() => {
timeoutError = new Error(`dsh plugin remove timed out for ${name}`)
child.kill('SIGTERM')
killTimer = setTimeout(() => child.kill('SIGKILL'), 5_000)
killTimer.unref()
}, timeoutMs)
timer.unref()
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => {
size += chunk.byteLength
if (size <= 256 * 1024) (stream === child.stdout ? stdout : stderr).push(chunk)
})
child.once('error', finish)
child.once('exit', code => timeoutError !== undefined
? finish(timeoutError)
: code === 0
? finish()
: finish(new Error(`dsh plugin remove failed for ${name}: ${[
['stdout', Buffer.concat(stdout).toString('utf8').trim()],
['stderr', Buffer.concat(stderr).toString('utf8').trim()],
].filter(([, value]) => value !== '').map(([stream, value]) => `${stream}: ${value}`).join('\\n')}`)))
})
}
function message(error) {
return error instanceof Error ? error.message : String(error)
}
function validateActions(inventory, actions) {
if (!Array.isArray(actions) || actions.length === 0 || actions.length > 128) {
throw new Error('User Plugin actions must be a non-empty bounded array')
}
const plugins = new Map(inventory.plugins.map(plugin => [plugin.name, plugin]))
const names = new Set()
return Object.freeze(actions.map(value => {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'action,name'
|| !userPluginInternals.PACKAGE_NAME_PATTERN.test(value.name)
|| !['enable', 'disable', 'uninstall'].includes(value.action)
|| names.has(value.name)) {
throw new Error('User Plugin action is invalid')
}
names.add(value.name)
const plugin = plugins.get(value.name)
if (plugin === undefined) throw new Error(`User Plugin ${value.name} is not managed by the Web Profile`)
if (value.action === 'enable' && (plugin.damaged || plugin.reservedNameConflict)) {
throw new Error(`User Plugin ${value.name} cannot be enabled`)
}
return Object.freeze({ name: value.name, action: value.action })
}))
}
async function mutateProfile({ inventory, selectionStore, actions, runPlugin }) {
const manifestPath = join(inventory.profileRoot, 'package.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
const bundles = [...(manifest.dsh?.profile?.bundles ?? [])]
const originalBundles = [...bundles]
const selection = await selectionStore.read()
const disabled = new Map(selection.disabled.map(entry => [entry.name, entry.index]))
for (const { name, action } of actions) {
if (action === 'disable') {
const index = bundles.indexOf(name)
if (index !== -1) {
disabled.set(name, originalBundles.indexOf(name))
for (let found = bundles.indexOf(name); found !== -1; found = bundles.indexOf(name)) bundles.splice(found, 1)
}
} else if (action === 'enable') {
if (!bundles.includes(name)) bundles.splice(Math.min(disabled.get(name) ?? bundles.length, bundles.length), 0, name)
disabled.delete(name)
} else if (action === 'uninstall') {
for (let found = bundles.indexOf(name); found !== -1; found = bundles.indexOf(name)) bundles.splice(found, 1)
}
}
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
await durableReplace(manifestPath, `${canonicalJson(manifest).toString('utf8')}\n`)
for (const { name, action } of actions) {
if (action === 'uninstall') {
await runPlugin({ dshHome: join(inventory.profileRoot, '..', '..'), profile: inventory.profile, name })
disabled.delete(name)
}
}
if (actions.some(({ action }) => action === 'uninstall')) {
const installedManifest = JSON.parse(await readFile(manifestPath, 'utf8'))
installedManifest.dsh = {
...installedManifest.dsh,
profile: { ...installedManifest.dsh?.profile, bundles },
}
await durableReplace(manifestPath, `${canonicalJson(installedManifest).toString('utf8')}\n`)
}
await selectionStore.write({
schema: 1,
disabled: [...disabled].map(([name, index]) => ({ name, index })),
})
const resulting = await inventory.read()
const result = new Map(resulting.plugins.map(plugin => [plugin.name, plugin]))
for (const { name, action } of actions) {
if (action === 'uninstall' && result.has(name)) throw new Error(`User Plugin ${name} remains installed`)
if (action === 'enable' && result.get(name)?.enabled !== true) throw new Error(`User Plugin ${name} was not enabled`)
if (action === 'disable' && result.get(name)?.enabled !== false) throw new Error(`User Plugin ${name} was not disabled`)
}
return resulting
}
export class UserPluginTransactionManager {
constructor({ inventory, selectionStore, snapshots, journal, pauseDsh, restartDsh, runPlugin = runDshPlugin, report = async () => {} }) {
this.inventory = inventory
this.selectionStore = selectionStore
this.snapshots = snapshots
this.journal = journal
this.pauseDsh = pauseDsh
this.restartDsh = restartDsh
this.runPlugin = runPlugin
this.report = report
}
record(messageValue, fields = {}) {
return Promise.resolve().then(() => this.report(messageValue, fields)).catch(() => {})
}
async validate({ revision, actions }) {
const current = await this.inventory.read()
if (revision !== current.revision) {
const error = new Error('User Plugin inventory revision has changed')
error.code = 'REVISION_CONFLICT'
throw error
}
return Object.freeze({ revision, actions: validateActions(current, actions) })
}
async apply({ taskId = randomUUID(), revision, actions, strategy = 'restart', onProgress = () => {} }) {
if (!['restart', 'next-start'].includes(strategy)) throw new Error('User Plugin activation strategy is invalid')
const validated = await this.validate({ revision, actions })
const normalized = validated.actions
const publishProgress = state => { try { onProgress(state) } catch {} }
const selectionBefore = await this.selectionStore.snapshot()
let state = await this.journal.begin({ taskId, revision, actions: normalized, selection: selectionBefore })
publishProgress(state)
let paused = false
let snapshotCreated = false
await this.record('user-plugin.transaction.started', { taskId, actions: normalized })
try {
if (strategy === 'restart') {
await this.pauseDsh()
paused = true
state = await this.journal.transition('paused')
publishProgress(state)
}
await this.snapshots.create(taskId)
snapshotCreated = true
state = await this.journal.transition('snapshotted', { snapshotId: taskId })
publishProgress(state)
state = await this.journal.transition('mutating')
publishProgress(state)
const result = await mutateProfile({
inventory: this.inventory,
selectionStore: this.selectionStore,
actions: normalized,
runPlugin: this.runPlugin,
})
state = await this.journal.transition('committed')
publishProgress(state)
let completedStrategy = strategy
let activationError = null
if (strategy === 'restart') {
state = await this.journal.transition('restarting')
publishProgress(state)
try {
await this.restartDsh()
} catch (error) {
completedStrategy = 'next-start'
activationError = message(error)
await this.record('user-plugin.activation.deferred', { taskId, error })
}
}
state = await this.journal.transition('completed')
publishProgress(state)
await this.snapshots.remove(taskId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', { taskId, error }))
await this.record('user-plugin.transaction.completed', { taskId })
return Object.freeze({
taskId, status: 'success', strategy: completedStrategy, activationError, inventory: result,
})
} catch (error) {
const committed = ['committed', 'restarting'].includes(state.phase)
if (committed) {
await this.journal.transition('failed', { error: message(error) })
await this.snapshots.remove(taskId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', { taskId, error: cleanupError }))
} else if (snapshotCreated || state.snapshotId !== null) {
const snapshotId = state.snapshotId ?? taskId
try {
if (state.phase !== 'restoring') state = await this.journal.transition('restoring', {
error: message(error),
snapshotId,
})
await this.snapshots.restore(snapshotId)
await this.selectionStore.restore(selectionBefore)
if (strategy === 'restart') await this.restartDsh()
await this.journal.transition('failed', { recoveryResult: 'success' })
await this.snapshots.remove(snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', {
taskId,
error: cleanupError,
}))
} catch (recoveryError) {
const aggregate = new AggregateError([error, recoveryError], 'User Plugin transaction and recovery failed')
aggregate.journal = await this.journal.transition('failed', {
error: `${message(error)}; recovery failed: ${message(recoveryError)}`,
recoveryResult: 'failed',
}).catch(() => {})
throw aggregate
}
} else if (paused) {
try {
await this.restartDsh()
await this.journal.transition('failed', { error: message(error), recoveryResult: 'success' })
} catch (recoveryError) {
const aggregate = new AggregateError([error, recoveryError], 'User Plugin transaction and recovery failed')
aggregate.journal = await this.journal.transition('failed', {
error: `${message(error)}; recovery failed: ${message(recoveryError)}`,
recoveryResult: 'failed',
}).catch(() => {})
throw aggregate
}
} else {
await this.journal.transition('failed', { error: message(error) }).catch(() => {})
}
await this.record('user-plugin.transaction.failed', { taskId, error, committed })
if (error !== null && typeof error === 'object') error.journal = await this.journal.read().catch(() => undefined)
throw error
}
}
async recover() {
const state = await this.journal.read()
if (state === undefined) return undefined
if (['completed', 'failed'].includes(state.phase)) {
if (state.snapshotId !== null) await this.snapshots.remove(state.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error,
}))
return state
}
if (['committed', 'restarting'].includes(state.phase)) {
try {
if (state.phase === 'committed') await this.journal.transition('restarting')
await this.restartDsh()
const completed = await this.journal.transition('completed')
if (state.snapshotId !== null) await this.snapshots.remove(state.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error,
}))
return completed
} catch (error) {
const failed = await this.journal.transition('failed', { error: message(error) })
if (state.snapshotId !== null) {
await this.snapshots.remove(state.snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error: cleanupError,
}))
}
return failed
}
}
if (state.phase === 'validated') return this.journal.transition('failed', { error: 'transaction interrupted before DSH pause' })
await this.pauseDsh()
if (state.phase === 'paused') {
try {
await this.restartDsh()
return this.journal.transition('failed', { error: 'transaction interrupted before snapshot', recoveryResult: 'success' })
} catch (error) {
return this.journal.transition('failed', { error: message(error), recoveryResult: 'failed' })
}
}
try {
const restoring = state.phase === 'restoring' ? state : await this.journal.transition('restoring', {
error: state.error ?? 'transaction interrupted before commit',
})
await this.snapshots.restore(restoring.snapshotId)
await this.selectionStore.restore({
present: restoring.selectionPresent,
state: { schema: 1, disabled: restoring.previousDisabled },
})
await this.restartDsh()
const failed = await this.journal.transition('failed', { recoveryResult: 'success' })
await this.snapshots.remove(restoring.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error,
}))
return failed
} catch (error) {
return this.journal.transition('failed', { error: message(error), recoveryResult: 'failed' })
}
}
async recoverBeforeDshStart() {
return recoverUserPluginBeforeDshStart({
journal: this.journal,
report: (messageValue, fields) => this.record(messageValue, fields),
selectionStore: this.selectionStore,
snapshots: this.snapshots,
})
}
async completeDshStartup({ healthy, error = null }) {
const state = await this.journal.read()
if (state === undefined || state.phase !== 'restarting') return state
const next = healthy
? await this.journal.transition('completed')
: await this.journal.transition('failed', { error: error ?? 'DSH failed to start after the committed User Plugin change' })
if (state.snapshotId !== null) {
await this.snapshots.remove(state.snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error: cleanupError,
}))
}
return next
}
}
export async function recoverUserPluginBeforeDshStart({ journal, selectionStore, snapshots, report = async () => {} }) {
const record = (messageValue, fields) => Promise.resolve()
.then(() => report(messageValue, fields))
.catch(() => {})
let state = await journal.read()
if (state === undefined) return undefined
if (['completed', 'failed'].includes(state.phase)) {
if (state.snapshotId !== null) await snapshots.remove(state.snapshotId).catch(error => record(
'user-plugin.snapshot.cleanup.failed', { taskId: state.taskId, error },
))
return state
}
if (state.phase === 'validated') {
return journal.transition('failed', {
error: 'transaction interrupted before DSH pause', recoveryResult: 'success',
})
}
if (state.phase === 'paused') {
return journal.transition('failed', {
error: 'transaction interrupted before snapshot', recoveryResult: 'success',
})
}
if (['committed', 'restarting'].includes(state.phase)) {
if (state.phase === 'committed') state = await journal.transition('restarting')
return state
}
try {
if (state.phase !== 'restoring') state = await journal.transition('restoring', {
error: state.error ?? 'transaction interrupted before commit',
})
await snapshots.restore(state.snapshotId)
await selectionStore.restore({
present: state.selectionPresent,
state: { schema: 1, disabled: state.previousDisabled },
})
const failed = await journal.transition('failed', { recoveryResult: 'success' })
await snapshots.remove(state.snapshotId).catch(error => record(
'user-plugin.snapshot.cleanup.failed', { taskId: state.taskId, error },
))
return failed
} catch (error) {
return journal.transition('failed', { error: message(error), recoveryResult: 'failed' })
}
}
export const userPluginTransactionInternals = Object.freeze({ mutateProfile, pluginEnvironment, runDshPlugin, validateActions })
@@ -0,0 +1,297 @@
import { createHash, randomUUID } from 'node:crypto'
import { spawn } from 'node:child_process'
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs'
import { hashTree } from '../../../platform/lib/tree-hash.mjs'
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const STATE_SCHEMA = 1
export const SYSTEM_SKILL_CATALOG_ARTIFACT_ID = 'system-skill-catalog'
export const SYSTEM_SKILL_CATALOG_MEDIA_TYPE = 'application/vnd.dsh-platform.system-skill-catalog.v1+tar+gzip'
function runTar(args, { capture = false } = {}) {
return new Promise((resolveTar, reject) => {
const child = spawn('tar', args, { stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] })
const stdout = []
const stderr = []
child.stdout?.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolveTar(Buffer.concat(stdout).toString('utf8'))
: reject(new Error(`System Skill archive failed: ${Buffer.concat(stderr).toString('utf8')}`)))
})
}
async function exists(path) {
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
}
export async function retainLegacySystemSkillCatalog({ sourceRoot, outputRoot }) {
const source = resolve(sourceRoot)
const destination = join(resolve(outputRoot), 'legacy-bootstrap')
if (await exists(join(destination, 'catalog.json'))) {
await readSystemSkillCatalog(destination)
return destination
}
await readSystemSkillCatalog(source)
const staging = `${destination}.${randomUUID()}.tmp`
try {
await cp(source, staging, { recursive: true, verbatimSymlinks: true })
try {
await rename(staging, destination)
} catch (error) {
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
}
} finally {
await rm(staging, { recursive: true, force: true })
}
await readSystemSkillCatalog(destination)
return destination
}
export async function materializeSystemSkillCatalog({ environmentRoot, outputRoot }) {
const root = resolve(environmentRoot)
const manifest = parseEnvironmentManifest(await readFile(join(root, 'environment.manifest.json')))
const artifact = manifest.artifacts.find(entry => entry.id === SYSTEM_SKILL_CATALOG_ARTIFACT_ID)
if (artifact === undefined) return undefined
if (artifact.mediaType !== SYSTEM_SKILL_CATALOG_MEDIA_TYPE) {
throw new Error('System Skill catalog Artifact media type is invalid')
}
const archive = join(root, 'artifacts', artifact.id)
const bytes = await readFile(archive)
if (bytes.byteLength !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) {
throw new Error('System Skill catalog Artifact differs from its Environment manifest')
}
const destination = join(resolve(outputRoot), artifact.sha256)
if (!await exists(destination)) {
const staging = `${destination}.${randomUUID()}.tmp`
await mkdir(staging, { recursive: true })
try {
const entries = (await runTar(['-tzf', archive], { capture: true })).split('\n').filter(Boolean)
if (entries.length === 0 || entries.some(entry => (
entry.startsWith('/') || !entry.startsWith('skills/') || entry.split('/').includes('..')
))) throw new Error('System Skill catalog archive contains an unsafe path')
await runTar(['-xzf', archive, '--no-same-owner', '--no-same-permissions', '--strip-components=1', '-C', staging])
await readSystemSkillCatalog(staging)
try {
await rename(staging, destination)
} catch (error) {
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
}
} finally {
await rm(staging, { recursive: true, force: true })
}
}
await readSystemSkillCatalog(destination)
return destination
}
function exactKeys(value, keys, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== [...keys].sort().join(',')) {
throw new Error(`${label} is invalid`)
}
return value
}
function skillId(value) {
if (typeof value !== 'string' || !ID_PATTERN.test(value) || value.length > 64) {
throw new Error('System Skill ID is invalid')
}
return value
}
function description(value, label) {
exactKeys(value, ['en', 'zh'], label)
for (const language of ['en', 'zh']) {
if (typeof value[language] !== 'string' || value[language].length === 0 || value[language].length > 240) {
throw new Error(`${label}.${language} is invalid`)
}
}
return Object.freeze({ en: value.en, zh: value.zh })
}
export async function readSystemSkillCatalog(root) {
const sourceRoot = resolve(root)
const document = JSON.parse(await readFile(join(sourceRoot, 'catalog.json'), 'utf8'))
exactKeys(document, ['schema', 'skills'], 'System Skill catalog')
if (document.schema !== 1 || !Array.isArray(document.skills)) throw new Error('System Skill catalog is invalid')
const ids = new Set()
const skills = []
for (const entry of document.skills) {
exactKeys(entry, ['description', 'id', 'source'], 'System Skill catalog entry')
const id = skillId(entry.id)
if (ids.has(id)) throw new Error(`duplicate System Skill ID ${id}`)
ids.add(id)
if (typeof entry.source !== 'string' || entry.source !== id) throw new Error(`System Skill ${id} source is invalid`)
const source = resolve(sourceRoot, entry.source)
if (!source.startsWith(`${sourceRoot}/`)) throw new Error(`System Skill ${id} source escapes its catalog`)
const metadata = await lstat(join(source, 'SKILL.md'))
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`System Skill ${id} SKILL.md is invalid`)
const bytes = await readFile(join(source, 'SKILL.md'), 'utf8')
if (!bytes.startsWith('---\n') || !bytes.includes(`\nname: ${id}\n`)) {
throw new Error(`System Skill ${id} frontmatter does not match its catalog`)
}
skills.push(Object.freeze({
id,
source,
sha256: await hashTree(source),
description: description(entry.description, `System Skill ${id} description`),
}))
}
return Object.freeze(skills)
}
function defaults(catalog) {
return Object.fromEntries(catalog.map(skill => [skill.id, { installed: true, enabled: true }]))
}
function normalizeState(value, catalog) {
const fallback = defaults(catalog)
if (value === undefined) return fallback
exactKeys(value, ['schema', 'skills'], 'System Skill selection state')
if (value.schema !== STATE_SCHEMA || value.skills === null || typeof value.skills !== 'object' || Array.isArray(value.skills)) {
throw new Error('System Skill selection state is invalid')
}
for (const skill of catalog) {
const saved = value.skills[skill.id]
if (saved === undefined) continue
exactKeys(saved, ['enabled', 'installed'], `System Skill ${skill.id} selection`)
if (typeof saved.installed !== 'boolean' || typeof saved.enabled !== 'boolean' || (saved.enabled && !saved.installed)) {
throw new Error(`System Skill ${skill.id} selection is invalid`)
}
fallback[skill.id] = { installed: saved.installed, enabled: saved.enabled }
}
return fallback
}
export class SystemSkillSelectionStore {
constructor(path) {
this.path = resolve(path)
}
async read(catalog) {
let value
try {
value = JSON.parse(await readFile(this.path, 'utf8'))
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
return normalizeState(value, catalog)
}
async write(catalog, state) {
const normalized = normalizeState({ schema: STATE_SCHEMA, skills: state }, catalog)
const document = {
schema: STATE_SCHEMA,
skills: Object.fromEntries(catalog.map(skill => [skill.id, normalized[skill.id]])),
}
await mkdir(dirname(this.path), { recursive: true })
const temporary = `${this.path}.${randomUUID()}.tmp`
try {
await writeFile(temporary, `${canonicalJson(document)}\n`, { flag: 'wx', mode: 0o600 })
await rename(temporary, this.path)
} finally {
await rm(temporary, { force: true })
}
return normalized
}
}
async function replaceSkillLink(viewRoot, skill) {
const path = join(viewRoot, skill.id)
const existing = await lstat(path).catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error))
if (existing?.isSymbolicLink() && await readlink(path) === skill.source) return
const temporary = join(viewRoot, `.${skill.id}.${randomUUID()}.tmp`)
try {
await symlink(skill.source, temporary, 'dir')
await rename(temporary, path)
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
export async function synchronizeSystemSkillView({ catalog, selection, viewRoot }) {
const root = resolve(viewRoot)
await mkdir(root, { recursive: true })
const expected = new Set(catalog.filter(skill => selection[skill.id].enabled).map(skill => skill.id))
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!expected.has(entry.name)) await rm(join(root, entry.name), { recursive: true, force: true })
}
for (const skill of catalog) {
if (selection[skill.id].enabled) await replaceSkillLink(root, skill)
}
}
export class SystemSkillManager {
constructor({ sourceRoot, viewRoot, statePath }) {
this.sourceRoot = resolve(sourceRoot)
this.viewRoot = resolve(viewRoot)
this.store = new SystemSkillSelectionStore(statePath)
}
async initialize() {
const catalog = await readSystemSkillCatalog(this.sourceRoot)
const selection = await this.store.read(catalog)
await this.store.write(catalog, selection)
await synchronizeSystemSkillView({ catalog, selection, viewRoot: this.viewRoot })
return this.listFrom(catalog, selection)
}
async useSourceRoot(sourceRoot) {
const nextRoot = resolve(sourceRoot)
const catalog = await readSystemSkillCatalog(nextRoot)
const selection = await this.store.read(catalog)
await this.store.write(catalog, selection)
await synchronizeSystemSkillView({ catalog, selection, viewRoot: this.viewRoot })
this.sourceRoot = nextRoot
return this.listFrom(catalog, selection)
}
listFrom(catalog, selection) {
return Object.freeze(catalog.map(skill => Object.freeze({
id: skill.id,
sha256: skill.sha256,
description: skill.description,
installed: selection[skill.id].installed,
enabled: selection[skill.id].enabled,
})))
}
async list() {
const catalog = await readSystemSkillCatalog(this.sourceRoot)
return this.listFrom(catalog, await this.store.read(catalog))
}
async configure(idValue, action) {
const id = skillId(idValue)
if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) throw new Error('System Skill action is invalid')
const catalog = await readSystemSkillCatalog(this.sourceRoot)
if (!catalog.some(skill => skill.id === id)) throw new Error(`System Skill ${id} is not provided by the current Environment`)
const before = await this.store.read(catalog)
const next = structuredClone(before)
const current = next[id]
if (action === 'install') next[id] = { installed: true, enabled: true }
else if (action === 'uninstall') next[id] = { installed: false, enabled: false }
else if (action === 'enable') {
if (!current.installed) throw new Error(`System Skill ${id} must be installed before it can be enabled`)
next[id] = { ...current, enabled: true }
} else next[id] = { ...current, enabled: false }
try {
await this.store.write(catalog, next)
await synchronizeSystemSkillView({ catalog, selection: next, viewRoot: this.viewRoot })
return this.listFrom(catalog, next)
} catch (error) {
try {
await this.store.write(catalog, before)
await synchronizeSystemSkillView({ catalog, selection: before, viewRoot: this.viewRoot })
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'System Skill operation and rollback failed')
}
throw error
}
}
}
@@ -0,0 +1,229 @@
import { spawn } from 'node:child_process'
import { cp, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { RuntimeSlots } from '../../patch-manager/index.mjs'
import { PlatformPaths } from '../../../../platform/lib/paths.mjs'
import { LocalApiClient } from './client.mjs'
import { ManagedDeploymentBuilder } from './managed-store.mjs'
import { deriveRecordId, parseDeploymentRecord } from '../../../../platform/lib/deployment-contracts.mjs'
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolve(Buffer.concat(stdout))
: reject(new Error(Buffer.concat(stderr).toString('utf8'))))
})
}
async function exists(path) {
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
}
function matchesRecoveryJournal(record, deployment) {
if (record?.id === deployment.runtime) return true
if (record === null || record === undefined) return false
if (!Array.isArray(record.receiptTokens) || !Array.isArray(deployment.receiptTokens)) return false
const sameReceipts = [...record.receiptTokens].sort().join('\0')
=== [...deployment.receiptTokens].sort().join('\0')
return record.dshVersion === deployment.dsh
&& record.environmentVersion === deployment.environment
&& record.snapshotId === deployment.dataSnapshot
&& sameReceipts
}
export class PlatformActivator {
constructor({
dataRoot,
runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
bootstrap,
stage0,
builder,
sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
this.dataRoot = dataRoot
this.paths = new PlatformPaths(dataRoot, runRoot)
this.bootstrap = bootstrap ?? new LocalApiClient(this.paths.bootstrapSocket)
this.stage0 = stage0 ?? new LocalApiClient(this.paths.trustSocket)
this.builder = builder ?? new ManagedDeploymentBuilder({ paths: this.paths })
this.sleep = sleep
this.runtimeSlots = new RuntimeSlots(this.paths.runtimesRoot)
this.environmentSlots = new RuntimeSlots(this.paths.environmentsRoot)
this.systemPluginSlots = new RuntimeSlots(this.paths.systemPluginsRoot)
this.experimentalCandidates = new Map()
}
prepareManaged(prepared, options) {
return this.builder.buildStable(prepared, options)
}
async pristine(version, receipt) {
const root = join(this.paths.pristineRoot, version)
if (!await exists(root)) {
const staging = `${root}.${randomUUID()}.tmp`
await mkdir(staging, { recursive: true })
try {
const entries = (await run('tar', ['-tzf', receipt.path])).toString('utf8').split('\n').filter(Boolean)
if (!entries.includes('package/package.json')) throw new Error('official DSH has no package metadata')
if (entries.some(entry => entry.startsWith('/') || !entry.startsWith('package/') || entry.split('/').includes('..'))) {
throw new Error('official DSH archive contains an unsafe path')
}
await run('tar', ['-xzf', receipt.path, '-C', staging])
await rename(staging, root)
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
const packageMetadata = JSON.parse(await readFile(join(root, 'package', 'package.json'), 'utf8'))
if (packageMetadata.name !== '@deepseek-ai/dsh' || packageMetadata.version !== version) {
throw new Error('official DSH package metadata differs from its requested version')
}
return join(root, 'package')
}
async stageEnvironment(prepared) {
const version = prepared.environment.manifest.version
const versions = join(this.paths.environmentsRoot, 'versions')
const destination = join(versions, version)
if (await exists(destination)) return version
const staging = join(versions, `.${version}.${randomUUID()}.tmp`)
await mkdir(join(staging, 'artifacts'), { recursive: true })
try {
await cp(prepared.paths.get(prepared.stable.desired.environment.manifestArtifactId), join(staging, 'environment.manifest.json'))
for (const descriptor of prepared.environment.manifest.artifacts) {
await cp(prepared.paths.get(descriptor.id), join(staging, 'artifacts', descriptor.id))
}
await rename(staging, destination)
return version
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async activate(prepared, { onProgress = async () => {}, onSwitching = async () => {} } = {}) {
const desiredBootstrap = prepared.stable.desired.bootstrap.version
const artifacts = prepared.bootstrap.manifest.artifacts
if (artifacts.length !== 1) throw new Error('Bootstrap manifest must contain exactly one package')
const receipt = prepared.receipts.get(artifacts[0].id)
if (receipt === undefined) throw new Error('Bootstrap package receipt is required')
const staged = await this.stage0.stageBootstrap(receipt.token, desiredBootstrap)
// Older Stage-0 responses have only status=switching. Preserve that handoff
// contract while allowing content-identical targets to continue in place.
if (staged.restartRequired === true || staged.status === 'switching') await new Promise(() => {})
const managed = await this.prepareManaged(prepared, { onProgress })
await onSwitching()
await this.bootstrap.request('POST', '/v1/deployments/activate', { record: managed.record })
return managed.record
}
async rollback(recordId) {
return this.bootstrap.request('POST', '/v1/deployments/rollback', recordId === undefined ? {} : { recordId })
}
rollbackDeployments() {
return this.bootstrap.request('GET', '/v1/deployments/rollback-plan')
}
async currentDeployment({ required = true } = {}) {
let record
for (let attempt = 0; attempt < 100; attempt += 1) {
({ record } = await this.bootstrap.request('GET', '/v1/deployments/current'))
if (record !== null || !required) break
const status = await this.bootstrap.request('GET', '/v1/status')
if (status.startupComplete === true) break
await this.sleep(100)
}
if (record === null) {
if (required) throw new Error('current Deployment is required')
return null
}
return Object.freeze({
authority: record.authority,
targetSequence: record.targetSequence,
dsh: record.dshVersion,
runtime: record.id,
environment: record.environmentVersion,
dataSnapshot: record.snapshotId,
receiptTokens: Object.freeze([...record.receiptTokens]),
})
}
async prepareExperimental(prepared, { targetSequence }) {
const { record: current } = await this.bootstrap.request('GET', '/v1/deployments/current')
if (current === null) throw new Error('current Deployment is required')
const receiptTokens = await this.experimentalActivationTokens(prepared.receiptTokens)
const built = await this.builder.buildExperimental(prepared, current, receiptTokens, { targetSequence })
this.experimentalCandidates.set(built.record.id, built.record)
return Object.freeze({
runtimeId: built.record.id,
environmentVersion: built.record.environmentVersion,
dshVersion: built.record.dshVersion,
})
}
suspendDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/suspend') }
resumeDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/resume') }
health() { return this.bootstrap.request('GET', '/v1/health') }
async experimentalActivationTokens(experimentalTokens) {
const active = (await this.stage0.activeReceipts()).receipts
return Object.freeze([
...active.filter(receipt => receipt.authorityType === 'stable').map(receipt => receipt.token),
...experimentalTokens,
])
}
async switchExperimental(runtimeId) {
const record = this.experimentalCandidates.get(runtimeId)
if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared')
return this.bootstrap.request('POST', '/v1/deployments/candidate', { record })
}
async bindExperimentalSnapshot(runtimeId, snapshotId) {
const record = this.experimentalCandidates.get(runtimeId)
if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared')
const content = { ...record, snapshotId }
delete content.id
const bound = parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) })
this.experimentalCandidates.delete(runtimeId)
this.experimentalCandidates.set(bound.id, bound)
return bound.id
}
async commitExperimental(runtimeId) {
const result = await this.bootstrap.request('POST', '/v1/deployments/candidate/commit', { recordId: runtimeId })
this.experimentalCandidates.delete(runtimeId)
return result
}
async restoreDeployment(deployment, { resume = true } = {}) {
await this.bootstrap.request('POST', '/v1/deployments/candidate/cancel')
const { record } = await this.bootstrap.request('GET', '/v1/deployments/current')
if (!matchesRecoveryJournal(record, deployment)) {
const { previous } = await this.rollbackDeployments()
if (previous === null) throw new Error('previous Deployment is unavailable')
if (!matchesRecoveryJournal(previous, deployment)) {
throw new Error('previous Deployment differs from the recovery journal')
}
await this.bootstrap.request('POST', '/v1/deployments/rollback', { recordId: previous.id })
}
if (resume) await this.resumeDsh()
}
async cleanup() {
const platform = await this.bootstrap.request('POST', '/v1/platform/gc')
return Object.freeze({
platform,
bootstrap: await this.stage0.collectBootstrap(),
trustedObjects: (await this.stage0.collectGarbage()).removed,
})
}
}
@@ -0,0 +1,170 @@
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
export const AUTOMATIC_CHECK_INTERVALS = Object.freeze([3_600, 10_800, 21_600, 43_200, 86_400])
const DEFAULT_STATE = Object.freeze({
schema: 1,
automaticCheck: Object.freeze({
enabled: true,
intervalSeconds: 21_600,
notificationsEnabled: true,
}),
latestAutomatic: Object.freeze({ stable: null, upstream: null }),
})
function exactKeys(value, expected, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
const keys = Object.keys(value).sort()
const wanted = [...expected].sort()
if (keys.length !== wanted.length || keys.some((key, index) => key !== wanted[index])) {
throw new Error(`${label} fields are invalid`)
}
}
function parseConfig(value) {
exactKeys(value, ['enabled', 'intervalSeconds', 'notificationsEnabled'], 'automatic check config')
if (typeof value.enabled !== 'boolean') throw new Error('automatic check enabled must be boolean')
if (!AUTOMATIC_CHECK_INTERVALS.includes(value.intervalSeconds)) throw new Error('automatic check interval is invalid')
if (typeof value.notificationsEnabled !== 'boolean') throw new Error('update notifications enabled must be boolean')
return Object.freeze({ ...value })
}
function parseStable(value) {
if (value === null) return null
exactKeys(value, ['checkedAt', 'dsh', 'environment', 'targetSequence'], 'Stable notification')
if (!Number.isSafeInteger(value.targetSequence) || value.targetSequence < 1) throw new Error('Stable notification sequence is invalid')
compareDshVersions(value.dsh, value.dsh)
if (typeof value.environment !== 'string' || value.environment.length === 0) throw new Error('Stable notification Environment is invalid')
if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Stable notification timestamp is invalid')
return Object.freeze({ ...value })
}
function parseUpstream(value) {
if (value === null) return null
exactKeys(value, ['checkedAt', 'version'], 'Upstream notification')
compareDshVersions(value.version, value.version)
if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Upstream notification timestamp is invalid')
return Object.freeze({ ...value })
}
function parseState(value) {
exactKeys(value, ['automaticCheck', 'latestAutomatic', 'schema'], 'automatic check state')
if (value.schema !== 1) throw new Error('automatic check state schema must be 1')
exactKeys(value.latestAutomatic, ['stable', 'upstream'], 'automatic notification state')
return Object.freeze({
schema: 1,
automaticCheck: parseConfig(value.automaticCheck),
latestAutomatic: Object.freeze({
stable: parseStable(value.latestAutomatic.stable),
upstream: parseUpstream(value.latestAutomatic.upstream),
}),
})
}
function isHeld(version, environment, holds) {
return holds.some(hold => hold.dshVersion === version
&& (hold.type !== 'combination' || hold.environmentVersion === environment))
}
function stableSatisfied(candidate, current) {
return current.environment === candidate.environment && compareDshVersions(current.dsh, candidate.dsh) >= 0
}
export class AutomaticCheckStateStore {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.pending = Promise.resolve()
}
async read() {
try {
return parseState(JSON.parse(await readFile(this.path, 'utf8')))
} catch (error) {
if (error?.code === 'ENOENT') return DEFAULT_STATE
throw error
}
}
async replace(update) {
const operation = this.pending.then(async () => {
const next = parseState(await update(await this.read()))
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
this.pending = operation.catch(() => {})
return operation
}
configure(value) {
const config = parseConfig(value)
return this.replace(previous => ({
...previous,
automaticCheck: config,
latestAutomatic: !config.enabled || !config.notificationsEnabled
? { stable: null, upstream: null }
: previous.latestAutomatic,
}))
}
record({ channel, current, target, upstream, stableAvailable, holds = [] }) {
return this.replace(previous => {
if (!previous.automaticCheck.enabled || !previous.automaticCheck.notificationsEnabled) return previous
const checkedAt = this.now().toISOString()
let stable = previous.latestAutomatic.stable
let latestUpstream = previous.latestAutomatic.upstream
if (stable !== null && stableSatisfied(stable, current)) stable = null
if (
stableAvailable === true
&& (stable === null || target.targetSequence > stable.targetSequence)
) {
stable = {
targetSequence: target.targetSequence,
dsh: target.desired.dsh.version,
environment: target.desired.environment.version,
checkedAt,
}
}
if (channel !== 'experimental') {
latestUpstream = null
} else {
if (latestUpstream !== null && compareDshVersions(current.dsh, latestUpstream.version) >= 0) latestUpstream = null
if (
upstream !== null
&& compareDshVersions(upstream.version, current.dsh) > 0
&& !isHeld(upstream.version, target.desired.environment.version, holds)
&& (latestUpstream === null || compareDshVersions(upstream.version, latestUpstream.version) > 0)
) latestUpstream = { version: upstream.version, checkedAt }
}
return { ...previous, latestAutomatic: { stable, upstream: latestUpstream } }
})
}
clearSatisfied(current) {
return this.replace(previous => ({
...previous,
latestAutomatic: {
stable: previous.latestAutomatic.stable !== null
&& stableSatisfied(previous.latestAutomatic.stable, current)
? null
: previous.latestAutomatic.stable,
upstream: previous.latestAutomatic.upstream !== null
&& compareDshVersions(current.dsh, previous.latestAutomatic.upstream.version) >= 0
? null
: previous.latestAutomatic.upstream,
},
}))
}
clearUpstream() {
return this.replace(previous => ({
...previous,
latestAutomatic: { ...previous.latestAutomatic, upstream: null },
}))
}
}
@@ -0,0 +1,169 @@
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
import { isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
const DEFAULT_STATE = Object.freeze({
schema: 1,
updateChannel: 'stable',
holds: Object.freeze([]),
experimentalBlocked: null,
})
function text(value, label) {
if (typeof value !== 'string' || value === '' || value.length > 512 || value.includes('\0')) {
throw new TrustError(`${label} is invalid`)
}
return value
}
function parseHold(value) {
const hold = plainObject(value, 'update hold')
const keys = Object.keys(hold).sort()
const expected = ['createdAt', 'dshVersion', 'environmentVersion', 'id', 'reason', 'type']
if (JSON.stringify(keys) !== JSON.stringify(expected)) throw new TrustError('update hold has unexpected fields')
if (!['version', 'combination'].includes(hold.type)) throw new TrustError('update hold type is invalid')
if (hold.type === 'version' && hold.environmentVersion !== null) throw new TrustError('version hold cannot name an Environment')
if (hold.type === 'combination' && typeof hold.environmentVersion !== 'string') {
throw new TrustError('combination hold requires an Environment')
}
isoTimestamp(hold.createdAt, 'update hold creation time')
return Object.freeze({
id: text(hold.id, 'update hold ID'),
type: hold.type,
dshVersion: text(hold.dshVersion, 'update hold DSH version'),
environmentVersion: hold.environmentVersion === null ? null : text(hold.environmentVersion, 'update hold Environment'),
reason: text(hold.reason, 'update hold reason'),
createdAt: hold.createdAt,
})
}
function parseState(value) {
const state = plainObject(value, 'channel state')
const keys = Object.keys(state).sort()
if (JSON.stringify(keys) !== JSON.stringify(['experimentalBlocked', 'holds', 'schema', 'updateChannel'])) {
throw new TrustError('channel state has unexpected fields')
}
if (state.schema !== 1) throw new TrustError('channel state schema must be 1')
if (!['stable', 'experimental'].includes(state.updateChannel)) throw new TrustError('update channel is invalid')
if (!Array.isArray(state.holds)) throw new TrustError('channel holds must be an array')
const holds = state.holds.map(parseHold)
if (new Set(holds.map(hold => hold.id)).size !== holds.length) throw new TrustError('update hold IDs must be unique')
const blocked = state.experimentalBlocked === null ? null : parseHold(state.experimentalBlocked)
if (blocked !== null && blocked.type !== 'combination') throw new TrustError('Experimental block must identify a combination')
return Object.freeze({ schema: 1, updateChannel: state.updateChannel, holds: Object.freeze(holds), experimentalBlocked: blocked })
}
export class ChannelStateStore {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async read() {
try { return parseState(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
if (error?.code === 'ENOENT') return DEFAULT_STATE
throw error
}
}
write(value) { return durableReplace(this.path, `${JSON.stringify(parseState(value))}\n`) }
setChannel(updateChannel) {
return this.exclusive(async () => {
const current = await this.read()
const next = parseState({ ...current, updateChannel })
await this.write(next)
return next
})
}
addHold({ type, dshVersion, environmentVersion = null, reason }) {
return this.exclusive(async () => {
const current = await this.read()
const identity = `${type}\0${dshVersion}\0${environmentVersion ?? ''}`
const id = createHash('sha256').update(identity).digest('hex').slice(0, 24)
const hold = parseHold({
id, type, dshVersion, environmentVersion, reason,
createdAt: this.now().toISOString(),
})
const holds = [...current.holds.filter(item => item.id !== id), hold]
const next = parseState({ ...current, holds })
await this.write(next)
return hold
})
}
retry(id) {
return this.exclusive(async () => {
const current = await this.read()
const holds = current.holds.filter(hold => hold.id !== id)
const clearsBlock = current.experimentalBlocked?.id === id
if (holds.length === current.holds.length && !clearsBlock) throw new TrustError('update hold does not exist')
const next = parseState({ ...current, holds, experimentalBlocked: clearsBlock ? null : current.experimentalBlocked })
await this.write(next)
return next
})
}
block({ dshVersion, environmentVersion, reason }) {
return this.exclusive(async () => {
const current = await this.read()
const blocked = parseHold({
id: `blocked-${randomUUID()}`, type: 'combination', dshVersion, environmentVersion, reason,
createdAt: this.now().toISOString(),
})
const next = parseState({ ...current, experimentalBlocked: blocked })
await this.write(next)
return next
})
}
}
function held(local, dshVersion, environmentVersion) {
return local.holds.find(hold => (
hold.dshVersion === dshVersion
&& (hold.type === 'version' || hold.environmentVersion === environmentVersion)
))
}
export function planDesiredState({ local, current, supported, stableTargetSequence, upstream = null }) {
const aheadOfStable = compareDshVersions(current.dsh, supported.dsh) > 0
const stableIdentityMismatch = Number.isSafeInteger(stableTargetSequence) && (
current.targetSequence !== stableTargetSequence
|| (local.updateChannel === 'stable'
? current.authority !== 'stable'
: !['stable', 'experimental'].includes(current.authority))
)
const base = {
updateChannel: local.updateChannel,
current,
supported,
upstream,
aheadOfStable,
experimentalBlocked: local.experimentalBlocked,
holds: local.holds,
}
if (aheadOfStable) return Object.freeze({ ...base, action: 'frozen', reason: 'Stable has not caught up with the current DSH' })
if (compareDshVersions(current.dsh, supported.dsh) < 0 || current.environment !== supported.environment || stableIdentityMismatch) {
return Object.freeze({ ...base, action: 'stable', reason: 'converge the signed Stable target before Experimental DSH' })
}
if (local.updateChannel === 'stable' || upstream === null) {
return Object.freeze({ ...base, action: 'none', reason: null })
}
const matchingHold = held(local, upstream.version, supported.environment)
if (matchingHold !== undefined) return Object.freeze({ ...base, action: 'held', reason: matchingHold.reason, hold: matchingHold })
if (
local.experimentalBlocked?.dshVersion === upstream.version
&& local.experimentalBlocked.environmentVersion === supported.environment
) return Object.freeze({ ...base, action: 'blocked', reason: local.experimentalBlocked.reason })
return Object.freeze({ ...base, action: 'experimental', reason: null })
}
@@ -0,0 +1,66 @@
import { request } from 'node:http'
export class LocalApiClient {
constructor(socketPath) {
this.socketPath = socketPath
}
request(method, path, body, extraHeaders = {}) {
return new Promise((resolve, reject) => {
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
const req = request({
socketPath: this.socketPath,
method,
path,
headers: {
...extraHeaders,
...(bytes === undefined ? {} : {
'content-type': 'application/json',
'content-length': bytes.byteLength,
}),
},
}, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => {
let value
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { value = {} }
if ((response.statusCode ?? 500) >= 400) {
const error = new Error(value.error ?? `local API returned ${String(response.statusCode)}`)
error.statusCode = response.statusCode
if (typeof value.code === 'string') error.code = value.code
if (Number.isInteger(value.retryAfterSeconds)) error.retryAfterSeconds = value.retryAfterSeconds
error.localApiPath = path
reject(error)
} else resolve(value)
})
})
req.once('error', reject)
req.end(bytes)
})
}
status() { return this.request('GET', '/v1/status') }
activeReceipts() { return this.request('GET', '/v1/receipts/active') }
acceptKeyring(document, signature) {
return this.request('POST', '/v1/keyring', { document: document.toString('base64'), signature })
}
acceptTarget(document, signature) {
return this.request('POST', '/v1/target', { document: document.toString('base64'), signature })
}
importArtifact(artifactId, sourcePath, parentReceipt = null) {
return this.request('POST', '/v1/artifacts/import', { artifactId, sourcePath, parentReceipt })
}
ensureOfficialDsh(version, metadataPath, tarballPath) {
return this.request('POST', '/v1/dsh/ensure', { version, metadataPath, tarballPath })
}
acceptManifest(receipt, signatureReceipt) {
return this.request('POST', '/v1/manifests/accept', { receipt, signatureReceipt })
}
activate(receipts) { return this.request('POST', '/v1/activate', { receipts }) }
collectGarbage() { return this.request('POST', '/v1/objects/collect', {}) }
stageBootstrap(receipt, version) {
return this.request('POST', '/v1/bootstrap/stage', { receipt, version })
}
collectBootstrap() { return this.request('POST', '/v1/bootstrap/collect', {}) }
}
@@ -0,0 +1,894 @@
import { randomUUID } from 'node:crypto'
import { EventEmitter } from 'node:events'
import { recoverInterruptedUpdate } from './recovery.mjs'
import { planDesiredState } from './channel-state.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
export class UpdateConflictError extends Error {}
function healthMetrics(health) {
const components = Array.isArray(health?.components) ? health.components : []
return {
readyServices: components.filter(component => component?.healthy === true).length,
totalServices: components.length,
}
}
function journalDeployment(deployment) {
return Object.freeze({
dsh: deployment.dsh,
environment: deployment.environment,
runtime: deployment.runtime,
dataSnapshot: deployment.dataSnapshot,
receiptTokens: deployment.receiptTokens,
})
}
function metricPercentage(processed, total) {
return typeof processed === 'number' && typeof total === 'number' && total > 0
? Math.floor(Math.max(0, Math.min(1, processed / total)) * 100)
: null
}
function holdReason(message) {
const lines = message.split(/\r?\n/u).map(line => line.trim()).filter(Boolean)
const npmNotarget = lines.find(line => /^npm error notarget\b/u.test(line))
const selected = npmNotarget?.replace(/^npm error notarget\s*/u, '') ?? lines[0] ?? 'Experimental update failed'
const normalized = selected.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim()
return (normalized || 'Experimental update failed').slice(0, 512)
}
export class UpdateCoordinator extends EventEmitter {
constructor({
metadata, preparer, activator, state, npm, journal, snapshots, channelState, completeRecovery, automaticChecks,
allowUnavailableMetadata = false,
probationSeconds = 120,
report = async () => {},
now = () => new Date(),
sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
super()
this.metadata = metadata
this.preparer = preparer
this.activator = activator
this.state = state
this.npm = npm
this.journal = journal
this.snapshots = snapshots
this.channelState = channelState
this.completeRecovery = completeRecovery
this.automaticChecks = automaticChecks
this.allowUnavailableMetadata = allowUnavailableMetadata
this.probationSeconds = probationSeconds
this.report = report
this.now = now
this.sleep = sleep
this.task = undefined
this.checkTask = undefined
this.reportTimes = new Map()
}
async transition(status, fields = {}) {
const previous = await this.state.read()
const phase = fields.phase ?? (
status === 'idle'
? null
: fields.rollbackPhase ?? (
['success', 'failed'].includes(status)
? previous.phase ?? previous.rollbackPhase ?? (['idle', 'success', 'failed'].includes(previous.status) ? null : previous.status)
: status
)
)
const contextChanged = phase !== (previous.phase ?? null)
|| (fields.operation !== undefined && fields.operation !== previous.operation)
const phaseMetrics = contextChanged ? {
processedBytes: null,
totalBytes: null,
processedItems: null,
totalItems: null,
readyServices: null,
totalServices: null,
detail: null,
} : {}
const value = await this.state.write(status, { ...phaseMetrics, ...fields, phase })
this.emit('state', value)
await this.record('update.phase.changed', {
status,
phase,
operation: value.operation ?? null,
rollbackPhase: value.rollbackPhase ?? null,
taskId: value.taskId ?? null,
progress: value.progress ?? null,
processedBytes: value.processedBytes ?? null,
totalBytes: value.totalBytes ?? null,
processedItems: value.processedItems ?? null,
totalItems: value.totalItems ?? null,
readyServices: value.readyServices ?? null,
totalServices: value.totalServices ?? null,
detail: value.detail ?? null,
targetSequence: value.targetSequence ?? value.available?.targetSequence ?? null,
outcome: value.outcome ?? null,
...(value.error === null || value.error === undefined ? {} : { error: value.error, level: 'error' }),
})
return value
}
async record(message, fields = {}) {
try {
const current = await this.state.read()
const context = {
...(current.taskId === null || current.taskId === undefined ? {} : { taskId: current.taskId }),
...(current.operation === null || current.operation === undefined ? {} : { operation: current.operation }),
...(current.phase === null || current.phase === undefined
? (current.status === null || current.status === undefined ? {} : { phase: current.status })
: { phase: current.phase }),
}
return await this.report(message, { ...context, ...fields })
} catch {}
}
async bestEffort(message, operation, fallback, fields = {}, cooldownMs = 0) {
try {
return await operation()
} catch (error) {
const timestamp = this.now().valueOf()
const previous = this.reportTimes.get(message)
if (cooldownMs === 0 || previous === undefined || timestamp - previous >= cooldownMs) {
this.reportTimes.set(message, timestamp)
await this.record(message, { ...fields, error, level: 'warning' })
}
return fallback
}
}
async reportHealth(status, fields) {
if (this.activator.health === undefined) return undefined
const health = await this.activator.health()
await this.transition(status, { ...fields, ...healthMetrics(health) })
return health
}
hasActiveTask() {
return this.task !== undefined
}
check(source = 'manual') {
if (this.task !== undefined && source === 'page-open') return Promise.resolve({ busy: true })
if (this.task !== undefined) return Promise.reject(new UpdateConflictError('an update task is already running'))
if (this.checkTask !== undefined) return this.checkTask
this.checkTask = this.runCheck(source).finally(() => { this.checkTask = undefined })
return this.checkTask
}
startTask(operation) {
if (this.task !== undefined) throw new UpdateConflictError('an update task is already running')
const taskId = randomUUID()
const precedingCheck = this.checkTask
this.task = (async () => {
if (precedingCheck !== undefined) {
try { await precedingCheck } catch {}
}
return operation(taskId)
})().finally(() => { this.task = undefined })
return { taskId, completion: this.task }
}
async runCheck(source) {
await this.transition('checking', {
checkSource: source, taskId: null, progress: 0, operation: 'check', rollbackPhase: null, error: null,
})
try {
if (this.allowUnavailableMetadata) return await this.recordUnavailableMetadata(source)
if (this.channelState !== undefined) {
const plan = await this.desiredState()
const rollbackPlan = await this.rollbackPlan()
const updateAvailable = ['stable', 'experimental'].includes(plan.action)
await this.transition('idle', {
available: {
targetSequence: plan.target.targetSequence,
bootstrap: plan.target.desired.bootstrap.version,
environment: plan.target.desired.environment.version,
dsh: plan.target.desired.dsh.version,
},
supported: plan.supported,
...(plan.upstream === null ? {} : { upstream: { version: plan.upstream.version } }),
current: { dsh: plan.current.dsh, environment: plan.current.environment, runtime: plan.current.runtime },
aheadOfStable: plan.aheadOfStable,
experimentalBlocked: plan.experimentalBlocked,
holds: plan.holds,
rollbackPlan,
updateAvailable,
checkedAt: this.now().toISOString(),
metadataUnavailable: false,
remoteCheckError: null,
remoteCheckFailedAt: null,
remoteCheckSource: null,
})
if (source === 'automatic' && this.automaticChecks !== undefined) {
await this.automaticChecks.record({
channel: plan.updateChannel,
current: plan.current,
target: plan.target,
upstream: plan.upstream,
stableAvailable: plan.action === 'stable',
holds: plan.holds,
})
this.emit('state', await this.state.read())
}
return { value: plan.target, updateAvailable }
}
const target = await this.metadata.check()
await this.transition('idle', {
available: {
targetSequence: target.value.targetSequence,
bootstrap: target.value.desired.bootstrap.version,
environment: target.value.desired.environment.version,
dsh: target.value.desired.dsh.version,
},
checkedAt: new Date().toISOString(),
metadataUnavailable: false,
remoteCheckError: null,
remoteCheckFailedAt: null,
remoteCheckSource: null,
})
return target
} catch (error) {
await this.record('update.check.failed', { error, checkSource: source })
await this.transition('idle', {
error: null,
remoteCheckError: error instanceof Error ? error.message : 'update check failed',
remoteCheckFailedAt: this.now().toISOString(),
remoteCheckSource: error?.remoteCheckSource === 'upstream' ? 'upstream' : 'stable',
})
throw error
}
}
async recordUnavailableMetadata(source) {
const local = await this.channelState?.read()
const upstream = local?.updateChannel === 'experimental' && this.npm !== undefined
? await this.bestEffort('update.upstream-discovery.failed', () => this.npm.discover(), null, { checkSource: source })
: null
await this.transition('idle', {
metadataUnavailable: true,
...(upstream === null ? {} : { upstream }),
updateAvailable: false,
checkedAt: this.now().toISOString(),
error: null,
})
return { unavailable: true, upstream }
}
start() {
return this.startTask(taskId => this.run(taskId))
}
startExperimental() {
if (this.npm === undefined || this.journal === undefined || this.snapshots === undefined) {
throw new Error('Experimental updates are not configured')
}
return this.startTask(taskId => this.runExperimental(taskId))
}
startReconcile() {
return this.startTask(taskId => this.runReconcile(taskId))
}
async setChannel(updateChannel) {
if (this.task !== undefined) throw new UpdateConflictError('an update task is already running')
if (this.channelState === undefined) throw new Error('update channels are not configured')
const result = await this.channelState.setChannel(updateChannel)
if (updateChannel === 'stable') await this.automaticChecks?.clearUpstream()
await this.record('update.channel.changed', { updateChannel })
return result
}
async retryHold(id) {
if (this.task !== undefined) throw new UpdateConflictError('an update task is already running')
if (this.channelState === undefined) throw new Error('update channels are not configured')
const result = await this.channelState.retry(id)
await this.record('update.hold.retried', { holdId: id })
return result
}
rollbackPlan() {
if (this.completeRecovery === undefined) return Promise.resolve(null)
return this.completeRecovery.plan()
}
startCompleteRollback(planId, options = {}) {
if (this.completeRecovery === undefined) throw new Error('complete rollback is not configured')
return this.startTask(taskId => this.runCompleteRollback(taskId, planId, options))
}
async runCompleteRollback(taskId, planId, options) {
const operation = options.requireConfirmation ? 'return-stable' : 'rollback'
try {
const rollbackPlan = await this.completeRecovery.plan()
let verifiedStable = null
await this.transition('restoring-data', {
taskId,
progress: 5,
operation,
rollbackPhase: 'preparing',
rollbackIncludesSnapshot: rollbackPlan?.snapshot !== null && rollbackPlan?.snapshot !== undefined,
error: null,
})
if (options.requireConfirmation) {
const [plan, target] = await Promise.all([this.completeRecovery.plan(), this.metadata.check()])
if (
plan === null
|| plan.planId !== planId
|| compareDshVersions(plan.previous.dsh, target.value.desired.dsh.version) > 0
) throw new Error('no verified pre-Experimental Stable recovery point is available')
verifiedStable = target.value
}
const result = await this.completeRecovery.restore(planId, {
...options,
onProgress: (() => {
let previousKey
return (rollbackPhase, progress, metrics = {}) => {
const key = [
rollbackPhase,
progress,
metricPercentage(metrics.processedBytes, metrics.totalBytes),
metricPercentage(metrics.processedItems, metrics.totalItems),
].join(':')
if (key === previousKey) return undefined
previousKey = key
return this.transition('restoring-data', {
taskId,
progress,
operation,
rollbackPhase,
processedBytes: metrics.processedBytes ?? null,
totalBytes: metrics.totalBytes ?? null,
processedItems: metrics.processedItems ?? null,
totalItems: metrics.totalItems ?? null,
})
}
})(),
})
await this.reportHealth('restoring-data', {
taskId,
progress: 95,
operation,
rollbackPhase: 'verifying',
})
const current = await this.activator.currentDeployment()
if (
rollbackPlan === null
|| current.dsh !== rollbackPlan.previous.dsh
|| current.environment !== rollbackPlan.previous.environment
|| current.runtime !== rollbackPlan.previous.runtime
) throw new Error('restored Deployment differs from the rollback plan')
const [stored, local] = await Promise.all([
this.state.read(),
this.channelState?.read() ?? Promise.resolve({ updateChannel: 'stable', holds: [], experimentalBlocked: null }),
])
const supported = verifiedStable === null
? stored.supported ?? null
: {
dsh: verifiedStable.desired.dsh.version,
environment: verifiedStable.desired.environment.version,
}
let aheadOfStable = false
let updateAvailable = false
if (supported !== null) {
const upstream = local.updateChannel === 'experimental'
&& typeof stored.upstream?.version === 'string'
&& compareDshVersions(stored.upstream.version, supported.dsh) > 0
? stored.upstream
: null
const plan = planDesiredState({
local,
current,
supported,
stableTargetSequence: verifiedStable?.targetSequence ?? stored.available?.targetSequence,
upstream,
})
aheadOfStable = plan.aheadOfStable
updateAvailable = ['stable', 'experimental'].includes(plan.action)
}
await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId })
await this.transition('success', {
taskId,
progress: 100,
current: { dsh: current.dsh, environment: current.environment, runtime: current.runtime },
aheadOfStable,
updateAvailable,
experimentalBlocked: local.experimentalBlocked,
holds: local.holds,
outcome: null,
probationUntil: null,
error: null,
})
return result
} catch (error) {
await this.record('update.rollback.failed', { error, taskId, operation })
await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'rollback failed' })
throw error
}
}
async desiredState() {
if (this.channelState === undefined) throw new Error('update channels are not configured')
const stable = (await this.metadata.check()).value
const [current, local] = await Promise.all([this.activator.currentDeployment(), this.channelState.read()])
const supported = { dsh: stable.desired.dsh.version, environment: stable.desired.environment.version }
let upstream = null
let upstreamCandidate = null
if (
local.updateChannel === 'experimental'
&& compareDshVersions(current.dsh, supported.dsh) <= 0
&& current.environment === supported.environment
) {
try {
upstream = await this.npm.discover(stable.officialDshPolicy)
} catch (error) {
const scoped = new Error(error instanceof Error ? error.message : 'official DSH discovery failed', { cause: error })
scoped.name = error instanceof Error ? error.name : 'Error'
scoped.code = error?.code
scoped.remoteCheckSource = 'upstream'
throw scoped
}
upstreamCandidate = compareDshVersions(upstream.version, supported.dsh) > 0 ? upstream : null
}
return Object.freeze({
...planDesiredState({ local, current, supported, stableTargetSequence: stable.targetSequence, upstream: upstreamCandidate }),
upstream,
target: stable,
})
}
async publicStatus() {
const [update, local, current, rollbackPlan, journal, automatic] = await Promise.all([
this.state.read(),
this.channelState?.read() ?? Promise.resolve({ updateChannel: 'stable', holds: [], experimentalBlocked: null }),
this.bestEffort(
'update.status.current.failed',
() => this.activator.currentDeployment({ required: false }),
null,
{},
60_000,
),
this.bestEffort('update.status.rollback-plan.failed', () => this.rollbackPlan(), null, {}, 60_000),
this.journal === undefined
? Promise.resolve(undefined)
: this.bestEffort('update.status.journal.failed', () => this.journal.read(), undefined, {}, 60_000),
this.automaticChecks?.read() ?? Promise.resolve(null),
])
const returnStableAvailable = rollbackPlan !== null && rollbackPlan.snapshot !== null && update.supported?.dsh !== undefined
&& compareDshVersions(rollbackPlan.previous.dsh, update.supported.dsh) <= 0
return Object.freeze({
update,
updateChannel: local.updateChannel,
current: current === null ? null : { dsh: current.dsh, environment: current.environment, runtime: current.runtime },
supported: update.supported ?? null,
upstream: update.upstream ?? null,
aheadOfStable: update.aheadOfStable ?? false,
experimentalBlocked: local.experimentalBlocked,
holds: local.holds,
probation: journal?.phase === 'probation' ? { until: journal.probationUntil } : null,
rollbackPlan: rollbackPlan === null ? null : { ...rollbackPlan, returnStableAvailable },
...(automatic === null ? {} : {
automaticCheck: automatic.automaticCheck,
latestAutomatic: automatic.latestAutomatic,
}),
})
}
async runReconcile(taskId) {
try {
const plan = await this.desiredState()
if (plan.action === 'stable') {
const blockExperimentalCombination = plan.current?.authority === 'experimental'
&& plan.current.environment !== plan.supported?.environment
await this.run(taskId, { complete: false })
const next = await this.desiredState()
if (next.action === 'experimental') {
return this.runExperimental(taskId, { blockCombination: blockExperimentalCombination })
}
return this.transition('success', {
taskId,
progress: 100,
current: { dsh: next.current.dsh, environment: next.current.environment, runtime: next.current.runtime },
aheadOfStable: next.aheadOfStable,
experimentalBlocked: next.experimentalBlocked,
holds: next.holds,
updateAvailable: ['stable', 'experimental'].includes(next.action),
error: null,
outcome: next.action,
})
}
if (plan.action === 'experimental') return this.runExperimental(taskId)
return this.transition('success', {
taskId, progress: 100, error: null,
outcome: plan.action,
})
} catch (error) {
await this.record('update.reconcile.failed', { error, taskId })
const current = await this.state.read()
if (current.status !== 'failed') {
await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'update planning failed' })
}
throw error
}
}
async run(taskId, { complete = true } = {}) {
try {
await this.transition('planning', {
taskId, progress: 0, operation: 'update', rollbackPhase: null, rollbackIncludesSnapshot: null,
processedBytes: null, totalBytes: null, processedItems: null, totalItems: null,
readyServices: null, totalServices: null, detail: null, error: null,
})
const target = await this.metadata.check()
const artifacts = Array.isArray(target.value.artifacts) ? target.value.artifacts : []
await this.transition('downloading', {
taskId,
progress: 10,
targetSequence: target.value.targetSequence,
processedBytes: 0,
totalBytes: artifacts.reduce((total, artifact) => total + artifact.size, 0),
processedItems: 0,
totalItems: artifacts.length,
detail: 'downloading',
})
let previousDownloadKey
let previousDownloadProgress = 10
const prepared = await this.preparer.prepare(target.value, {
onProgress: metrics => {
const byteRatio = Number.isFinite(metrics.totalBytes) && metrics.totalBytes > 0
? metrics.processedBytes / metrics.totalBytes
: null
const itemRatio = Number.isFinite(metrics.totalItems) && metrics.totalItems > 0
? metrics.processedItems / metrics.totalItems
: null
const ratio = byteRatio ?? itemRatio
const measuredProgress = ratio === null ? 10 : 10 + Math.round(Math.max(0, Math.min(1, ratio)) * 55)
const progress = Math.max(previousDownloadProgress, measuredProgress)
previousDownloadProgress = progress
const key = `${progress}:${metrics.processedItems}:${metrics.totalItems}`
if (key === previousDownloadKey) return undefined
previousDownloadKey = key
return this.transition('downloading', {
taskId,
progress,
processedBytes: metrics.processedBytes,
totalBytes: metrics.totalBytes,
processedItems: metrics.processedItems,
totalItems: metrics.totalItems,
detail: metrics.detail ?? null,
})
},
})
await this.transition('validating', { taskId, progress: 70 })
await this.transition('building-candidate', { taskId, progress: 75 })
let previousBuildProgress = 75
let buildComplete = false
await this.activator.activate(prepared, {
onProgress: progress => {
if (typeof progress === 'number') {
const stageProgress = Math.max(previousBuildProgress, progress)
if (stageProgress === previousBuildProgress) return undefined
previousBuildProgress = stageProgress
return this.transition('building-candidate', { taskId, progress: stageProgress })
}
const ratio = progress.totalBytes > 0 ? progress.processedBytes / progress.totalBytes : 0
const stageProgress = Math.max(
previousBuildProgress,
75 + Math.round(Math.max(0, Math.min(1, ratio)) * 14),
)
const complete = progress.processedBytes === progress.totalBytes
&& progress.processedItems === progress.totalItems
if (stageProgress === previousBuildProgress && (!complete || buildComplete)) return undefined
previousBuildProgress = stageProgress
buildComplete = complete
return this.transition('building-candidate', {
taskId,
progress: stageProgress,
processedBytes: progress.processedBytes,
totalBytes: progress.totalBytes,
processedItems: progress.processedItems,
totalItems: progress.totalItems,
})
},
onSwitching: () => this.transition('switching', { taskId, progress: 90 }),
})
const health = await this.reportHealth('switching', { taskId, progress: 95 })
const current = this.activator.currentDeployment === undefined
? null
: await this.activator.currentDeployment()
await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId })
return complete ? this.transition('success', {
taskId,
progress: 100,
...(current === null ? {} : {
current: { dsh: current.dsh, environment: current.environment, runtime: current.runtime },
updateAvailable: false,
}),
error: null,
}) : health
} catch (error) {
await this.record('update.stable.failed', { error, taskId })
await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'update failed' })
throw error
}
}
trustActivate(prepared) {
return this.preparer.trust.activate(prepared.receiptTokens)
}
async runExperimental(taskId, { blockCombination = false } = {}) {
let transaction
let candidate
let failureClass = 'check'
let obsoleteSnapshotId = null
try {
await this.transition('checking-upstream', {
taskId, progress: 0, operation: 'update', rollbackPhase: null, rollbackIncludesSnapshot: null, error: null,
})
const stable = (await this.metadata.check()).value
candidate = await this.npm.latest(stable)
if (candidate === null) return this.transition('success', { taskId, progress: 100, error: null })
const from = await this.activator.currentDeployment()
const bootstrap = await this.activator.bootstrap.status()
if (
from.environment !== stable.desired.environment.version
|| bootstrap.bootstrapVersion !== stable.desired.bootstrap.version
) throw new Error('Stable Environment and Bootstrap must converge before Experimental DSH')
await this.transition('downloading', { taskId, progress: 10 })
failureClass = 'candidate'
let previousDownloadProgress = 10
const prepared = await this.preparer.prepareExperimental(candidate, {
onProgress: metrics => {
const ratio = Number.isFinite(metrics.totalBytes) && metrics.totalBytes > 0
? metrics.processedBytes / metrics.totalBytes
: metrics.processedItems / metrics.totalItems
const progress = Math.max(previousDownloadProgress, 10 + Math.round(Math.max(0, Math.min(1, ratio)) * 20))
previousDownloadProgress = progress
return this.transition('downloading', {
taskId,
progress,
processedBytes: metrics.processedBytes,
totalBytes: metrics.totalBytes,
processedItems: metrics.processedItems,
totalItems: metrics.totalItems,
detail: metrics.detail ?? null,
})
},
})
await this.transition('building-candidate', { taskId, progress: 35 })
const built = await this.activator.prepareExperimental(prepared, { targetSequence: stable.targetSequence })
const previousJournal = await this.journal.read()
if (previousJournal !== undefined && ['committed', 'rolled-back', 'failed'].includes(previousJournal.phase)) {
obsoleteSnapshotId = previousJournal.snapshotId
}
transaction = await this.journal.begin({
transactionId: taskId,
mode: 'experimental',
from: journalDeployment(from),
to: { dsh: built.dshVersion, environment: built.environmentVersion, runtime: built.runtimeId },
})
transaction = await this.journal.transition('candidate-ready', { receiptTokens: prepared.receiptTokens })
await this.transition('snapshotting-data', { taskId, progress: 55 })
failureClass = 'snapshot'
await this.activator.suspendDsh()
transaction = await this.journal.transition('suspended')
let previousSnapshotProgress
const snapshot = await this.snapshots.create({
id: taskId,
runtimeId: from.runtime,
environmentVersion: from.environment,
dshVersion: from.dsh,
onProgress: metrics => {
const metricProgress = metricPercentage(metrics.processedItems, metrics.totalItems)
?? metricPercentage(metrics.processedBytes, metrics.totalBytes)
if (metricProgress === previousSnapshotProgress) return undefined
previousSnapshotProgress = metricProgress
return this.transition('snapshotting-data', {
taskId,
progress: 55,
processedBytes: metrics.processedBytes ?? null,
totalBytes: metrics.totalBytes ?? null,
processedItems: metrics.processedItems ?? null,
totalItems: metrics.totalItems ?? null,
})
},
})
const candidateRuntimeId = this.activator.bindExperimentalSnapshot === undefined
? built.runtimeId
: await this.activator.bindExperimentalSnapshot(built.runtimeId, snapshot.id)
transaction = await this.journal.transition('snapshot-created', {
snapshotId: snapshot.id,
to: { ...transaction.to, runtime: candidateRuntimeId },
})
await this.transition('switching', { taskId, progress: 70 })
await this.activator.switchExperimental(candidateRuntimeId)
failureClass = 'combination'
transaction = await this.journal.transition('switched')
const probationUntil = new Date(this.now().valueOf() + this.probationSeconds * 1000).toISOString()
transaction = await this.journal.transition('probation', { probationUntil })
await this.transition('probation', { taskId, progress: 85, probationUntil })
do {
const health = await this.activator.health()
const remainingMs = Math.max(0, new Date(probationUntil).valueOf() - this.now().valueOf())
const elapsedRatio = this.probationSeconds <= 0
? 1
: Math.max(0, Math.min(1, 1 - remainingMs / (this.probationSeconds * 1000)))
await this.transition('probation', {
taskId,
progress: 85 + Math.round(elapsedRatio * 14),
probationUntil,
detail: `probation:${String(Math.ceil(remainingMs / 1000))}`,
...healthMetrics(health),
})
if (!health.healthy) throw new Error('Experimental Runtime failed probation health checks')
if (this.now().valueOf() >= new Date(probationUntil).valueOf()) break
await this.sleep(Math.min(1_000, Math.max(0, new Date(probationUntil).valueOf() - this.now().valueOf())))
} while (true)
await this.activator.commitExperimental(candidateRuntimeId)
transaction = await this.journal.transition('committed')
await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId })
if (this.activator.cleanup !== undefined) {
await this.bestEffort('update.assets.cleanup.failed', () => this.activator.cleanup(), undefined, { taskId })
}
if (obsoleteSnapshotId !== null && obsoleteSnapshotId !== transaction.snapshotId) {
if (this.snapshots.remove !== undefined) {
await this.bestEffort('update.snapshot.cleanup.failed', () => this.snapshots.remove(obsoleteSnapshotId), undefined, {
snapshotId: obsoleteSnapshotId,
taskId,
})
}
}
return this.transition('success', {
taskId,
progress: 100,
current: { dsh: built.dshVersion, environment: built.environmentVersion, runtime: candidateRuntimeId },
aheadOfStable: true,
updateAvailable: false,
outcome: 'frozen',
probationUntil: null,
error: null,
})
} catch (error) {
let message = error instanceof Error ? error.message : 'Experimental update failed'
const reason = holdReason(message)
const failureState = await this.state.read()
const failurePhase = failureState.phase ?? failureState.status
let recoveryCompleted = false
await this.record('update.experimental.failed', { error, failureClass, taskId })
if (candidate !== undefined && this.channelState !== undefined) {
if (failureClass === 'candidate') {
await this.bestEffort('update.hold.persist.failed', () => this.channelState.addHold({
type: 'version', dshVersion: candidate.version, reason,
}), undefined, { dshVersion: candidate.version, holdType: 'version', taskId })
} else if (failureClass === 'combination') {
const environmentVersion = transaction?.to.environment
if (environmentVersion !== undefined) {
const persist = blockCombination
? () => this.channelState.block({ dshVersion: candidate.version, environmentVersion, reason })
: () => this.channelState.addHold({
type: 'combination', dshVersion: candidate.version, environmentVersion, reason,
})
await this.bestEffort(
blockCombination ? 'update.experimental-block.persist.failed' : 'update.hold.persist.failed',
persist,
undefined,
{
dshVersion: candidate.version,
environmentVersion,
...(blockCombination ? { blocked: true } : { holdType: 'combination' }),
taskId,
},
)
}
}
}
if (this.journal !== undefined) {
transaction = await this.bestEffort('update.journal.read.failed', () => this.journal.read(), transaction, { taskId })
}
if (transaction !== undefined && !['committed', 'rolled-back', 'failed'].includes(transaction.phase)) {
try {
if (['snapshot-created', 'switched', 'probation', 'restoring-data'].includes(transaction.phase)) {
if (transaction.phase !== 'restoring-data') transaction = await this.journal.transition('restoring-data', { error: message })
const recoveryProgress = offset => Math.min(99, Math.max(Number(failureState.progress) || 0, offset))
await this.transition('restoring-data', {
taskId,
operation: 'update',
progress: recoveryProgress(86),
detail: 'recovery:suspend',
rollbackIncludesSnapshot: transaction.snapshotId !== null,
error: null,
})
await this.bestEffort('update.rollback.suspend.failed', () => this.activator.suspendDsh(), undefined, { taskId })
await this.transition('restoring-data', {
taskId, operation: 'update', progress: recoveryProgress(88), detail: 'recovery:deployment',
})
await this.activator.restoreDeployment(transaction.from, { resume: false })
if (transaction.snapshotId !== null) {
await this.transition('restoring-data', {
taskId, operation: 'update', progress: recoveryProgress(90), detail: 'recovery:snapshot',
})
let previousRestoreProgress
await this.snapshots.restore(transaction.snapshotId, {
onProgress: metrics => {
const metricProgress = metricPercentage(metrics.processedItems, metrics.totalItems)
?? metricPercentage(metrics.processedBytes, metrics.totalBytes)
if (metricProgress === previousRestoreProgress) return undefined
previousRestoreProgress = metricProgress
return this.transition('restoring-data', {
taskId,
operation: 'update',
progress: recoveryProgress(90 + Math.round((metricProgress ?? 0) * 0.05)),
detail: 'recovery:snapshot',
processedBytes: metrics.processedBytes ?? null,
totalBytes: metrics.totalBytes ?? null,
processedItems: metrics.processedItems ?? null,
totalItems: metrics.totalItems ?? null,
})
},
})
}
await this.transition('restoring-data', {
taskId,
operation: 'update',
progress: recoveryProgress(96),
detail: 'recovery:runtime',
processedBytes: null,
totalBytes: null,
processedItems: null,
totalItems: null,
})
await this.activator.resumeDsh()
const health = await this.reportHealth('restoring-data', {
taskId, operation: 'update', progress: recoveryProgress(99), detail: 'recovery:health',
})
if (health?.healthy === false) throw new Error('restored Deployment failed health checks')
await this.journal.transition('rolled-back', { error: message })
recoveryCompleted = true
await this.record('update.experimental.rollback.completed', { taskId })
} else {
await this.bestEffort('update.rollback.resume.failed', () => this.activator.resumeDsh(), undefined, { taskId })
await this.journal.transition('failed', { error: message })
}
} catch (rollbackError) {
await this.record('update.experimental.rollback.failed', { error: rollbackError, taskId })
const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : 'unknown error'
message = `${message}; rollback failed: ${rollbackMessage}`
}
}
await this.transition('failed', {
taskId,
error: message,
...(recoveryCompleted ? {
phase: failurePhase,
progress: failureState.progress,
detail: failureState.detail,
processedBytes: failureState.processedBytes,
totalBytes: failureState.totalBytes,
processedItems: failureState.processedItems,
totalItems: failureState.totalItems,
readyServices: failureState.readyServices,
totalServices: failureState.totalServices,
} : {}),
})
throw error
}
}
async recover() {
if (this.journal === undefined || this.snapshots === undefined) return undefined
return recoverInterruptedUpdate({ journal: this.journal, snapshots: this.snapshots, activator: this.activator })
}
async clearSatisfiedNotifications() {
if (this.automaticChecks === undefined) return
await this.automaticChecks.clearSatisfied(await this.activator.currentDeployment())
}
}
@@ -0,0 +1,142 @@
import { open, readFile, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { exactKeys, isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
const PHASES = new Set([
'planning', 'candidate-ready', 'suspended', 'snapshot-created', 'switched', 'probation',
'committed', 'restoring-data', 'rolled-back', 'failed',
])
const TRANSITIONS = new Map([
['planning', new Set(['candidate-ready', 'failed'])],
['candidate-ready', new Set(['suspended', 'failed'])],
['suspended', new Set(['snapshot-created', 'failed'])],
['snapshot-created', new Set(['switched', 'restoring-data', 'failed'])],
['switched', new Set(['probation', 'restoring-data'])],
['probation', new Set(['committed', 'restoring-data'])],
['restoring-data', new Set(['rolled-back', 'failed'])],
['committed', new Set(['restoring-data'])],
['rolled-back', new Set()],
['failed', new Set()],
])
function deployment(value, label, snapshot) {
const object = plainObject(value, label)
exactKeys(object, ['dsh', 'environment', 'runtime', ...(snapshot ? ['dataSnapshot', 'receiptTokens'] : [])], label)
for (const name of ['dsh', 'environment', 'runtime']) {
if (typeof object[name] !== 'string' || object[name] === '') throw new TrustError(`${label}.${name} is invalid`)
}
if (snapshot && object.dataSnapshot !== null && typeof object.dataSnapshot !== 'string') {
throw new TrustError(`${label}.dataSnapshot is invalid`)
}
if (snapshot && (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string'))) {
throw new TrustError(`${label}.receiptTokens is invalid`)
}
return Object.freeze({ ...object, ...(snapshot ? { receiptTokens: Object.freeze([...object.receiptTokens]) } : {}) })
}
function parseJournal(value) {
const object = plainObject(value, 'update journal')
exactKeys(object, [
'error', 'from', 'mode', 'phase', 'probationUntil', 'receiptTokens', 'schema',
'snapshotId', 'to', 'transactionId', 'updatedAt',
], 'update journal')
if (object.schema !== 1) throw new TrustError('update journal schema must be 1')
if (typeof object.transactionId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(object.transactionId)) {
throw new TrustError('update journal transaction ID is invalid')
}
if (!['stable', 'experimental'].includes(object.mode)) throw new TrustError('update journal mode is invalid')
if (!PHASES.has(object.phase)) throw new TrustError('update journal phase is invalid')
if (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string')) {
throw new TrustError('update journal receipt tokens are invalid')
}
for (const name of ['snapshotId', 'probationUntil', 'error']) {
if (object[name] !== null && typeof object[name] !== 'string') throw new TrustError(`update journal ${name} is invalid`)
}
if (object.probationUntil !== null) isoTimestamp(object.probationUntil, 'update journal probation deadline')
isoTimestamp(object.updatedAt, 'update journal update time')
return Object.freeze({
...object,
from: deployment(object.from, 'update journal from', true),
to: deployment(object.to, 'update journal to', false),
receiptTokens: Object.freeze([...object.receiptTokens]),
})
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
export class UpdateJournal {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async read() {
try { return parseJournal(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
if (error?.code === 'ENOENT') return undefined
throw error
}
}
begin(value) {
return this.exclusive(async () => {
const previous = await this.read()
if (previous !== undefined && !['committed', 'rolled-back', 'failed'].includes(previous.phase)) {
throw new TrustError('an update transaction journal already exists')
}
const journal = parseJournal({
schema: 1,
transactionId: value.transactionId,
mode: value.mode,
phase: 'planning',
from: value.from,
to: value.to,
snapshotId: null,
receiptTokens: [],
probationUntil: null,
error: null,
updatedAt: this.now().toISOString(),
})
await durableReplace(this.path, `${JSON.stringify(journal)}\n`)
return journal
})
}
transition(phase, fields = {}) {
return this.exclusive(async () => {
const current = await this.read()
if (current === undefined) throw new TrustError('update transaction journal does not exist')
if (!TRANSITIONS.get(current.phase)?.has(phase)) {
throw new TrustError(`update journal cannot transition from ${current.phase} to ${phase}`)
}
const next = parseJournal({ ...current, ...fields, phase, updatedAt: this.now().toISOString() })
if (next.transactionId !== current.transactionId || next.mode !== current.mode) {
throw new TrustError('update journal identity cannot change')
}
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
}
clear() {
return this.exclusive(async () => {
const current = await this.read()
if (current !== undefined && !['committed', 'rolled-back', 'failed'].includes(current.phase)) {
throw new TrustError('cannot clear a nonterminal update journal')
}
await rm(this.path, { force: true })
await syncDirectory(dirname(this.path)).catch(error => { if (error?.code !== 'ENOENT') throw error })
})
}
}
@@ -0,0 +1,275 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { cp, lstat, mkdir, readFile, rename, rm, symlink } from 'node:fs/promises'
import { join } from 'node:path'
import { buildRuntime } from '../../patch-manager/index.mjs'
import { reconcileSystemPlugins } from '../../plugin-manager/system.mjs'
import { artifactForReference, parseEnvironmentManifest } from '../../../../platform/lib/contracts.mjs'
import { deriveRecordId, parseDeploymentRecord } from '../../../../platform/lib/deployment-contracts.mjs'
import { hashTree } from '../../../../platform/lib/tree-hash.mjs'
async function exists(path) {
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
}
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolve(Buffer.concat(stdout).toString('utf8'))
: reject(new Error(Buffer.concat(stderr).toString('utf8'))))
})
}
const NPM_INSTALL_ATTEMPTS = 7
const NPM_INSTALL_RETRY_MS = 5_000
function retryableNpmInstall(error) {
return /(?:^|\n)npm error (?:code (?:ETARGET|E404|ENOVERSIONS)|notarget\b)/u.test(
error instanceof Error ? error.message : '',
)
}
async function verifyDirectory(path, expectedHash) {
const details = await lstat(path)
if (!details.isDirectory() || details.isSymbolicLink()) throw new Error(`Managed Store asset is not an immutable directory: ${path}`)
const sha256 = await hashTree(path)
if (expectedHash !== undefined && sha256 !== expectedHash) throw new Error(`Managed Store asset content conflicts at ${path}`)
return sha256
}
async function publish(staging, root, id) {
const sha256 = await hashTree(staging)
const destination = join(root, id)
try {
await rename(staging, destination)
} catch (error) {
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
await verifyDirectory(destination, sha256)
}
return Object.freeze({ id, sha256, path: destination })
}
function storeReference(kind, asset) {
return Object.freeze({ storage: 'store', kind, id: asset.id, sha256: asset.sha256 })
}
export class ManagedDeploymentBuilder {
constructor({
paths,
npmInstall = (installRoot, archivePath, { preferOnline }) => run('npm', [
'install', '--global', '--prefix', installRoot, '--omit=dev', '--ignore-scripts',
'--no-audit', '--no-fund', preferOnline ? '--prefer-online' : '--prefer-offline',
'--cache', paths.npmCacheRoot, archivePath,
]),
sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
this.paths = paths
this.npmInstall = npmInstall
this.sleep = sleep
}
async pristine(version, receipt) {
const id = `pristine-npm-${receipt.objectSha256}`
const destination = join(this.paths.pristineRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
const installRoot = join(this.paths.pristineRoot, `.install.${randomUUID()}.tmp`)
const staging = join(this.paths.pristineRoot, `.${id}.${randomUUID()}.tmp`)
await mkdir(installRoot, { recursive: false })
try {
const entries = (await run('tar', ['-tzf', receipt.path])).split('\n').filter(Boolean)
if (!entries.includes('package/package.json') || entries.some(entry => (
entry.startsWith('/') || !entry.startsWith('package/') || entry.split('/').includes('..')
))) throw new Error('official DSH archive contains an unsafe path')
for (let attempt = 1; attempt <= NPM_INSTALL_ATTEMPTS; attempt += 1) {
const attemptRoot = join(installRoot, `attempt-${String(attempt)}`)
await mkdir(attemptRoot, { recursive: false })
try {
const archivePath = join(attemptRoot, 'official-dsh.tgz')
await symlink(receipt.path, archivePath, 'file')
await this.npmInstall(attemptRoot, archivePath, { preferOnline: attempt > 1 })
const installed = join(attemptRoot, 'lib', 'node_modules', '@deepseek-ai', 'dsh')
await rename(installed, staging)
break
} catch (error) {
if (attempt === NPM_INSTALL_ATTEMPTS || !retryableNpmInstall(error)) throw error
await this.sleep(Math.min(30_000, NPM_INSTALL_RETRY_MS * (2 ** (attempt - 1))))
} finally {
await rm(attemptRoot, { recursive: true, force: true })
}
}
const metadata = JSON.parse(await readFile(join(staging, 'package.json'), 'utf8'))
if (metadata.name !== '@deepseek-ai/dsh' || metadata.version !== version) {
throw new Error('official DSH package metadata differs from its requested version')
}
return await publish(staging, this.paths.pristineRoot, id)
} finally {
await rm(installRoot, { recursive: true, force: true })
await rm(staging, { recursive: true, force: true })
}
}
async environment(prepared) {
const manifest = prepared.environment.manifest
const identity = {
schema: 1,
version: manifest.version,
targetSequence: prepared.stable.targetSequence,
manifest: prepared.environment.manifestReceipt.objectSha256,
artifacts: manifest.artifacts.map(artifact => ({ id: artifact.id, sha256: artifact.sha256 })),
}
const id = deriveRecordId('environment', identity)
const destination = join(this.paths.environmentsRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
const staging = join(this.paths.environmentsRoot, `.${id}.${randomUUID()}.tmp`)
await mkdir(join(staging, 'artifacts'), { recursive: true })
try {
await cp(prepared.paths.get(prepared.stable.desired.environment.manifestArtifactId), join(staging, 'environment.manifest.json'))
for (const descriptor of manifest.artifacts) {
await cp(prepared.paths.get(descriptor.id), join(staging, 'artifacts', descriptor.id))
}
return await publish(staging, this.paths.environmentsRoot, id)
} finally {
await rm(staging, { recursive: true, force: true })
}
}
async runtime(prepared, pristine, environment, { onProgress = async () => {} } = {}) {
const patches = prepared.environment.manifest.patches.map(reference => {
const artifact = artifactForReference(prepared.environment.manifest, reference)
return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 }
})
const id = deriveRecordId('runtime', {
schema: 1,
pristine: pristine.sha256,
environment: environment.sha256,
patches,
})
const destination = join(this.paths.runtimesRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
await buildRuntime({
pristineRoot: pristine.path,
versionsRoot: this.paths.runtimesRoot,
runtimeId: id,
patchPaths: patches.map(patch => prepared.paths.get(patch.artifactId)),
onProgress,
})
return Object.freeze({ id, sha256: await verifyDirectory(destination), path: destination })
}
async systemPlugins(prepared, environment) {
const plugins = prepared.environment.manifest.systemPlugins.map(reference => {
const artifact = artifactForReference(prepared.environment.manifest, reference)
return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 }
})
const id = deriveRecordId('system-plugins', {
schema: 1,
environment: environment.sha256,
plugins,
})
const destination = join(this.paths.systemPluginsRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
const buildRoot = join(this.paths.systemPluginsRoot, `.build.${randomUUID()}.tmp`)
try {
const built = await reconcileSystemPlugins({
root: buildRoot,
environmentVersion: id,
plugins: prepared.environment.manifest.systemPlugins,
artifactPath: reference => prepared.paths.get(artifactForReference(prepared.environment.manifest, reference).id),
})
return await publish(built, this.paths.systemPluginsRoot, id)
} finally {
await rm(buildRoot, { recursive: true, force: true })
}
}
async buildStable(prepared, { onProgress = async () => {} } = {}) {
const pristine = await this.pristine(prepared.dsh.version, prepared.dsh.receipt)
await onProgress(80)
const environment = await this.environment(prepared)
await onProgress(82)
const runtime = await this.runtime(prepared, pristine, environment, {
onProgress: metrics => onProgress({ stage: 'runtime', ...metrics }),
})
await onProgress(87)
const systemPlugins = await this.systemPlugins(prepared, environment)
await onProgress(89)
const content = {
schema: 1,
authority: 'stable',
targetSequence: prepared.stable.targetSequence,
dshVersion: prepared.dsh.version,
environmentVersion: prepared.environment.manifest.version,
environment: storeReference('environment', environment),
pristine: storeReference('pristine', pristine),
runtime: storeReference('runtime', runtime),
systemPlugins: storeReference('system-plugins', systemPlugins),
receiptTokens: [...prepared.receiptTokens],
snapshotId: null,
}
return Object.freeze({
record: parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) }),
assets: Object.freeze({ pristine, environment, runtime, systemPlugins }),
})
}
async buildExperimental(prepared, currentValue, receiptTokens, { targetSequence }) {
const current = parseDeploymentRecord(currentValue)
const pristine = await this.pristine(prepared.version, prepared.receipt)
const environmentRoot = join(this.paths.viewsRoot, 'environment')
const manifest = parseEnvironmentManifest(await readFile(join(environmentRoot, 'environment.manifest.json')))
if (manifest.version !== current.environmentVersion) throw new Error('current Environment view differs from its Deployment Record')
const patches = manifest.patches.map(reference => {
const artifact = artifactForReference(manifest, reference)
return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 }
})
const runtimeId = deriveRecordId('runtime', {
schema: 1,
pristine: pristine.sha256,
environment: current.environment.sha256,
patches,
})
const runtimePath = join(this.paths.runtimesRoot, runtimeId)
if (!await exists(runtimePath)) {
await buildRuntime({
pristineRoot: pristine.path,
versionsRoot: this.paths.runtimesRoot,
runtimeId,
patchPaths: patches.map(patch => join(environmentRoot, 'artifacts', patch.artifactId)),
})
}
const runtime = Object.freeze({ id: runtimeId, sha256: await verifyDirectory(runtimePath), path: runtimePath })
const content = {
schema: 1,
authority: 'experimental',
targetSequence,
dshVersion: prepared.version,
environmentVersion: current.environmentVersion,
environment: current.environment,
pristine: storeReference('pristine', pristine),
runtime: storeReference('runtime', runtime),
systemPlugins: current.systemPlugins,
receiptTokens: [...receiptTokens],
snapshotId: null,
}
const record = parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) })
return Object.freeze({ record, runtime })
}
}
@@ -0,0 +1,180 @@
import { setTimeout as delay } from 'node:timers/promises'
import { parseStable } from '../../../../platform/lib/contracts.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
export class MetadataUnavailableError extends Error {
constructor() {
super('signed update metadata has not been published')
this.name = 'MetadataUnavailableError'
this.code = 'METADATA_UNAVAILABLE'
}
}
async function responseBytes(response, label, maxBytes = 10 * 1024 * 1024) {
if (!response.ok) throw new HttpResponseError(label, response.status)
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.byteLength > maxBytes) throw new Error(`${label} exceeds the download limit`)
return bytes
}
class HttpResponseError extends Error {
constructor(label, status) {
super(`${label} returned HTTP ${String(status)}`)
this.name = 'HttpResponseError'
this.status = status
}
}
function retryableRequest(error) {
return !(error instanceof HttpResponseError)
|| error.status === 408
|| error.status === 429
|| error.status >= 500
}
function exhaustedRequest(label, error, attempts) {
const timeout = error?.name === 'TimeoutError'
const detail = error instanceof Error && error.message !== '' ? `: ${error.message}` : ''
const wrapped = new Error(
timeout
? `${label} timed out after ${String(attempts)} attempts`
: `${label} failed after ${String(attempts)} attempts${detail}`,
{ cause: error },
)
wrapped.name = timeout ? 'TimeoutError' : 'Error'
if (error?.code !== undefined) wrapped.code = error.code
wrapped.requestExhausted = true
return wrapped
}
function timeoutForAttempt(initial, retry, attempt) {
return attempt === 1 ? initial : retry
}
export class MetadataClient {
constructor({
baseUrl,
trust,
fetchImpl = fetch,
attempts = 2,
retryMs = 250,
requestTimeoutMs = 5_000,
retryRequestTimeoutMs = requestTimeoutMs,
}) {
this.baseUrl = new URL(baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
if (this.baseUrl.protocol !== 'https:' && this.baseUrl.hostname !== '127.0.0.1' && this.baseUrl.hostname !== 'localhost') {
throw new Error('update metadata URL must use HTTPS')
}
this.trust = trust
this.fetchImpl = fetchImpl
this.attempts = attempts
this.retryMs = retryMs
this.requestTimeoutMs = requestTimeoutMs
this.retryRequestTimeoutMs = retryRequestTimeoutMs
}
async file(name) {
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
const response = await this.fetchImpl(new URL(name, this.baseUrl), {
signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)),
})
if (response.status === 404) throw new MetadataUnavailableError()
return await responseBytes(response, name)
} catch (error) {
if (error instanceof MetadataUnavailableError) throw error
if (!retryableRequest(error)) throw error
lastError = error
if (attempt < this.attempts) await delay(this.retryMs)
}
}
throw exhaustedRequest(name, lastError, this.attempts)
}
async verifiedPair(payloadName, signatureName, accept) {
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
const [payload, signatureBytes] = await Promise.all([
this.file(payloadName), this.file(signatureName),
])
await accept(payload, JSON.parse(signatureBytes.toString('utf8')))
return payload
} catch (error) {
if (error instanceof MetadataUnavailableError
|| error instanceof HttpResponseError
|| error?.requestExhausted === true) throw error
lastError = error
if (attempt < this.attempts) await delay(this.retryMs)
}
}
throw lastError
}
async check() {
await this.verifiedPair('keyring.json', 'keyring.sig.json', (bytes, signature) => (
this.trust.acceptKeyring(bytes, signature)
))
const stable = await this.verifiedPair('stable.json', 'stable.sig.json', (bytes, signature) => (
this.trust.acceptTarget(bytes, signature)
))
return Object.freeze({ bytes: stable, value: parseStable(stable) })
}
}
export class NpmRegistryClient {
constructor({
fetchImpl = fetch,
registry = 'https://registry.npmjs.org/',
packageName = '@deepseek-ai/dsh',
attempts = 2,
retryMs = 250,
requestTimeoutMs = 5_000,
retryRequestTimeoutMs = requestTimeoutMs,
}) {
this.fetchImpl = fetchImpl
this.registry = registry
this.packageName = packageName
this.attempts = attempts
this.retryMs = retryMs
this.requestTimeoutMs = requestTimeoutMs
this.retryRequestTimeoutMs = retryRequestTimeoutMs
}
async discover(policy = {}) {
const registry = policy.registry ?? this.registry
const packageName = policy.packageName ?? this.packageName
const packagePath = encodeURIComponent(packageName)
let bytes
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
const response = await this.fetchImpl(new URL(packagePath, registry), {
headers: { accept: 'application/vnd.npm.install-v1+json' },
redirect: 'error',
signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)),
})
bytes = await responseBytes(response, 'npm packument', 20 * 1024 * 1024)
break
} catch (error) {
if (!retryableRequest(error)) throw error
lastError = error
if (attempt < this.attempts) await delay(this.retryMs)
}
}
if (bytes === undefined) throw exhaustedRequest('npm packument', lastError, this.attempts)
let packument
try { packument = JSON.parse(bytes.toString('utf8')) } catch { throw new Error('npm packument is not valid JSON') }
const latest = packument?.['dist-tags']?.latest
const version = typeof latest === 'string' ? packument?.versions?.[latest]?.version : undefined
if (version !== latest) throw new Error('npm packument has no coherent latest DSH version')
return Object.freeze({ version })
}
async latest(stable) {
const found = await this.discover(stable.officialDshPolicy)
const version = found.version
return compareDshVersions(version, stable.desired.dsh.version) > 0 ? Object.freeze({ version }) : null
}
}
@@ -0,0 +1,192 @@
import { constants } from 'node:fs'
import { createWriteStream } from 'node:fs'
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { setTimeout as delay } from 'node:timers/promises'
import {
OFFICIAL_DSH_METADATA_LIMIT,
OFFICIAL_DSH_PACKAGE,
OFFICIAL_DSH_TARBALL_LIMIT,
officialDshMetadataUrl,
officialDshTarballUrl,
} from '../../../../platform/lib/official-dsh-contracts.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
function exactResponse(response, url, label) {
if (response.redirected || (response.url !== undefined && response.url !== '' && response.url !== url.href)) {
throw new Error(`${label} changed origin or redirected`)
}
if (!response.ok) {
const error = new Error(`${label} returned HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
return response
}
function responseLength(response) {
const header = response.headers?.get?.('content-length')
if (header === null || header === undefined || header === '') return null
const value = Number(header)
return Number.isSafeInteger(value) && value >= 0 ? value : null
}
async function boundedBytes(response, label, maxBytes) {
const declared = responseLength(response)
if (declared !== null && declared > maxBytes) throw new Error(`${label} exceeds the download limit`)
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.byteLength > maxBytes) throw new Error(`${label} exceeds the download limit`)
return bytes
}
async function atomicBytes(path, bytes) {
const temporary = `${path}.${randomUUID()}.tmp`
try {
const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
try {
await handle.writeFile(bytes)
await handle.sync()
} finally {
await handle.close()
}
await rename(temporary, path)
} catch (error) {
await rm(temporary, { force: true }).catch(() => {})
throw error
}
}
async function streamToFile(response, path, label, maxBytes, onProgress) {
if (response.body === null || response.body === undefined) throw new Error(`${label} has no response body`)
const declared = responseLength(response)
if (declared !== null && declared > maxBytes) throw new Error(`${label} exceeds the download limit`)
const temporary = `${path}.${randomUUID()}.tmp`
let processedBytes = 0
try {
const meter = new Transform({
transform(chunk, _encoding, callback) {
processedBytes += chunk.byteLength
if (processedBytes > maxBytes) {
callback(new Error(`${label} exceeds the download limit`))
return
}
Promise.resolve(onProgress({ processedBytes, totalBytes: Number.isFinite(declared) ? declared : null }))
.then(() => callback(null, chunk), callback)
},
})
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 }))
const handle = await open(temporary, constants.O_RDWR)
try { await handle.sync() } finally { await handle.close() }
await rename(temporary, path)
return { processedBytes, totalBytes: declared }
} catch (error) {
await rm(temporary, { force: true }).catch(() => {})
throw error
}
}
export class OfficialDshDownloader {
constructor({ fetchImpl = fetch, attempts = 3, retryMs = 250 }) {
this.fetchImpl = fetchImpl
this.attempts = attempts
this.retryMs = retryMs
}
async retry(operation) {
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
return await operation(attempt)
} catch (error) {
lastError = error
const status = error?.status
if (error?.retryable === false
|| (Number.isInteger(status) && status < 500 && status !== 408 && status !== 429)
|| attempt === this.attempts) {
throw error
}
await delay(this.retryMs)
}
}
throw lastError
}
async download(version, root, { onProgress = async () => {} } = {}) {
compareDshVersions(version, version)
await mkdir(root, { recursive: true })
const metadataUrl = officialDshMetadataUrl()
const metadata = await this.retry(async () => {
const metadataResponse = exactResponse(await this.fetchImpl(metadataUrl, {
headers: { accept: 'application/vnd.npm.install-v1+json', 'accept-encoding': 'identity' },
redirect: 'error',
signal: AbortSignal.timeout(15_000),
}), metadataUrl, 'official DSH metadata')
return boundedBytes(metadataResponse, 'official DSH metadata', OFFICIAL_DSH_METADATA_LIMIT)
})
let packument
try { packument = JSON.parse(metadata.toString('utf8')) } catch { throw new Error('official DSH metadata is not valid JSON') }
const selected = packument?.versions?.[version]
if (selected?.name !== OFFICIAL_DSH_PACKAGE || selected?.version !== version) {
throw new Error('official DSH metadata has no coherent requested version')
}
const tarballUrl = officialDshTarballUrl(version)
if (selected?.dist?.tarball !== tarballUrl.href) throw new Error('official DSH tarball URL is not canonical')
const metadataPath = join(root, 'official-dsh-packument.json')
const tarballPath = join(root, 'official-dsh.tgz')
await atomicBytes(metadataPath, metadata)
try {
let reportedBytes = metadata.byteLength
const result = await this.retry(async () => {
const tarballResponse = exactResponse(await this.fetchImpl(tarballUrl, {
headers: { accept: 'application/octet-stream', 'accept-encoding': 'identity' },
redirect: 'error',
}), tarballUrl, 'official DSH tarball')
if (tarballResponse.headers?.get?.('content-encoding')) {
const error = new Error('official DSH tarball must not use content encoding')
error.retryable = false
throw error
}
const declaredTarball = responseLength(tarballResponse)
const knownTotal = declaredTarball === null ? null : metadata.byteLength + declaredTarball
await onProgress({
processedBytes: reportedBytes,
totalBytes: knownTotal,
processedItems: 1,
totalItems: 2,
detail: 'official-dsh-metadata',
})
return streamToFile(
tarballResponse,
tarballPath,
'official DSH tarball',
OFFICIAL_DSH_TARBALL_LIMIT,
metrics => {
reportedBytes = Math.max(reportedBytes, metadata.byteLength + metrics.processedBytes)
return onProgress({
processedBytes: reportedBytes,
totalBytes: knownTotal,
processedItems: 1,
totalItems: 2,
detail: 'official-dsh-tarball',
})
},
)
})
await onProgress({
processedBytes: Math.max(reportedBytes, metadata.byteLength + result.processedBytes),
totalBytes: result.totalBytes === null ? null : metadata.byteLength + result.totalBytes,
processedItems: 2,
totalItems: 2,
detail: 'official-dsh-tarball',
})
return Object.freeze({ metadataPath, tarballPath })
} catch (error) {
await rm(metadataPath, { force: true }).catch(() => {})
await rm(tarballPath, { force: true }).catch(() => {})
throw error
}
}
}
@@ -0,0 +1,206 @@
import { createHash, randomUUID } from 'node:crypto'
import { createWriteStream } from 'node:fs'
import { link, mkdir, open, readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { parseBootstrapManifest, parseEnvironmentManifest } from '../../../../platform/lib/contracts.mjs'
import { OfficialDshDownloader } from './official-dsh-download.mjs'
async function verifyExisting(path, descriptor) {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
try {
for await (const chunk of handle.createReadStream({ autoClose: false })) {
size += chunk.byteLength
if (size > descriptor.size) return false
hash.update(chunk)
}
} finally {
await handle.close()
}
return size === descriptor.size && hash.digest('hex') === descriptor.sha256
}
async function download(descriptor, destination, fetchImpl, onChunk) {
let lastError
let reportedBytes = 0
for (let attempt = 1; attempt <= 3; attempt += 1) {
const temporary = `${destination}.${randomUUID()}.tmp`
try {
const response = await fetchImpl(descriptor.url, {
headers: { 'accept-encoding': 'identity' },
})
if (!response.ok) {
const error = new Error(`Artifact ${descriptor.id} returned HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
if (response.body === null || response.body === undefined) throw new Error(`Artifact ${descriptor.id} has no response body`)
const contentEncoding = response.headers?.get?.('content-encoding')
if (contentEncoding !== null && contentEncoding !== undefined && contentEncoding !== '') {
const error = new Error(`Artifact ${descriptor.id} must not use content encoding`)
error.retryable = false
throw error
}
const declaredHeader = response.headers?.get?.('content-length')
if (declaredHeader !== null && declaredHeader !== undefined && declaredHeader !== '') {
const declared = Number(declaredHeader)
if (!Number.isSafeInteger(declared) || declared !== descriptor.size) {
throw new Error(`Artifact ${descriptor.id} does not match stable metadata`)
}
}
const hash = createHash('sha256')
let size = 0
const meter = new Transform({
transform(chunk, _encoding, callback) {
size += chunk.byteLength
if (size > descriptor.size) {
callback(new Error(`Artifact ${descriptor.id} does not match stable metadata`))
return
}
hash.update(chunk)
reportedBytes = Math.max(reportedBytes, size)
Promise.resolve(onChunk(reportedBytes)).then(() => callback(null, chunk), callback)
},
})
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 }))
if (size !== descriptor.size || hash.digest('hex') !== descriptor.sha256) {
throw new Error(`Artifact ${descriptor.id} does not match stable metadata`)
}
const handle = await open(temporary, 'r+')
try { await handle.sync() } finally { await handle.close() }
try {
await link(temporary, destination)
await rm(temporary)
} catch (error) {
if (error?.code !== 'EEXIST') throw error
if (!await verifyExisting(destination, descriptor)) {
throw new Error(`existing Artifact ${descriptor.id} differs from the target`)
}
await rm(temporary, { force: true })
}
return destination
} catch (error) {
await rm(temporary, { force: true }).catch(() => {})
lastError = error
const status = error?.status
if (error?.retryable === false || (Number.isInteger(status) && status < 500 && status !== 408 && status !== 429) || attempt === 3) throw error
await delay(250)
}
}
throw lastError
}
export class TargetPreparer {
constructor({ untrustedRoot, trust, fetchImpl = fetch, officialDsh = new OfficialDshDownloader({ fetchImpl }) }) {
this.untrustedRoot = untrustedRoot
this.trust = trust
this.fetchImpl = fetchImpl
this.officialDsh = officialDsh
}
async prepare(stable, { onProgress = async () => {} } = {}) {
const taskRoot = join(this.untrustedRoot, `target-${String(stable.targetSequence)}`)
await mkdir(taskRoot, { recursive: true })
const downloads = new Map()
const paths = new Map()
const receipts = new Map()
const authorities = new Map()
let downloadedBytes = 0
let completedDownloads = 0
let totalBytes = stable.artifacts.reduce((total, descriptor) => total + descriptor.size, 0)
let totalItems = stable.artifacts.length
const importDescriptor = async (descriptor, parentReceipt = null) => {
const authority = parentReceipt ?? 'stable'
if (authorities.has(descriptor.id) && authorities.get(descriptor.id) !== authority) {
throw new Error(`Artifact ID ${descriptor.id} is reused across authorities`)
}
authorities.set(descriptor.id, authority)
if (!downloads.has(descriptor.id) && !stable.artifacts.some(root => root.id === descriptor.id)) {
totalBytes += descriptor.size
totalItems += 1
}
if (!downloads.has(descriptor.id)) {
const downloadBase = downloadedBytes
const downloaded = await download(
descriptor,
join(taskRoot, descriptor.id),
this.fetchImpl,
async processed => {
await onProgress({
processedBytes: downloadBase + processed,
totalBytes,
processedItems: completedDownloads,
totalItems,
})
},
)
downloadedBytes = downloadBase + descriptor.size
downloads.set(descriptor.id, downloaded)
completedDownloads += 1
await onProgress({ processedBytes: downloadedBytes, totalBytes, processedItems: completedDownloads, totalItems })
}
if (!receipts.has(descriptor.id)) {
const receipt = await this.trust.importArtifact(
descriptor.id,
downloads.get(descriptor.id),
parentReceipt,
)
receipts.set(descriptor.id, receipt)
paths.set(descriptor.id, receipt.path)
}
return receipts.get(descriptor.id)
}
for (const descriptor of stable.artifacts) await importDescriptor(descriptor)
const prepareManifest = async (reference, parser) => {
const manifestReceipt = receipts.get(reference.manifestArtifactId)
const signatureReceipt = receipts.get(reference.signatureArtifactId)
await this.trust.acceptManifest(manifestReceipt.token, signatureReceipt.token)
const manifest = parser(await readFile(paths.get(reference.manifestArtifactId)))
for (const descriptor of manifest.artifacts) await importDescriptor(descriptor, manifestReceipt.token)
return { manifest, manifestReceipt }
}
const bootstrap = await prepareManifest(stable.desired.bootstrap, parseBootstrapManifest)
const environment = await prepareManifest(stable.desired.environment, parseEnvironmentManifest)
const dshBaseBytes = downloadedBytes
const dshBaseItems = completedDownloads
const dshSources = await this.officialDsh.download(
stable.desired.dsh.version,
join(taskRoot, 'official-dsh'),
{
onProgress: metrics => onProgress({
processedBytes: dshBaseBytes + metrics.processedBytes,
totalBytes: metrics.totalBytes === null ? null : totalBytes + metrics.totalBytes,
processedItems: dshBaseItems + metrics.processedItems,
totalItems: totalItems + metrics.totalItems,
detail: metrics.detail,
}),
},
)
const dshReceipt = await this.trust.ensureOfficialDsh(
stable.desired.dsh.version,
dshSources.metadataPath,
dshSources.tarballPath,
)
return Object.freeze({
stable,
paths,
receipts,
bootstrap,
environment,
dsh: Object.freeze({ version: stable.desired.dsh.version, receipt: dshReceipt }),
receiptTokens: Object.freeze([...receipts.values(), dshReceipt].map(receipt => receipt.token)),
})
}
async prepareExperimental(candidate, { onProgress = async () => {} } = {}) {
const taskRoot = join(this.untrustedRoot, `experimental-${candidate.version}`)
const sources = await this.officialDsh.download(candidate.version, taskRoot, { onProgress })
const receipt = await this.trust.ensureOfficialDsh(candidate.version, sources.metadataPath, sources.tarballPath)
return Object.freeze({ version: candidate.version, receipt, receiptTokens: Object.freeze([receipt.token]) })
}
}
@@ -0,0 +1,82 @@
const TERMINAL_PHASES = new Set(['committed', 'rolled-back', 'failed'])
const PRE_SWITCH_PHASES = new Set(['planning', 'candidate-ready', 'suspended'])
export async function recoverInterruptedUpdate({ journal, snapshots, activator, resume = true }) {
const transaction = await journal.read()
if (transaction === undefined || TERMINAL_PHASES.has(transaction.phase)) return transaction
if (PRE_SWITCH_PHASES.has(transaction.phase)) {
if (resume) await activator.resumeDsh().catch(() => {})
return journal.transition('failed', { error: 'interrupted before Runtime switch' })
}
const restoring = transaction.phase === 'restoring-data'
? transaction
: await journal.transition('restoring-data', { error: 'recovering interrupted update' })
if (resume) await activator.suspendDsh().catch(() => {})
await activator.restoreDeployment(restoring.from, { resume: false })
if (restoring.snapshotId !== null) await snapshots.restore(restoring.snapshotId)
if (resume) await activator.resumeDsh()
return journal.transition('rolled-back', { error: 'recovered interrupted update' })
}
export async function reconcileRecoveredState({ journal, state }) {
const [transaction, persisted] = await Promise.all([journal.read(), state.read()])
const sameTask = transaction !== undefined && persisted.taskId === transaction.transactionId
// A metadata check has no journal and cannot be resumed after Management
// restarts. Leaving this state behind would keep every UI showing a spinner
// and could make startup mistake the check for an interrupted update.
if (persisted.status === 'checking' && !sameTask) {
return {
transaction,
persisted: await state.write('idle', {
taskId: null,
progress: 0,
error: null,
checkSource: null,
}),
}
}
if (sameTask && !['idle', 'success', 'failed'].includes(persisted.status)) {
if (transaction.phase === 'committed') {
return { transaction, persisted: await state.write('success', { progress: 100, error: null }) }
}
if (['rolled-back', 'failed'].includes(transaction.phase)) {
return {
transaction,
persisted: await state.write('failed', { error: transaction.error ?? 'interrupted update was not committed' }),
}
}
}
return { transaction, persisted }
}
export function resumeInterruptedReconcile({ coordinator, persisted, report, audit }) {
const task = coordinator.startReconcile()
const context = {
taskId: task.taskId,
interruptedTaskId: persisted.taskId ?? null,
}
const started = Promise.resolve()
.then(() => persisted.taskId === null || persisted.taskId === undefined
? undefined
: audit('update.failed', {
taskId: persisted.taskId,
outcome: 'interrupted',
resumedByTaskId: task.taskId,
level: 'warning',
}))
.then(() => report('update.resume.started', context))
.catch(() => {})
void task.completion.then(
async () => {
await started
await report('update.resume.completed', context)
},
async error => {
await started
await report('update.resume.failed', { ...context, error })
},
).catch(() => {})
return task
}
@@ -0,0 +1,143 @@
import { createHash } from 'node:crypto'
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
import { TrustError } from '../../../../platform/lib/validation.mjs'
function deploymentIdentity(deployment) {
return {
dsh: deployment.dsh,
environment: deployment.environment,
runtime: deployment.runtime,
receiptTokens: [...deployment.receiptTokens].sort(),
}
}
function recordIdentity(record) {
return deploymentIdentity({
dsh: record.dshVersion,
environment: record.environmentVersion,
runtime: record.id,
receiptTokens: record.receiptTokens,
})
}
function recordMatchesDeployment(record, deployment) {
return record.dshVersion === deployment.dsh
&& record.environmentVersion === deployment.environment
&& [...record.receiptTokens].sort().join('\0') === [...deployment.receiptTokens].sort().join('\0')
}
function sameRecordContent(left, right) {
return left.authority === right.authority
&& left.targetSequence === right.targetSequence
&& left.dshVersion === right.dshVersion
&& left.environmentVersion === right.environmentVersion
&& left.environment.sha256 === right.environment.sha256
&& left.pristine.sha256 === right.pristine.sha256
&& left.runtime.sha256 === right.runtime.sha256
&& left.systemPlugins.sha256 === right.systemPlugins.sha256
}
function bindPlan(value) {
return Object.freeze({
...value,
planId: createHash('sha256').update(canonicalJson(value)).digest('hex'),
})
}
export class CompleteStateRecovery {
constructor({ journal, snapshots, activator }) {
this.journal = journal
this.snapshots = snapshots
this.activator = activator
}
async plan() {
const transaction = await this.journal.read()
if (transaction?.phase === 'committed' && transaction.snapshotId !== null) {
const current = await this.activator.currentDeployment()
if (
current.dsh === transaction.to.dsh
&& current.environment === transaction.to.environment
&& current.runtime === transaction.to.runtime
) {
let previous = deploymentIdentity(transaction.from)
if (this.activator.rollbackDeployments !== undefined) {
const slots = await this.activator.rollbackDeployments()
if (
slots.current?.id !== current.runtime
|| slots.previous === null
|| !recordMatchesDeployment(slots.previous, transaction.from)
) return null
previous = recordIdentity(slots.previous)
}
const snapshot = await this.snapshots.inspect(transaction.snapshotId)
if (
snapshot.runtimeId !== transaction.from.runtime
|| snapshot.environmentVersion !== transaction.from.environment
|| snapshot.dshVersion !== transaction.from.dsh
) throw new TrustError('rollback snapshot does not describe the previous deployment')
return bindPlan({
transactionId: transaction.transactionId,
mode: transaction.mode,
current: deploymentIdentity(current),
previous,
snapshot: {
id: snapshot.id,
createdAt: snapshot.createdAt,
sha256: snapshot.archiveSha256,
size: snapshot.archiveSize,
},
})
}
}
if (this.activator.rollbackDeployments === undefined) return null
const { current, previous } = await this.activator.rollbackDeployments()
if (current === null || previous === null) return null
if (sameRecordContent(current, previous)) return null
return bindPlan({
transactionId: null,
mode: 'stable',
current: recordIdentity(current),
previous: recordIdentity(previous),
snapshot: null,
})
}
async restore(planId, {
confirmDataLoss = false,
requireConfirmation = false,
onProgress = async () => {},
} = {}) {
if (requireConfirmation && confirmDataLoss !== true) {
throw new TrustError('return to Stable requires explicit data-loss confirmation')
}
const plan = await this.plan()
if (plan === null || plan.planId !== planId) throw new TrustError('rollback plan is stale or unavailable')
if (requireConfirmation && plan.snapshot === null) {
throw new TrustError('return to Stable requires a verified data snapshot')
}
if (plan.snapshot === null) {
await onProgress('switching', 35, {})
await this.activator.rollback(plan.previous.runtime)
await onProgress('verifying', 90, {})
return Object.freeze({ status: 'rolled-back', transactionId: null })
}
let transaction = await this.journal.transition('restoring-data', { error: 'manual complete-state rollback' })
await onProgress('stopping', 15, {})
await this.activator.suspendDsh()
try {
await onProgress('switching', 35, {})
await this.activator.restoreDeployment({ ...transaction.from, runtime: plan.previous.runtime }, { resume: false })
await onProgress('restoring-data', 65, {})
await this.snapshots.restore(transaction.snapshotId, {
onProgress: metrics => onProgress('restoring-data', 65, metrics),
})
await onProgress('verifying', 85, {})
await this.activator.resumeDsh()
transaction = await this.journal.transition('rolled-back', { error: 'manual complete-state rollback completed' })
return Object.freeze({ status: 'rolled-back', transactionId: transaction.transactionId })
} catch (error) {
throw error
}
}
}
@@ -0,0 +1,55 @@
export class UpdateScheduler {
constructor({
check,
intervalSeconds = 21_600,
onError = async () => {},
random = Math.random,
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds < 60) throw new Error('update interval must be at least 60 seconds')
this.check = check
this.onError = onError
this.intervalMs = intervalSeconds * 1_000
this.random = random
this.setTimer = setTimer
this.clearTimer = clearTimer
this.timer = undefined
this.enabled = false
}
schedule() {
const jitter = 0.9 + this.random() * 0.2
const timer = this.setTimer(async () => {
if (this.timer !== timer) return
this.timer = undefined
try {
await this.check()
} catch (error) {
await Promise.resolve().then(() => this.onError(error)).catch(() => {})
}
if (this.enabled && this.timer === undefined) this.schedule()
}, Math.round(this.intervalMs * jitter))
this.timer = timer
timer.unref?.()
}
start() {
this.enabled = true
if (this.timer === undefined) this.schedule()
}
configure({ enabled, intervalSeconds }) {
if (typeof enabled !== 'boolean') throw new Error('automatic check enabled must be boolean')
if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds < 60) throw new Error('update interval must be at least 60 seconds')
this.stop()
this.intervalMs = intervalSeconds * 1_000
if (enabled) this.start()
}
stop() {
this.enabled = false
if (this.timer !== undefined) this.clearTimer(this.timer)
this.timer = undefined
}
}
@@ -0,0 +1,29 @@
export class SnapshotClient {
constructor(api, scope) {
if (!['dsh', 'user-plugin'].includes(scope)) throw new Error('snapshot scope is invalid')
this.api = api
this.scope = scope
}
async call(operation, body, onProgress) {
const response = await this.api.request('POST', `/v1/${this.scope}/${operation}`, body)
if (response.progress !== null && onProgress !== undefined) await onProgress(response.progress)
return response.result
}
create(value = undefined) {
if (this.scope === 'dsh') {
const { onProgress, ...body } = value
return this.call('create', body, onProgress)
}
return this.call('create', { id: value })
}
inspect(id) { return this.call('inspect', { id }) }
restore(id, { onProgress } = {}) {
return this.call('restore', { id }, onProgress)
}
remove(id) { return this.call('remove', { id }) }
}
@@ -0,0 +1,199 @@
import { spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
function validId(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
throw new TrustError('snapshot ID is invalid')
}
return value
}
function version(value, label) {
if (typeof value !== 'string' || value === '' || value.length > 128 || /[\0/\\]/.test(value)) {
throw new TrustError(`${label} is invalid`)
}
return value
}
function parseManifest(bytes) {
const value = parseJsonDocument(bytes, 'snapshot manifest')
exactKeys(value, [
'archiveSha256', 'archiveSize', 'createdAt', 'dshVersion', 'environmentVersion',
'id', 'runtimeId', 'schema', 'source',
], 'snapshot manifest')
if (value.schema !== 1) throw new TrustError('snapshot manifest schema must be 1')
validId(value.id)
if (value.source !== '/data/dsh') throw new TrustError('snapshot source is invalid')
if (typeof value.archiveSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.archiveSha256)) {
throw new TrustError('snapshot archive SHA-256 is invalid')
}
if (!Number.isSafeInteger(value.archiveSize) || value.archiveSize < 0) {
throw new TrustError('snapshot archive size is invalid')
}
isoTimestamp(value.createdAt, 'snapshot creation time')
version(value.runtimeId, 'snapshot runtime ID')
version(value.environmentVersion, 'snapshot Environment version')
version(value.dshVersion, 'snapshot DSH version')
return Object.freeze(value)
}
function run(command, args) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolveRun(Buffer.concat(stdout).toString('utf8'))
: reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`)))
})
}
async function hashFile(path) {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
try {
for await (const chunk of handle.createReadStream({ autoClose: false })) {
hash.update(chunk)
size += chunk.byteLength
}
} finally {
await handle.close()
}
return { sha256: hash.digest('hex'), size }
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
async function countTree(path) {
const details = await lstat(path)
if (!details.isDirectory() || details.isSymbolicLink()) return 1
let count = 0
for (const entry of await readdir(path)) count += await countTree(join(path, entry))
return count
}
export class PersistentStateSnapshots {
constructor({ root, sourceRoot = '/data/dsh', now = () => new Date(), runImpl = run }) {
this.root = resolve(root)
this.sourceRoot = resolve(sourceRoot)
this.now = now
this.run = runImpl
}
path(id) {
return join(this.root, 'versions', validId(id))
}
async create({ id = randomUUID(), runtimeId, environmentVersion, dshVersion, onProgress = async () => {} }) {
validId(id)
const sourceDetails = await lstat(this.sourceRoot)
if (!sourceDetails.isDirectory() || sourceDetails.isSymbolicLink()) {
throw new TrustError('snapshot source must be a real directory')
}
const versions = join(this.root, 'versions')
const destination = this.path(id)
await lstat(destination).then(
() => { throw new TrustError(`snapshot ${id} already exists`) },
error => { if (error?.code !== 'ENOENT') throw error },
)
await mkdir(versions, { recursive: true })
const staging = join(versions, `.${id}.${randomUUID()}.tmp`)
await mkdir(staging)
const archive = join(staging, 'data.tar.gz')
try {
const totalItems = await countTree(this.sourceRoot)
await onProgress({ processedItems: 0, totalItems })
await this.run('tar', ['-czpf', archive, '-C', this.sourceRoot, '.'])
await onProgress({ processedItems: totalItems, totalItems })
const measured = await hashFile(archive)
const manifest = canonicalJson({
schema: 1,
id,
createdAt: this.now().toISOString(),
source: '/data/dsh',
runtimeId: version(runtimeId, 'snapshot runtime ID'),
environmentVersion: version(environmentVersion, 'snapshot Environment version'),
dshVersion: version(dshVersion, 'snapshot DSH version'),
archiveSha256: measured.sha256,
archiveSize: measured.size,
})
parseManifest(manifest)
const handle = await open(join(staging, 'manifest.json'), 'wx', 0o600)
try { await handle.writeFile(manifest); await handle.sync() } finally { await handle.close() }
await syncDirectory(staging)
await rename(staging, destination)
await syncDirectory(versions)
return Object.freeze({ ...parseManifest(manifest), path: destination })
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async inspect(id) {
const root = this.path(id)
const manifest = parseManifest(await readFile(join(root, 'manifest.json')))
if (manifest.id !== id) throw new TrustError('snapshot manifest ID does not match its directory')
const archive = join(root, 'data.tar.gz')
const measured = await hashFile(archive)
if (measured.sha256 !== manifest.archiveSha256 || measured.size !== manifest.archiveSize) {
throw new TrustError('snapshot archive does not match its manifest', 'TRUST_ARTIFACT_MISMATCH')
}
const entries = (await this.run('tar', ['-tzf', archive])).split('\n').filter(Boolean)
if (entries.length === 0) throw new TrustError('snapshot archive is empty')
for (const entry of entries) {
if (entry.startsWith('/') || entry.split('/').includes('..')) {
throw new TrustError(`snapshot archive path is unsafe: ${entry}`)
}
}
return Object.freeze({ ...manifest, archive })
}
async restore(id, { onProgress = async () => {} } = {}) {
const snapshot = await this.inspect(id)
await mkdir(this.sourceRoot, { recursive: true })
for (const entry of await readdir(this.sourceRoot)) {
await rm(join(this.sourceRoot, entry), { recursive: true, force: true })
}
const entries = (await this.run('tar', ['-tzf', snapshot.archive])).split('\n').filter(Boolean)
await onProgress({ processedItems: 0, totalItems: entries.length })
const ownership = process.getuid?.() === 0 ? [] : ['--no-same-owner']
await this.run('tar', ['-xzpf', snapshot.archive, ...ownership, '-C', this.sourceRoot])
await onProgress({ processedItems: entries.length, totalItems: entries.length })
await syncDirectory(this.sourceRoot)
return snapshot
}
async list() {
let names
try { names = await readdir(join(this.root, 'versions')) } catch (error) {
if (error?.code === 'ENOENT') return []
throw error
}
const snapshots = []
for (const name of names.filter(name => !name.startsWith('.')).sort()) snapshots.push(await this.inspect(name))
return Object.freeze(snapshots)
}
async remove(id) {
try {
await this.inspect(id)
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
await rm(this.path(id), { recursive: true, force: true })
return true
}
}
@@ -0,0 +1,32 @@
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
export const TERMINAL_UPDATE_STATES = new Set(['idle', 'success', 'failed'])
export class UpdateStateStore {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
}
async read() {
try {
return JSON.parse(await readFile(this.path, 'utf8'))
} catch (error) {
if (error?.code === 'ENOENT') return { status: 'idle', taskId: null, progress: 0, error: null }
throw error
}
}
async write(status, fields = {}) {
const previous = await this.read()
const next = {
...previous,
...fields,
status,
updatedAt: this.now().toISOString(),
}
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return Object.freeze(next)
}
}
@@ -0,0 +1,30 @@
{
"schema": 1,
"id": "access-manager",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "exec",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs", "health"],
"timeoutSeconds": 5
},
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,53 @@
#!/usr/bin/env node
import { chmod, mkdir, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
import { AccessStateStore } from './lib/store.mjs'
import { AccessService, createAccessHttpServer } from './lib/server.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
async function listen(server, path) {
await mkdir(dirname(path), { recursive: true })
await rm(path, { force: true })
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(path, resolve)
})
await chmod(path, 0o600)
}
const classificationToken = process.env.DSH_ACCESS_CLASSIFICATION_TOKEN
delete process.env.DSH_ACCESS_CLASSIFICATION_TOKEN
if (typeof classificationToken !== 'string' || classificationToken.length < 32) {
throw new Error('Access classification token is unavailable')
}
const store = new AccessStateStore({ root: paths.accessStateRoot })
await store.prepare()
const service = new AccessService({
store,
classificationToken,
report: (message, fields) => {
process.send?.({ type: 'diagnostic', message, fields })
},
})
await service.reconcileRuntimePolicy()
const access = createAccessHttpServer({ service })
const recovery = createAccessHttpServer({ service, surface: 'recovery' })
await listen(access, paths.accessSocket)
await listen(recovery, paths.accessRecoverySocket)
process.send?.({ type: 'ready', componentReady: true })
let resolveSignal
const signal = new Promise(resolve => { resolveSignal = resolve })
process.once('SIGINT', () => resolveSignal())
process.once('SIGTERM', () => resolveSignal())
await signal
await Promise.all([
new Promise(resolve => access.close(resolve)),
new Promise(resolve => recovery.close(resolve)),
])
@@ -0,0 +1,64 @@
import { createHash, randomBytes } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(random) { return `dshcap_${random(32).toString('base64url')}` }
export function capabilityTarget(path) {
if (typeof path !== 'string' || path.length === 0 || path.length > 8_192 || !path.startsWith('/')) {
throw new TypeError('capability target is invalid')
}
const value = new URL(path, 'http://capability.internal')
if (value.origin !== 'http://capability.internal' || value.hash !== '') throw new TypeError('capability target is invalid')
return `${value.pathname}${value.search}`
}
export class CapabilityStore {
constructor({ now = Date.now, random = randomBytes, ttlMs = 5_000 } = {}) {
this.now = now
this.random = random
this.ttlMs = ttlMs
this.capabilities = new Map()
}
prune() {
const now = this.now()
for (const [key, value] of this.capabilities) if (value.expiresAt <= now) this.capabilities.delete(key)
}
issue(session, account, { audience, method, target }) {
if (!['management', 'maintenance', 'plugin'].includes(audience)) throw new TypeError('capability audience is invalid')
if (!/^(?:GET|HEAD|POST|PUT|DELETE)$/.test(method ?? '')) throw new TypeError('capability method is invalid')
this.prune()
const value = token(this.random)
this.capabilities.set(digest(value), {
audience,
method,
target: capabilityTarget(target),
sessionId: session.sessionId,
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null,
managementAccessVersion: account.managementAccess.version,
expiresAt: this.now() + this.ttlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.ttlMs).toISOString() })
}
consume(value, account, { audience, method, target }) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const capability = this.capabilities.get(key)
this.capabilities.delete(key)
const additionalVersion = account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null
if (capability === undefined || capability.accountId !== account.accountId
|| capability.mainCredentialVersion !== account.mainCredential.version
|| capability.managementAdditionalCredentialVersion !== additionalVersion
|| capability.managementAccessVersion !== account.managementAccess.version
|| capability.audience !== audience || capability.method !== method
|| capability.target !== capabilityTarget(target)) return undefined
return Object.freeze({ sessionId: capability.sessionId, audience: capability.audience })
}
}
@@ -0,0 +1,89 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import { AccessError } from './errors.mjs'
const scrypt = promisify(scryptCallback)
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u
const CONTROL_CHARACTERS = /\p{Cc}/u
export const SCRYPT_POLICY = Object.freeze({ N: 32768, r: 8, p: 1, keyLength: 32, maxmem: 64 * 1024 * 1024 })
function codePoints(value) { return [...value].length }
export function normalizeUsername(input) {
if (typeof input !== 'string') throw new AccessError('USERNAME_INVALID', 'username is invalid')
const value = input.trim().normalize('NFC')
const bytes = Buffer.byteLength(value, 'utf8')
if (value.length === 0 || codePoints(value) > 64 || bytes > 256
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
throw new AccessError('USERNAME_INVALID', 'username is invalid')
}
return value
}
export function normalizePassword(input) {
if (typeof input !== 'string') throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
const value = input.normalize('NFC')
const length = codePoints(value)
if (length < 8 || length > 1024 || Buffer.byteLength(value, 'utf8') > 1024
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
}
return value
}
function validParameters(value) {
return value !== null && typeof value === 'object'
&& Number.isInteger(value.N) && value.N >= 2
&& Number.isInteger(value.r) && value.r > 0
&& Number.isInteger(value.p) && value.p > 0
&& Number.isInteger(value.keyLength) && value.keyLength >= 16
&& Number.isInteger(value.maxmem) && value.maxmem >= 1024 * 1024
}
export async function createCredential(password, {
version = 1,
policy = SCRYPT_POLICY,
now = () => new Date(),
random = randomBytes,
derive = scrypt,
} = {}) {
const normalized = normalizePassword(password)
const salt = random(16)
const hash = await derive(normalized, salt, policy.keyLength, {
N: policy.N, r: policy.r, p: policy.p, maxmem: policy.maxmem,
})
return Object.freeze({
version,
algorithm: 'scrypt',
parameters: { ...policy },
salt: Buffer.from(salt).toString('base64url'),
hash: Buffer.from(hash).toString('base64url'),
changedAt: now().toISOString(),
})
}
export function validCredential(value) {
if (value === null || typeof value !== 'object' || value.algorithm !== 'scrypt'
|| !Number.isInteger(value.version) || value.version < 1 || !validParameters(value.parameters)
|| typeof value.salt !== 'string' || typeof value.hash !== 'string'
|| typeof value.changedAt !== 'string') return false
try {
return Buffer.from(value.salt, 'base64url').byteLength >= 16
&& Buffer.from(value.hash, 'base64url').byteLength === value.parameters.keyLength
&& Number.isFinite(Date.parse(value.changedAt))
} catch { return false }
}
export async function verifyCredential(password, credential, { derive = scrypt } = {}) {
if (!validCredential(credential)) return false
let normalized
try { normalized = normalizePassword(password) } catch { return false }
const expected = Buffer.from(credential.hash, 'base64url')
const salt = Buffer.from(credential.salt, 'base64url')
const parameters = credential.parameters
const actual = Buffer.from(await derive(normalized, salt, parameters.keyLength, {
N: parameters.N, r: parameters.r, p: parameters.p, maxmem: parameters.maxmem,
}))
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
@@ -0,0 +1,19 @@
export class AccessError extends Error {
constructor(code, message, statusCode = 400, details = {}) {
super(message)
this.name = 'AccessError'
this.code = code
this.statusCode = statusCode
this.details = details
}
}
export function accessErrorBody(error) {
if (error instanceof AccessError) return {
error: error.message,
code: error.code,
...(Number.isInteger(error.details.retryAfterSeconds)
? { retryAfterSeconds: error.details.retryAfterSeconds } : {}),
}
return { error: 'access operation failed', code: 'ACCESS_INTERNAL_ERROR' }
}
@@ -0,0 +1,97 @@
import { createHash, randomBytes } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` }
export class ManagementExchangeStore {
constructor({ now = Date.now, random = randomBytes, handoffTtlMs = 30_000, pendingTtlMs = 5 * 60_000, pendingAttempts = 16 } = {}) {
this.now = now
this.random = random
this.handoffTtlMs = handoffTtlMs
this.pendingTtlMs = pendingTtlMs
this.pendingAttempts = pendingAttempts
this.handoffs = new Map()
this.pending = new Map()
}
prune() {
const now = this.now()
for (const [key, value] of this.handoffs) if (value.expiresAt <= now) this.handoffs.delete(key)
for (const [key, value] of this.pending) if (value.expiresAt <= now) this.pending.delete(key)
}
createHandoff(session, account, targetOrigin) {
this.prune()
const value = token('dshh', this.random)
this.handoffs.set(digest(value), {
accountId: account.accountId,
dshSessionId: session.sessionId,
mainCredentialVersion: account.mainCredential.version,
managementAccessVersion: account.managementAccess.version,
sourceDshOrigin: session.origin,
targetOrigin,
expiresAt: this.now() + this.handoffTtlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.handoffTtlMs).toISOString() })
}
consumeHandoff(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const handoff = this.handoffs.get(key)
this.handoffs.delete(key)
if (handoff === undefined || handoff.accountId !== account.accountId
|| handoff.mainCredentialVersion !== account.mainCredential.version
|| handoff.managementAccessVersion !== account.managementAccess.version
|| handoff.targetOrigin !== targetOrigin) return undefined
return handoff
}
createPending(account, { targetOrigin, sourceDshOrigin = null, sourceDshSessionId = null }) {
this.prune()
const value = token('dshmp', this.random)
this.pending.set(digest(value), {
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.version,
managementAccessVersion: account.managementAccess.version,
targetOrigin,
sourceDshOrigin,
sourceDshSessionId,
attempts: 0,
expiresAt: this.now() + this.pendingTtlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.pendingTtlMs).toISOString() })
}
peekPendingSource(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const pending = this.pending.get(digest(value))
if (pending === undefined || pending.accountId !== account.accountId
|| pending.mainCredentialVersion !== account.mainCredential.version
|| pending.managementAccessVersion !== account.managementAccess.version
|| pending.targetOrigin !== targetOrigin) return undefined
return pending.sourceDshSessionId
}
inspectPending(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const pending = this.pending.get(key)
if (pending === undefined || pending.accountId !== account.accountId
|| pending.mainCredentialVersion !== account.mainCredential.version
|| pending.managementAccessVersion !== account.managementAccess.version
|| pending.targetOrigin !== targetOrigin) return undefined
pending.attempts += 1
if (pending.attempts > this.pendingAttempts) {
this.pending.delete(key)
return undefined
}
return { key, value: pending }
}
consumePending(key) { return this.pending.delete(key) }
}
@@ -0,0 +1,241 @@
import { AccessError } from './errors.mjs'
export class AuthenticationLimiter {
constructor({
globalLimit = 20,
windowMs = 60_000,
maxConcurrent = 2,
backoffThreshold = 5,
initialBackoffMs = 30_000,
maxBackoffMs = 15 * 60_000,
consecutiveResetMs = 24 * 60 * 60_000,
sourceWindows,
globalWindows,
clock = () => Date.now(),
} = {}) {
this.globalLimit = globalLimit
this.windowMs = windowMs
this.maxConcurrent = maxConcurrent
this.backoffThreshold = backoffThreshold
this.initialBackoffMs = initialBackoffMs
this.maxBackoffMs = maxBackoffMs
this.consecutiveResetMs = consecutiveResetMs
this.sourceWindows = sourceWindows ?? [
{ limit: 12, windowMs: 60 * 60_000 },
{ limit: 24, windowMs: 24 * 60 * 60_000 },
]
this.globalWindows = globalWindows ?? [
{ limit: globalLimit, windowMs },
{ limit: 60, windowMs: 60 * 60_000 },
{ limit: 120, windowMs: 24 * 60 * 60_000 },
]
this.maxSourceWindowMs = Math.max(...this.sourceWindows.map(value => value.windowMs))
this.maxGlobalWindowMs = Math.max(...this.globalWindows.map(value => value.windowMs))
this.clock = clock
this.global = []
this.sources = new Map()
this.consecutiveFailures = new Map()
this.active = 0
this.lastPruneAt = Number.NEGATIVE_INFINITY
}
prune(values, now, windowMs = this.windowMs) {
while (values.length > 0 && this.timestamp(values[0]) <= now - windowMs) values.shift()
}
timestamp(value) { return typeof value === 'number' ? value : value.at }
retryAfterSeconds(values, windows, now) {
let retryAfterMs = 0
for (const { limit, windowMs } of windows) {
const withinWindow = values.filter(value => this.timestamp(value) > now - windowMs)
if (withinWindow.length < limit) continue
retryAfterMs = Math.max(
retryAfterMs,
this.timestamp(withinWindow[withinWindow.length - limit]) + windowMs - now,
)
}
return Math.max(0, Math.ceil(retryAfterMs / 1_000))
}
retryKey(accountId, sourceId, credential = 'main') { return `${accountId}\u0000${credential}\u0000${sourceId}` }
retryPrefix(accountId, credential = 'all') {
return credential === 'all' ? `${accountId}\u0000` : `${accountId}\u0000${credential}\u0000`
}
pruneExpiredSources(now) {
if (now - this.lastPruneAt < this.windowMs) return
this.lastPruneAt = now
for (const [key, values] of this.sources) {
this.prune(values, now, this.maxSourceWindowMs)
if (values.length === 0) this.sources.delete(key)
}
for (const [key, retry] of this.consecutiveFailures) {
if (retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
}
currentRetry(accountId = 'unknown', sourceId = 'unknown', now = this.clock(), credential = 'main') {
const retryKey = this.retryKey(accountId, sourceId, credential)
const retry = this.consecutiveFailures.get(retryKey)
if (retry !== undefined && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(retryKey)
return undefined
}
return retry
}
checkRetry(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const now = this.clock()
const retry = this.currentRetry(accountId, sourceId, now, credential)
if (retry !== undefined && retry.blockedUntil > now) {
const retryAfterSeconds = Math.max(1, Math.ceil((retry.blockedUntil - now) / 1_000))
throw new AccessError(
'AUTHENTICATION_RETRY_REQUIRED',
`authentication retry is available in ${retryAfterSeconds} seconds`,
429,
{ retryAfterSeconds },
)
}
}
enter(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
this.checkRetry(accountId, sourceId, credential)
const now = this.clock()
this.pruneExpiredSources(now)
const retryKey = this.retryKey(accountId, sourceId, credential)
const source = this.sources.get(retryKey) ?? []
this.prune(source, now, this.maxSourceWindowMs)
this.sources.set(retryKey, source)
this.prune(this.global, now, this.maxGlobalWindowMs)
if (this.active >= this.maxConcurrent) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
'authentication is temporarily unavailable',
429,
{ retryAfterSeconds: 1 },
)
}
const globalRetryAfterSeconds = this.retryAfterSeconds(this.global, this.globalWindows, now)
const sourceRetryAfterSeconds = this.retryAfterSeconds(source, this.sourceWindows, now)
if (sourceRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`browser authentication is available in ${sourceRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: sourceRetryAfterSeconds },
)
}
if (globalRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`administrator authentication is available in ${globalRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: globalRetryAfterSeconds },
)
}
this.active += 1
let released = false
const release = success => {
if (released) return this.sourceStatus(accountId, sourceId, credential)
released = true
this.active -= 1
if (success !== true) {
const failedAt = this.clock()
this.global.push({ at: failedAt, credential })
source.push(failedAt)
const previous = this.consecutiveFailures.get(retryKey)
const failures = (previous !== undefined
&& previous.lastFailureAt > failedAt - this.consecutiveResetMs ? previous.failures : 0) + 1
const exponent = Math.max(0, failures - this.backoffThreshold)
const backoffMs = failures < this.backoffThreshold
? 0
: Math.min(this.maxBackoffMs, this.initialBackoffMs * (2 ** exponent))
this.consecutiveFailures.set(retryKey, {
failures,
blockedUntil: backoffMs === 0 ? 0 : failedAt + backoffMs,
lastFailureAt: failedAt,
})
} else {
this.consecutiveFailures.delete(this.retryKey(accountId, sourceId, credential))
this.sources.delete(this.retryKey(accountId, sourceId, credential))
}
return this.sourceStatus(accountId, sourceId, credential)
}
return { release }
}
sourceStatus(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const retry = this.currentRetry(accountId, sourceId, this.clock(), credential)
if (retry === undefined) return { consecutiveFailures: 0, retryAfterSeconds: 0 }
return {
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}
}
status(accountId = 'unknown', credential = 'all') {
const now = this.clock()
this.pruneExpiredSources(now)
this.prune(this.global, now, this.maxGlobalWindowMs)
const prefix = this.retryPrefix(accountId, credential)
for (const [key, retry] of this.consecutiveFailures) {
if (key.startsWith(prefix) && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
const states = [...this.consecutiveFailures.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, retry]) => ({
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}))
const sourcePrefix = prefix
const sourceRetryAfterSeconds = Math.max(0, ...[...this.sources.entries()]
.filter(([key]) => key.startsWith(sourcePrefix))
.map(([, values]) => this.retryAfterSeconds(values, this.sourceWindows, now)))
const global = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
return {
activeSources: states.length,
consecutiveFailures: Math.max(0, ...states.map(value => value.consecutiveFailures)),
retryAfterSeconds: Math.max(0, ...states.map(value => value.retryAfterSeconds)),
sourceRetryAfterSeconds,
globalFailures: global.length,
globalRetryAfterSeconds: this.retryAfterSeconds(global, this.globalWindows, now),
}
}
clear(accountId = 'unknown', credential = 'all') {
const previous = this.status(accountId, credential)
const prefix = this.retryPrefix(accountId, credential)
for (const key of this.consecutiveFailures.keys()) {
if (key.startsWith(prefix)) this.consecutiveFailures.delete(key)
}
for (const key of this.sources.keys()) {
if (key.startsWith(prefix)) this.sources.delete(key)
}
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return { cleared: previous.activeSources > 0 || previous.globalFailures > 0, ...previous }
}
clearGlobal(credential = 'all') {
const now = this.clock()
this.prune(this.global, now, this.maxGlobalWindowMs)
const matching = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
const globalFailures = matching.length
const globalRetryAfterSeconds = this.retryAfterSeconds(matching, this.globalWindows, now)
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return {
cleared: globalFailures > 0,
globalFailures,
globalRetryAfterSeconds,
}
}
}
@@ -0,0 +1,62 @@
import { readFile } from 'node:fs/promises'
function groupId(groupFile, name) {
for (const line of groupFile.split('\n')) {
const fields = line.split(':')
if (fields[0] === name && /^\d+$/.test(fields[2] ?? '')) return Number(fields[2])
}
return null
}
function supplementaryGroups(status) {
const line = status.split('\n').find(value => value.startsWith('Groups:'))
if (line === undefined) return []
return line.slice('Groups:'.length).trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger)
}
function mountOption(mounts, mountPoint, prefix) {
for (const line of mounts.split('\n')) {
const fields = line.split(' ')
if (fields[1] !== mountPoint) continue
return fields[3]?.split(',').find(value => value.startsWith(prefix)) ?? null
}
return null
}
async function optionalRead(path) {
try { return await readFile(path, 'utf8') } catch { return '' }
}
export async function detectRuntimeCapabilities({
groupPath = '/etc/group',
initStatusPath = '/proc/1/status',
mountsPath = '/proc/mounts',
ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope',
} = {}) {
const [groupsFile, initStatus, mounts, ptraceScopeValue] = await Promise.all([
optionalRead(groupPath),
optionalRead(initStatusPath),
optionalRead(mountsPath),
optionalRead(ptraceScopePath),
])
const enabledGroup = groupId(groupsFile, 'dsh-sudo-true')
const disabledGroup = groupId(groupsFile, 'dsh-sudo-false')
const initGroups = supplementaryGroups(initStatus)
const dshRootCapabilityEffective = enabledGroup !== null && initGroups.includes(enabledGroup)
const sudoSelection = dshRootCapabilityEffective
? 'enabled'
: (disabledGroup !== null && initGroups.includes(disabledGroup) ? 'disabled' : 'unknown')
const ptraceScope = /^\d+$/.test(ptraceScopeValue.trim()) ? Number(ptraceScopeValue.trim()) : null
const hidepid = mountOption(mounts, '/proc', 'hidepid=')
return {
dshRootCapabilityEffective,
agentIsolationEffective: false,
details: {
sudoSelection,
ptraceScope,
procHidepid: hidepid === null ? null : Number(hidepid.slice('hidepid='.length)),
agentIsolationReason: 'shared-node-process-identity',
},
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,301 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function digest(value) {
return createHash('sha256').update(value).digest()
}
function identifier(random = randomBytes, bytes = 32) {
return random(bytes).toString('base64url')
}
function sameDigest(value, expected) {
const actual = digest(value ?? '')
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
function sessionClient(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return Object.freeze({ ip: null, userAgent: null })
const bounded = (field, maximum) => typeof field === 'string' && field.length > 0 && field.length <= maximum
? field : null
return Object.freeze({
ip: bounded(value.ip, 128),
userAgent: bounded(value.userAgent, 512),
})
}
export class BrowserSessionStore {
constructor({
now = Date.now,
random = randomBytes,
dshAbsoluteMs = 30 * 24 * 60 * 60_000,
dshIdleMs = 7 * 24 * 60 * 60_000,
managementAbsoluteMs = 24 * 60 * 60_000,
managementIdleMs = 2 * 60 * 60_000,
activityReportMs = 5 * 60_000,
onEvent = () => {},
} = {}) {
this.now = now
this.random = random
this.policy = Object.freeze({
dsh: Object.freeze({ absoluteMs: dshAbsoluteMs, idleMs: dshIdleMs }),
management: Object.freeze({ absoluteMs: managementAbsoluteMs, idleMs: managementIdleMs }),
})
this.activityReportMs = activityReportMs
this.onEvent = onEvent
this.sessions = new Map()
}
setEventSink(onEvent) {
this.onEvent = typeof onEvent === 'function' ? onEvent : () => {}
}
emit(message, session, fields = {}) {
this.onEvent(message, {
accountId: session.accountId,
sessionId: session.sessionId,
kind: session.kind,
...fields,
})
}
prune() {
const now = this.now()
for (const [key, session] of this.sessions) {
const reason = session.expiresAt <= now
? 'absolute'
: session.lastSeenAt + session.idleMs <= now ? 'idle' : null
if (reason === null) continue
this.sessions.delete(key)
this.emit('access.session.expired', session, { reason })
}
for (const [key, session] of this.sessions) {
if (session.kind === 'management' && this.activeDshSession(session.sourceDshSessionId, session.accountId) === undefined) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
}
}
}
activeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string') return undefined
for (const session of this.sessions.values()) {
if (session.kind === 'dsh' && session.sessionId === sessionId && session.accountId === accountId) return session
}
return undefined
}
issue(kind, account, {
origin, sourceDshOrigin = null, sourceDshSessionId = null, client = null, authenticationSource = 'unknown',
} = {}) {
const policy = this.policy[kind]
if (policy === undefined) throw new TypeError('browser session kind is invalid')
if (typeof origin !== 'string' || origin.length === 0 || origin.length > 2_048) {
throw new TypeError('browser session origin is invalid')
}
this.prune()
const sourceDshSession = kind === 'management'
? this.activeDshSession(sourceDshSessionId, account.accountId)
: undefined
if (kind === 'management' && (sourceDshSession === undefined || sourceDshSession.origin !== sourceDshOrigin)) {
throw new TypeError('Management session requires an active DSH session')
}
const token = `${kind === 'dsh' ? 'dshs' : 'dshms'}_${identifier(this.random)}`
const csrfToken = `dshc_${identifier(this.random)}`
const sessionId = identifier(this.random, 16)
const createdAt = this.now()
const sourceClient = sourceDshSession === undefined ? sessionClient(client) : sourceDshSession.client
const expiresAt = Math.min(createdAt + policy.absoluteMs, sourceDshSession?.expiresAt ?? Number.POSITIVE_INFINITY)
this.sessions.set(digest(token).toString('hex'), {
sessionId,
kind,
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null,
managementAccessVersion: account.managementAccess.version,
origin,
sourceDshOrigin,
sourceDshSessionId,
authenticationSource: sourceDshSession?.authenticationSource ?? authenticationSource,
client: sourceClient,
csrfDigest: digest(csrfToken),
createdAt,
lastSeenAt: createdAt,
activityReportedAt: createdAt,
expiresAt,
idleMs: policy.idleMs,
})
return Object.freeze({
token,
csrfToken,
sessionId,
expiresAt: new Date(expiresAt).toISOString(),
})
}
validate(token, kind, account, { origin, csrfToken, requireCsrf = false, touch = true } = {}) {
this.prune()
if (typeof token !== 'string' || token.length > 512 || this.policy[kind] === undefined) return undefined
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (session === undefined || session.kind !== kind || session.accountId !== account.accountId
|| session.origin !== origin || (requireCsrf && !sameDigest(csrfToken, session.csrfDigest))) {
return undefined
}
let invalidationReason = null
if (session.mainCredentialVersion !== account.mainCredential.version) invalidationReason = 'main-credential-version'
else if (session.managementAccessVersion !== account.managementAccess.version) invalidationReason = 'management-access-version'
else if (kind === 'management'
&& session.managementAdditionalCredentialVersion !== (account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null)) invalidationReason = 'management-credential-version'
if (invalidationReason !== null) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: invalidationReason })
return undefined
}
if (kind === 'management') {
const source = this.activeDshSession(session.sourceDshSessionId, account.accountId)
if (source === undefined || source.origin !== session.sourceDshOrigin
|| source.mainCredentialVersion !== account.mainCredential.version
|| source.managementAccessVersion !== account.managementAccess.version) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
return undefined
}
}
if (touch) {
const now = this.now()
session.lastSeenAt = now
if (now - session.activityReportedAt >= this.activityReportMs) {
session.activityReportedAt = now
this.emit('access.session.activity', session)
}
}
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
accountId: session.accountId,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
createdAt: new Date(session.createdAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
})
}
revoke(token) {
if (typeof token !== 'string' || token.length > 512) return false
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (!this.sessions.delete(key)) return false
if (session.kind === 'dsh') this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
revokeKind(kind) {
const dshSessionIds = []
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== kind) continue
this.sessions.delete(key)
revoked += 1
if (kind === 'dsh') dshSessionIds.push(session.sessionId)
}
for (const sessionId of dshSessionIds) this.revokeManagementFromDsh(sessionId, 'source-dsh-session-revoked')
return revoked
}
countKind(kind) {
this.prune()
let count = 0
for (const session of this.sessions.values()) {
if (session.kind === kind) count += 1
}
return count
}
list(account, currentSessionId) {
this.prune()
const currentDshSessionId = this.sourceDshSessionId(currentSessionId) ?? currentSessionId
const managementSources = new Set([...this.sessions.values()]
.filter(session => session.kind === 'management')
.map(session => session.sourceDshSessionId))
return [...this.sessions.values()]
.filter(session => session.accountId === account.accountId
&& session.kind === 'dsh'
&& session.mainCredentialVersion === account.mainCredential.version
&& session.managementAccessVersion === account.managementAccess.version)
.map(session => Object.freeze({
sessionId: session.sessionId,
origin: session.origin,
current: session.sessionId === currentDshSessionId,
managementActive: managementSources.has(session.sessionId),
ip: session.client.ip,
userAgent: session.client.userAgent,
createdAt: new Date(session.createdAt).toISOString(),
lastSeenAt: new Date(session.lastSeenAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
}))
.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
}
revokeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string' || typeof accountId !== 'string') return false
for (const [key, session] of this.sessions) {
if (session.kind !== 'dsh' || session.sessionId !== sessionId || session.accountId !== accountId) continue
this.sessions.delete(key)
this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
return false
}
revokeManagementFromDsh(sessionId, reason = 'source-dsh-session') {
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== 'management' || session.sourceDshSessionId !== sessionId) continue
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason })
revoked += 1
}
return revoked
}
sourceDshSessionId(sessionId) {
for (const session of this.sessions.values()) {
if (session.kind === 'management' && session.sessionId === sessionId) return session.sourceDshSessionId
}
return null
}
refreshDshSession(sessionId, account) {
const session = this.activeDshSession(sessionId, account.accountId)
if (session === undefined) return false
session.mainCredentialVersion = account.mainCredential.version
session.managementAccessVersion = account.managementAccess.version
this.emit('access.session.refreshed', session, { reason: 'management-access-change' })
return true
}
details(sessionId) {
this.prune()
for (const session of this.sessions.values()) {
if (session.sessionId !== sessionId) continue
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
authenticationSource: session.authenticationSource,
})
}
return undefined
}
revokeAll() {
const revoked = this.sessions.size
this.sessions.clear()
return revoked
}
}
@@ -0,0 +1,249 @@
import { randomBytes, randomUUID } from 'node:crypto'
import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { AccessError } from './errors.mjs'
import { createCredential, normalizeUsername, validCredential } from './credentials.mjs'
import { validTotpSecret } from './totp.mjs'
const STATES = new Set(['never-initialized', 'migration-required', 'initialized', 'recovery-required'])
function identifier(random = randomBytes) { return random(32).toString('base64url') }
function timestamp(now) { return now().toISOString() }
async function syncDirectory(root) {
const handle = await open(root, 'r')
try { await handle.sync() } finally { await handle.close() }
}
async function atomicJson(path, value, mode = 0o600) {
const temporary = join(dirname(path), `.${path.split('/').at(-1)}.${randomUUID()}.tmp`)
const handle = await open(temporary, 'wx', mode)
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
await handle.sync()
} finally { await handle.close() }
await chmod(temporary, mode)
await rename(temporary, path)
await syncDirectory(dirname(path))
}
async function createExclusiveJson(path, value, mode = 0o600) {
const handle = await open(path, 'wx', mode)
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
await handle.sync()
} finally { await handle.close() }
await chmod(path, mode)
await syncDirectory(dirname(path))
}
async function readJson(path) {
try { return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) } }
catch (error) {
if (error?.code === 'ENOENT') return { exists: false, value: undefined }
return { exists: true, error }
}
}
export function validInitialization(value) {
return value !== null && typeof value === 'object' && value.schema === 1
&& typeof value.instanceId === 'string' && Buffer.from(value.instanceId, 'base64url').byteLength === 32
&& STATES.has(value.state) && Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt))
}
export function validAccount(value) {
const additional = value?.managementAdditionalCredential
const totp = value?.totp
const access = value?.managementAccess
const validOrigin = origin => {
try {
const parsed = new URL(origin)
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null'
&& parsed.username === '' && parsed.password === '' && parsed.pathname === '/'
&& parsed.search === '' && parsed.hash === '' && origin === parsed.origin
} catch { return false }
}
const additionalValid = additional !== null && typeof additional === 'object'
&& typeof additional.enabled === 'boolean'
&& Number.isInteger(additional.version) && additional.version >= 1
&& Number.isFinite(Date.parse(additional.changedAt))
&& (additional.enabled ? validCredential(additional.verifier) : additional.verifier === null)
const totpValid = totp === undefined || (totp !== null && typeof totp === 'object'
&& typeof totp.enabled === 'boolean'
&& Number.isInteger(totp.version) && totp.version >= 1
&& Number.isFinite(Date.parse(totp.changedAt))
&& (totp.enabled ? validTotpSecret(totp.secret) : totp.secret === null))
const isolatedEntryValid = access?.mode === 'compat'
? access.isolatedEntry === null && access.dshPublicOrigin === null
: access?.mode === 'isolated' && access.isolatedEntry !== null && typeof access.isolatedEntry === 'object'
&& validOrigin(access.dshPublicOrigin)
&& (access.isolatedEntry.kind === 'local-only' && validOrigin(access.isolatedEntry.managementLocalOrigin)
|| (access.isolatedEntry.kind === 'public' && (() => {
try {
const parsed = new URL(access.isolatedEntry.managementPublicOrigin)
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null'
&& parsed.username === '' && parsed.password === '' && parsed.pathname === '/'
&& parsed.search === '' && parsed.hash === ''
&& access.isolatedEntry.managementPublicOrigin === parsed.origin
} catch { return false }
})()))
return value !== null && typeof value === 'object' && value.schema === 1
&& typeof value.revision === 'string' && Buffer.from(value.revision, 'base64url').byteLength === 32
&& typeof value.accountId === 'string' && Buffer.from(value.accountId, 'base64url').byteLength === 32
&& (() => { try { return normalizeUsername(value.username) === value.username } catch { return false } })()
&& validCredential(value.mainCredential)
&& additionalValid && totpValid && isolatedEntryValid
&& Number.isInteger(access?.version) && access.version >= 1
&& Number.isFinite(Date.parse(access.changedAt))
&& Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt))
}
function validEvidence(evidence) {
if (evidence === null || typeof evidence !== 'object' || Array.isArray(evidence)) return false
const entries = Object.entries(evidence)
return entries.length > 0 && entries.every(([key, value]) => (
/^[a-z][A-Za-z0-9]{0,63}$/.test(key) && typeof value === 'boolean'
))
}
export class AccessStateStore {
constructor({ root, now = () => new Date(), random = randomBytes, createVerifier = createCredential } = {}) {
if (typeof root !== 'string') throw new TypeError('Access state root is required')
this.root = root
this.initializationPath = join(root, 'initialization.json')
this.accountPath = join(root, 'account.json')
this.now = now
this.random = random
this.createVerifier = createVerifier
}
async prepare() {
await mkdir(this.root, { recursive: true, mode: 0o700 })
await chmod(this.root, 0o700)
}
async inspect() {
const [initialization, account] = await Promise.all([
readJson(this.initializationPath), readJson(this.accountPath),
])
return {
initialization: initialization.error === undefined && validInitialization(initialization.value)
? initialization.value : undefined,
initializationExists: initialization.exists,
initializationDamaged: initialization.error !== undefined || (initialization.exists && !validInitialization(initialization.value)),
account: account.error === undefined && validAccount(account.value) ? account.value : undefined,
accountExists: account.exists,
accountDamaged: account.error !== undefined || (account.exists && !validAccount(account.value)),
}
}
async transition(initialization, state) {
const next = { ...initialization, state, updatedAt: timestamp(this.now) }
await atomicJson(this.initializationPath, next)
return next
}
async classify(evidence) {
if (!validEvidence(evidence)) throw new AccessError('CLASSIFICATION_EVIDENCE_INVALID', 'classification evidence is invalid')
await this.prepare()
const inspected = await this.inspect()
if (inspected.initialization !== undefined) {
if (inspected.initialization.state === 'never-initialized' && inspected.account !== undefined) {
const initialization = await this.transition(inspected.initialization, 'initialized')
return { initialization, account: inspected.account }
}
if (inspected.initialization.state === 'initialized' && inspected.account === undefined) {
const initialization = await this.transition(inspected.initialization, 'recovery-required')
return { initialization, account: undefined }
}
if (inspected.accountDamaged) {
const initialization = inspected.initialization.state === 'recovery-required'
? inspected.initialization : await this.transition(inspected.initialization, 'recovery-required')
return { initialization, account: undefined }
}
return { initialization: inspected.initialization, account: inspected.account }
}
if (inspected.initializationDamaged || inspected.accountExists) {
const initialization = {
schema: 1, instanceId: identifier(this.random), state: 'recovery-required',
createdAt: timestamp(this.now), updatedAt: timestamp(this.now),
}
await atomicJson(this.initializationPath, initialization)
return { initialization, account: undefined }
}
const persistentEvidence = Object.entries(evidence)
.some(([key, value]) => key !== 'legacyAuthenticationConfigured' && value)
const initialization = {
schema: 1,
instanceId: identifier(this.random),
state: persistentEvidence ? 'migration-required' : 'never-initialized',
createdAt: timestamp(this.now),
updatedAt: timestamp(this.now),
}
try { await createExclusiveJson(this.initializationPath, initialization) }
catch (error) {
if (error?.code === 'EEXIST') return this.classify(evidence)
throw error
}
return { initialization, account: undefined }
}
async state() {
const inspected = await this.inspect()
if (inspected.initialization === undefined) {
return { initialization: undefined, account: undefined, state: 'classification-pending' }
}
if (inspected.initialization.state === 'initialized' && inspected.account === undefined) {
return { initialization: inspected.initialization, account: undefined, state: 'recovery-required' }
}
return { initialization: inspected.initialization, account: inspected.account, state: inspected.initialization.state }
}
async createAccount({ username, password }, allowedState) {
const current = await this.state()
if (current.state !== allowedState) {
throw new AccessError('ALREADY_INITIALIZED', 'administrator initialization is unavailable', 409)
}
const normalized = normalizeUsername(username)
const now = timestamp(this.now)
const verifier = await this.createVerifier(password, { now: this.now, random: this.random })
const account = {
schema: 1,
revision: identifier(this.random),
accountId: identifier(this.random),
username: normalized,
mainCredential: verifier,
totp: { enabled: false, version: 1, secret: null, changedAt: now },
managementAdditionalCredential: { enabled: false, version: 1, verifier: null, changedAt: now },
managementAccess: {
mode: 'compat', version: 1, isolatedEntry: null, dshPublicOrigin: null, changedAt: now,
},
createdAt: now,
updatedAt: now,
}
await atomicJson(this.accountPath, account)
await this.transition(current.initialization, 'initialized')
return account
}
initialize(value) { return this.createAccount(value, 'never-initialized') }
migrate(value) { return this.createAccount(value, 'migration-required') }
recover(value) { return this.createAccount(value, 'recovery-required') }
async replaceAccount(account, revision) {
const current = await this.state()
if (current.state !== 'initialized' || current.account === undefined) {
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
}
if (current.account.revision !== revision) throw new AccessError('REVISION_CONFLICT', 'account revision changed', 409)
if (!validAccount(account)) throw new AccessError('ACCOUNT_INVALID', 'account record is invalid')
await atomicJson(this.accountPath, account)
return account
}
async clear() {
await rm(this.accountPath, { force: true })
}
}
@@ -0,0 +1,293 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const CODE_PATTERN = /^\d{6}$/
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` }
function base32Encode(value) {
let bits = 0
let buffer = 0
let output = ''
for (const byte of value) {
buffer = (buffer << 8) | byte
bits += 8
while (bits >= 5) {
output += BASE32_ALPHABET[(buffer >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31]
return output
}
function base32Decode(value) {
if (typeof value !== 'string' || value.length === 0 || /[^A-Z2-7]/u.test(value)) return undefined
let bits = 0
let buffer = 0
const output = []
for (const character of value) {
buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character)
bits += 5
if (bits >= 8) {
output.push((buffer >>> (bits - 8)) & 255)
bits -= 8
}
}
return Buffer.from(output)
}
function counterBuffer(counter) {
const value = Buffer.alloc(8)
value.writeBigUInt64BE(BigInt(counter))
return value
}
export function generateTotpSecret(random = randomBytes) {
return base32Encode(random(20))
}
export function validTotpSecret(secret) {
return base32Decode(secret)?.byteLength === 20
}
export function totpCode(secret, { now = Date.now(), offset = 0 } = {}) {
const key = base32Decode(secret)
if (key?.byteLength !== 20) throw new TypeError('TOTP secret is invalid')
const counter = Math.floor(now / 30_000) + offset
const hmac = createHmac('sha1', key).update(counterBuffer(counter)).digest()
const position = hmac.at(-1) & 15
const value = (hmac.readUInt32BE(position) & 0x7fffffff) % 1_000_000
return String(value).padStart(6, '0')
}
export function verifyTotpCode(code, secret, { now = Date.now(), window = 1 } = {}) {
if (typeof code !== 'string' || !CODE_PATTERN.test(code)) return false
const actual = Buffer.from(code)
for (let offset = -window; offset <= window; offset += 1) {
const expected = Buffer.from(totpCode(secret, { now, offset }))
if (timingSafeEqual(actual, expected)) return true
}
return false
}
export function totpUri({ secret, username, issuer = 'DSH Docker' }) {
if (!validTotpSecret(secret)) throw new TypeError('TOTP secret is invalid')
const label = `${issuer}:${username}`
const query = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' })
return `otpauth://totp/${encodeURIComponent(label)}?${query}`
}
export class TotpFlowStore {
constructor({ now = Date.now, random = randomBytes, enrollmentTtlMs = 5 * 60_000, loginTtlMs = 5 * 60_000 } = {}) {
this.now = now
this.random = random
this.enrollmentTtlMs = enrollmentTtlMs
this.loginTtlMs = loginTtlMs
this.enrollments = new Map()
this.disableConfirmations = new Map()
this.logins = new Map()
}
prune() {
const current = this.now()
for (const [key, value] of this.enrollments) if (value.expiresAt <= current) this.enrollments.delete(key)
for (const [key, value] of this.disableConfirmations) if (value.expiresAt <= current) this.disableConfirmations.delete(key)
for (const [key, value] of this.logins) if (value.expiresAt <= current) this.logins.delete(key)
}
createEnrollment(account, sessionId) {
this.prune()
for (const [key, value] of this.enrollments) {
if (value.accountId === account.accountId && value.sessionId === sessionId) this.enrollments.delete(key)
}
const value = token('dshte', this.random)
const secret = generateTotpSecret(this.random)
const expiresAt = this.now() + this.enrollmentTtlMs
this.enrollments.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
sessionId,
secret,
confirmed: false,
expiresAt,
})
return Object.freeze({ token: value, secret, expiresAt: new Date(expiresAt).toISOString() })
}
enrollment(value, account, sessionId) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const enrollment = this.enrollments.get(key)
if (enrollment === undefined || enrollment.accountId !== account.accountId
|| enrollment.accountRevision !== account.revision || enrollment.sessionId !== sessionId) return undefined
return { key, value: enrollment }
}
cancelEnrollment(value, account, sessionId) {
const enrollment = this.enrollment(value, account, sessionId)
return enrollment === undefined ? false : this.enrollments.delete(enrollment.key)
}
confirmEnrollment(key) {
const enrollment = this.enrollments.get(key)
if (enrollment === undefined) return false
enrollment.confirmed = true
return true
}
consumeEnrollment(key) { return this.enrollments.delete(key) }
createDisableConfirmation(account, sessionId) {
this.prune()
for (const [key, value] of this.disableConfirmations) {
if (value.accountId === account.accountId && value.sessionId === sessionId) this.disableConfirmations.delete(key)
}
const value = token('dshtd', this.random)
const expiresAt = this.now() + this.enrollmentTtlMs
this.disableConfirmations.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
sessionId,
confirmed: false,
expiresAt,
})
return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() })
}
disableConfirmation(value, account, sessionId) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const confirmation = this.disableConfirmations.get(key)
if (confirmation === undefined || confirmation.accountId !== account.accountId
|| confirmation.accountRevision !== account.revision || confirmation.sessionId !== sessionId) return undefined
return { key, value: confirmation }
}
cancelDisableConfirmation(value, account, sessionId) {
const confirmation = this.disableConfirmation(value, account, sessionId)
return confirmation === undefined ? false : this.disableConfirmations.delete(confirmation.key)
}
confirmDisable(key) {
const confirmation = this.disableConfirmations.get(key)
if (confirmation === undefined) return false
confirmation.confirmed = true
return true
}
consumeDisableConfirmation(key) { return this.disableConfirmations.delete(key) }
createLogin(account, details) {
this.prune()
const value = token('dshtl', this.random)
const expiresAt = this.now() + this.loginTtlMs
this.logins.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
...details,
expiresAt,
})
return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() })
}
login(value, account) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const login = this.logins.get(key)
if (login === undefined || login.accountId !== account.accountId
|| login.accountRevision !== account.revision) return undefined
return { key, value: login }
}
consumeLogin(key) { return this.logins.delete(key) }
clear() {
this.enrollments.clear()
this.disableConfirmations.clear()
this.logins.clear()
}
}
export class TotpRetryLimiter {
constructor({
now = Date.now,
threshold = 3,
retryMs = 10_000,
dailyWindowMs = 24 * 60 * 60_000,
sourceDailyLimit = 25,
globalDailyLimit = 50,
} = {}) {
this.now = now
this.threshold = threshold
this.retryMs = retryMs
this.dailyWindowMs = dailyWindowMs
this.sourceDailyLimit = sourceDailyLimit
this.globalDailyLimit = globalDailyLimit
this.entries = new Map()
this.globalFailures = new Map()
}
key(accountId, source) { return `${accountId}\u0000${source}` }
prune(values) {
const cutoff = this.now() - this.dailyWindowMs
while (values.length > 0 && values[0] <= cutoff) values.shift()
}
retry(accountId, source) {
const entry = this.entries.get(this.key(accountId, source))
const global = this.globalFailures.get(accountId) ?? []
if (entry !== undefined) this.prune(entry.failuresInWindow)
this.prune(global)
const until = values => Math.max(1, Math.ceil((values[0] + this.dailyWindowMs - this.now()) / 1000))
if (global.length >= this.globalDailyLimit) return { kind: 'rate', retryAfterSeconds: until(global) }
if (entry?.failuresInWindow.length >= this.sourceDailyLimit) {
return { kind: 'rate', retryAfterSeconds: until(entry.failuresInWindow) }
}
if (entry !== undefined && entry.blockedUntil > this.now()) {
return { kind: 'retry', retryAfterSeconds: Math.max(1, Math.ceil((entry.blockedUntil - this.now()) / 1000)) }
}
return { kind: null, retryAfterSeconds: 0 }
}
fail(accountId, source) {
const key = this.key(accountId, source)
const entry = this.entries.get(key) ?? { consecutiveFailures: 0, blockedUntil: 0, failuresInWindow: [] }
const global = this.globalFailures.get(accountId) ?? []
this.prune(entry.failuresInWindow)
this.prune(global)
entry.consecutiveFailures += 1
entry.failuresInWindow.push(this.now())
global.push(this.now())
if (entry.consecutiveFailures >= this.threshold) entry.blockedUntil = this.now() + this.retryMs
this.entries.set(key, entry)
this.globalFailures.set(accountId, global)
return this.retry(accountId, source)
}
succeed(accountId, source) {
const entry = this.entries.get(this.key(accountId, source))
if (entry === undefined) return
entry.consecutiveFailures = 0
entry.blockedUntil = 0
}
clearDailyLimits(accountId, { globalOnly = false } = {}) {
let cleared = this.globalFailures.delete(accountId) ? 1 : 0
if (globalOnly) return { cleared }
for (const [key, entry] of this.entries) {
if (!key.startsWith(`${accountId}\u0000`)) continue
if (entry.failuresInWindow.length > 0) {
entry.failuresInWindow = []
cleared += 1
}
}
return { cleared }
}
}
@@ -0,0 +1,120 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest() }
function token(prefix, random, bytes = 32) { return `${prefix}_${random(bytes).toString('base64url')}` }
function sameDigest(value, expected) {
if (typeof value !== 'string' || expected === null) return false
const actual = digest(value)
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
export class ManagementTransitionStore {
constructor({ now = Date.now, random = randomBytes, ttlMs = 30_000 } = {}) {
this.now = now
this.random = random
this.ttlMs = ttlMs
this.transitions = new Map()
this.continuations = new Map()
}
prune() {
const now = this.now()
for (const [id, value] of this.transitions) if (value.expiresAt <= now) this.transitions.delete(id)
for (const [key, value] of this.continuations) if (value.expiresAt <= now) this.continuations.delete(key)
}
create({ account, instanceId, sessionId, sourceOrigin, sourceDshOrigin, sourceDshSessionId, mode, isolatedEntry, candidateOrigin }) {
this.prune()
const transitionId = token('dshmt', this.random, 16)
const nonce = candidateOrigin === null ? null : token('dshmn', this.random)
const expiresAt = this.now() + this.ttlMs
this.transitions.set(transitionId, {
accountId: account.accountId,
accountRevision: account.revision,
managementAccessVersion: account.managementAccess.version,
instanceId,
sessionId,
sourceOrigin,
sourceDshOrigin,
sourceDshSessionId,
mode,
isolatedEntry,
candidateOrigin,
nonceDigest: nonce === null ? null : digest(nonce),
proofDigest: null,
expiresAt,
})
return Object.freeze({
transitionId,
nonce,
candidateOrigin,
expiresAt: new Date(expiresAt).toISOString(),
})
}
probe({ transitionId, nonce, candidateOrigin, sourceOrigin, instanceId }) {
this.prune()
const transition = this.transitions.get(transitionId)
if (transition === undefined || transition.candidateOrigin !== candidateOrigin
|| transition.sourceOrigin !== sourceOrigin || transition.instanceId !== instanceId
|| !sameDigest(nonce, transition.nonceDigest)) return undefined
transition.nonceDigest = null
const proof = token('dshmpf', this.random)
transition.proofDigest = digest(proof)
return Object.freeze({ proof, expiresAt: new Date(transition.expiresAt).toISOString() })
}
inspect({ transitionId, proof, account, sessionId }) {
this.prune()
const transition = this.transitions.get(transitionId)
if (transition === undefined || transition.accountId !== account.accountId
|| transition.accountRevision !== account.revision
|| transition.managementAccessVersion !== account.managementAccess.version
|| transition.sessionId !== sessionId
|| (transition.candidateOrigin !== null && !sameDigest(proof, transition.proofDigest))) return undefined
return transition
}
consume(value) {
const transition = this.inspect(value)
if (transition === undefined) return undefined
const { transitionId } = value
this.transitions.delete(transitionId)
return transition
}
createContinuation({ account, targetOrigin, sourceDshOrigin, sourceDshSessionId }) {
if (targetOrigin === null) return null
this.prune()
const continuation = token('dshmc', this.random)
const expiresAt = this.now() + this.ttlMs
this.continuations.set(digest(continuation).toString('hex'), {
accountId: account.accountId,
accountRevision: account.revision,
managementAccessVersion: account.managementAccess.version,
targetOrigin,
sourceDshOrigin,
sourceDshSessionId,
expiresAt,
})
return Object.freeze({ token: continuation, expiresAt: new Date(expiresAt).toISOString() })
}
consumeContinuation({ token: value, account, targetOrigin }) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value).toString('hex')
const continuation = this.continuations.get(key)
this.continuations.delete(key)
if (continuation === undefined || continuation.accountId !== account.accountId
|| continuation.accountRevision !== account.revision
|| continuation.managementAccessVersion !== account.managementAccess.version
|| continuation.targetOrigin !== targetOrigin) return undefined
return continuation
}
clear() {
this.transitions.clear()
this.continuations.clear()
}
}
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
const launch = new LocalApiClient(paths.accessLaunchSocket)
if (process.argv[2] === 'health') {
const status = await launch.request('GET', '/v1/status')
process.exit(status.componentReady === true ? 0 : 1)
}
const token = process.env.DSH_ACCESS_LAUNCH_TOKEN
delete process.env.DSH_ACCESS_LAUNCH_TOKEN
if (typeof token !== 'string' || token.length < 32) throw new Error('Access Manager launch token is unavailable')
await launch.request('POST', '/v1/start', { token })
let stopping = false
let resolveStop
const stopped = new Promise(resolve => { resolveStop = resolve })
const stop = () => {
if (stopping) return
stopping = true
void launch.request('POST', '/v1/stop', { token }).catch(() => {}).finally(resolveStop)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const poll = setInterval(() => {
void launch.request('GET', '/v1/status').then(status => {
if (!stopping && status.running !== true) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
}, () => {
if (!stopping) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
})
}, 500)
await stopped
clearInterval(poll)
@@ -0,0 +1,31 @@
{
"schema": 1,
"id": "gateway",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/gateway/index.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "http",
"host": "127.0.0.1",
"port": 3080,
"path": "/_dsh_gateway/health",
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": {
"stdout": true,
"stderr": true
}
}
@@ -0,0 +1,38 @@
#!/usr/bin/env node
import { loadConfig } from './lib/config.mjs'
import { UsageError } from './lib/errors.mjs'
import { runGateway } from './lib/lifecycle.mjs'
import { JsonlLogManager } from '../../modules/log-manager/index.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
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 },
})
logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) })
const report = (message, fields = {}) => message === 'gateway.access.login-failed'
? logs.diagnosticRateLimited(
`gateway.access.login-failed:${typeof fields.code === 'string' ? fields.code.toLowerCase() : 'unknown'}`,
'gateway', message, fields,
)
: logs.diagnostic('gateway', message, fields)
try {
const config = await loadConfig()
process.exitCode = await runGateway(config, { report, managementPort: config.managementPort })
} catch (error) {
if (error instanceof UsageError) {
await logs.diagnostic('gateway', 'gateway.configuration.failed', { error, level: 'warning' })
process.exitCode = error.exitCode
} else {
await logs.diagnostic('gateway', 'gateway.process.failed', { error })
process.exitCode = 1
}
}
@@ -0,0 +1,151 @@
import { request as httpRequest } from 'node:http'
// A Bootstrap handoff stops DSH while the updater that initiated it is still
// recorded as building the candidate. This state is consulted only after the
// DSH upstream becomes unavailable, so ordinary candidate builds stay visible.
const SWITCHING_STATES = new Set(['building-candidate', 'snapshotting-data', 'switching', 'probation'])
const RECOVERING_STATES = new Set(['restoring-data'])
const COPY = Object.freeze({
en: Object.freeze({
starting: 'DeepSeek Harness is starting',
stopping: 'DeepSeek Harness is stopping',
stopped: 'DeepSeek Harness is stopped',
restarting: 'DeepSeek Harness is restarting',
switching: 'Switching the DeepSeek Harness runtime',
'runtime-recovering': 'Restoring the DeepSeek Harness runtime',
recovering: 'DeepSeek Harness stopped unexpectedly and recovery is in progress',
failed: 'DeepSeek Harness could not start',
'plugin-failed': 'DeepSeek Harness plugins repeatedly failed to load',
unavailable: 'DeepSeek Harness is temporarily unavailable and recovery is in progress',
}),
zh: Object.freeze({
starting: 'DeepSeek Harness 正在启动',
stopping: 'DeepSeek Harness 正在停止',
stopped: 'DeepSeek Harness 已停止',
restarting: 'DeepSeek Harness 正在重新启动',
switching: '正在切换 DeepSeek Harness 运行版本',
'runtime-recovering': '正在恢复 DeepSeek Harness 运行版本',
recovering: 'DeepSeek Harness 意外停止,正在尝试恢复',
failed: 'DeepSeek Harness 启动失败',
'plugin-failed': 'DeepSeek Harness 插件持续加载失败',
unavailable: 'DeepSeek Harness 暂时不可用,正在尝试恢复',
}),
})
const MANAGEMENT_LINK = Object.freeze({
en: 'Open DSH Management Console for diagnostics and recovery',
zh: '打开 DSH 管理中心进行检查和恢复',
})
const REFRESH_LINK = Object.freeze({ en: 'Reload page', zh: '刷新页面' })
export class DshAvailability {
constructor({ now = () => Date.now(), failures = 3, failureWindowMs = 1_500 } = {}) {
this.now = now
this.failures = failures
this.failureWindowMs = failureWindowMs
this.everReady = false
this.consecutiveFailures = 0
this.firstFailureAt = null
}
observe(ready) {
if (ready) {
this.everReady = true
this.consecutiveFailures = 0
this.firstFailureAt = null
return
}
this.consecutiveFailures += 1
this.firstFailureAt ??= this.now()
}
classify(platform = {}) {
if (platform.operation === 'restart-failed') return 'failed'
if (platform.operation === 'restarting') return 'restarting'
if (platform.operation === 'recovering') return 'runtime-recovering'
if (platform.operation === 'switching') return 'switching'
const updateState = platform.update?.status
if (RECOVERING_STATES.has(updateState)) return 'runtime-recovering'
if (SWITCHING_STATES.has(updateState)) return 'switching'
const lifecycle = platform.dshLifecycle ?? {}
if (['starting', 'stopping', 'stopped', 'restarting', 'recovering', 'failed'].includes(lifecycle.state)) {
return lifecycle.state
}
if (platform.recoveryMode !== null && platform.recoveryMode !== undefined) return 'failed'
if (!this.everReady) return 'starting'
if (
this.consecutiveFailures >= this.failures
&& this.firstFailureAt !== null
&& this.now() - this.firstFailureAt >= this.failureWindowMs
) return 'unavailable'
return 'unknown'
}
}
export function probeDsh({ host, port, timeoutMs = 750 }) {
return new Promise(resolve => {
const request = httpRequest({ hostname: host, port, path: '/', method: 'GET' }, response => {
response.resume()
resolve((response.statusCode ?? 500) < 500)
})
request.setTimeout(timeoutMs, () => request.destroy())
request.once('error', () => resolve(false))
request.end()
})
}
function cookieLocale(cookie = '') {
for (const part of cookie.split(';')) {
const [name, value] = part.trim().split('=', 2)
if (name === 'dsh_locale' && (value === 'zh' || value === 'en')) return value
}
return undefined
}
export function language(headers) {
const saved = cookieLocale(headers.cookie)
if (saved !== undefined) return saved
for (const item of (headers['accept-language'] ?? '').split(',')) {
const primary = item.trim().split(';', 1)[0].split('-', 1)[0].toLowerCase()
if (primary === 'zh' || primary === 'en') return primary
}
return 'en'
}
export function stateMessage(state, headers = {}, lifecycle = {}) {
const locale = language(headers)
const message = COPY[locale][state] ?? COPY.en.unavailable
if (state !== 'recovering' || !(lifecycle.attempt > 0) || !(lifecycle.maxAttempts > 0)) return message
return locale === 'zh'
? `${message}(第 ${String(lifecycle.attempt)} / ${String(lifecycle.maxAttempts)} 次)`
: `${message} (attempt ${String(lifecycle.attempt)} of ${String(lifecycle.maxAttempts)})`
}
export function availabilityPage(state, headers = {}, { lifecycle = {}, returnPath = null, poll = true, allowRefresh = false, managementHref = '/_dsh_platform/console/' } = {}) {
const locale = language(headers)
const message = stateMessage(state, headers, lifecycle)
const managementLink = MANAGEMENT_LINK[locale]
const refreshLink = REFRESH_LINK[locale]
const target = JSON.stringify(returnPath).replaceAll('<', '\\u003c')
return `<!doctype html>
<html lang="${locale === 'zh' ? 'zh-CN' : 'en'}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DeepSeek Harness</title>
<style>
html{height:100%}body{min-height:100%;margin:0;display:grid;place-items:center;background:#151517;color:#f3f3f4;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.boot{display:flex;flex-direction:column;align-items:center;gap:16px;padding:24px;text-align:center}.wordmark{font-size:16px;line-height:24px;font-weight:600;letter-spacing:0}.status{max-width:520px;font-size:13px;line-height:20px;color:#a7a7ad}.actions{display:flex;flex-wrap:wrap;justify-content:center;gap:10px}.management{padding:7px 12px;border:1px solid #46464b;border-radius:6px;color:#d7d7db;font-size:13px;line-height:20px;text-decoration:none}.management:hover{background:#232326}.management:focus-visible{outline:2px solid #8ca8ff;outline-offset:2px}@media(prefers-color-scheme:light){body{background:#f9fafb;color:#0f1115}.status{color:#6d7178}.management{border-color:#d6d8dc;color:#34373d}.management:hover{background:#f0f1f3}}
</style>
</head>
<body>
<main class="boot"><div class="wordmark">HARNESS</div><div class="status" role="status">${message}</div><div class="actions"><a class="management" href="${String(managementHref).replaceAll('"', '&quot;')}">${managementLink}</a>${allowRefresh ? `<a class="management refresh" href="">${refreshLink}</a>` : ''}</div></main>
${poll ? `<script>
const returnPath=${target};const status=document.querySelector('.status');const management=document.querySelector('.management:not(.refresh)');
async function check(){try{const response=await fetch('/_dsh_gateway/readiness',{cache:'no-store'});const value=await response.json();if(typeof value.managementHref==='string')management.href=value.managementHref;if(value.ready){if(returnPath===null)location.reload();else location.replace(returnPath);return}if(typeof value.message==='string')status.textContent=value.message}catch{}setTimeout(check,1000)}
setTimeout(check,600);
</script>` : ''}
</body>
</html>`
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
import { UsageError } from './errors.mjs'
export function parseBoolean(name, value, fallback) {
const resolved = value ?? String(fallback)
if (resolved === 'true') return true
if (resolved === 'false') return false
throw new UsageError(`${name} must be true or false`)
}
function parseUrlAuthority(value) {
try {
return new URL(`http://${value}`)
} catch {
return undefined
}
}
function canonicalAuthority(value, parsed) {
const port = parsed.port !== '' ? parsed.port : new URL(`https://${value}`).port
return port === '' ? parsed.hostname : `${parsed.hostname}:${port}`
}
export function parseTrustedAuthority(value) {
const parsed = parseUrlAuthority(value)
if (
parsed === undefined
|| parsed.hostname.includes('*')
|| canonicalAuthority(value, parsed) !== value.toLowerCase()
) {
throw new UsageError(
`DSH_TRUSTED_HOSTS entry ${JSON.stringify(value)} must be a canonical host or host:port authority`,
)
}
const authority = canonicalAuthority(value, parsed)
return Object.freeze({
hostname: parsed.hostname,
authority,
anyPort: authority === parsed.hostname,
matchAuthority: parsed.host,
})
}
export function parseTrustedHosts(environment = process.env) {
const raw = environment.DSH_TRUSTED_HOSTS?.trim() ?? ''
if (raw === '') return Object.freeze({ wildcard: false, authorities: Object.freeze([]) })
const entries = raw.split(',').map(entry => entry.trim())
if (entries.some(entry => entry === '')) {
throw new UsageError('DSH_TRUSTED_HOSTS cannot contain an empty entry')
}
if (entries.includes('*')) {
if (entries.length !== 1) throw new UsageError('DSH_TRUSTED_HOSTS wildcard must be used alone')
return Object.freeze({ wildcard: true, authorities: Object.freeze([]) })
}
const unique = new Map()
for (const entry of entries) {
const parsed = parseTrustedAuthority(entry)
unique.set(`${parsed.authority}|${String(parsed.anyPort)}`, parsed)
}
return Object.freeze({ wildcard: false, authorities: Object.freeze([...unique.values()]) })
}
export async function loadConfig(environment = process.env) {
const managementPort = environment.DSH_MANAGEMENT_PORT === undefined || environment.DSH_MANAGEMENT_PORT.trim() === ''
? 3081 : Number(environment.DSH_MANAGEMENT_PORT)
if (managementPort !== null && (!Number.isInteger(managementPort) || managementPort < 1 || managementPort > 65535)) {
throw new UsageError('DSH_MANAGEMENT_PORT must be an integer between 1 and 65535')
}
return Object.freeze({
trustedHosts: parseTrustedHosts(environment),
polyfill: parseBoolean('DSH_PROXY_POLYFILL', environment.DSH_PROXY_POLYFILL, true),
managementPort,
})
}
@@ -0,0 +1,119 @@
import { request } from 'node:http'
function lifecycleReadiness(socketPath) {
const body = Buffer.from('{}')
return new Promise((resolve, reject) => {
const outgoing = request({
socketPath,
path: '/v1/runtime/readiness',
method: 'POST',
headers: {
'content-length': body.byteLength,
'content-type': 'application/json',
},
}, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.once('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`DSH lifecycle readiness returned HTTP ${String(response.statusCode)}`))
return
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
} catch (error) {
reject(error)
}
})
})
outgoing.setTimeout(750, () => outgoing.destroy(new Error('DSH lifecycle readiness timed out')))
outgoing.once('error', reject)
outgoing.end(body)
})
}
function exchange(host, port, readyUrl) {
const url = new URL(readyUrl)
if (url.protocol !== 'http:' || url.hostname !== host || Number(url.port) !== port
|| url.pathname !== '/' || url.username !== '' || url.password !== '' || url.hash !== ''
|| url.searchParams.getAll('token').length !== 1) {
throw new Error('DSH Web readiness URL is invalid')
}
return new Promise((resolve, reject) => {
const outgoing = request({
hostname: host,
port,
path: `${url.pathname}${url.search}`,
method: 'GET',
headers: { 'cache-control': 'no-store' },
}, response => {
response.resume()
response.once('end', () => {
const cookie = response.headers['set-cookie']?.[0]?.split(';', 1)[0]
if (response.statusCode !== 303 || response.headers.location !== '/'
|| typeof cookie !== 'string' || cookie === '') {
reject(new Error('DSH Web authentication exchange failed'))
} else resolve(cookie)
})
})
outgoing.setTimeout(750, () => outgoing.destroy(new Error('DSH Web authentication exchange timed out')))
outgoing.once('error', reject)
outgoing.end()
})
}
export class DshUpstreamAuthentication {
constructor({
socketPath = '/run/dsh-platform/dsh-lifecycle.sock',
host = '127.0.0.1',
port = 3079,
now = () => Date.now(),
legacyProbeIntervalMs = 250,
} = {}) {
this.socketPath = socketPath
this.host = host
this.port = port
this.now = now
this.legacyProbeIntervalMs = legacyProbeIntervalMs
this.cachedCookie = undefined
this.cachedGeneration = null
this.nextProbeAt = 0
this.pending = null
}
async publicReady() {
const readiness = await lifecycleReadiness(this.socketPath)
const ready = readiness?.publicReady === true
|| (readiness?.publicReady === undefined && readiness?.ready === true)
if (ready) this.nextProbeAt = 0
return ready
}
async cookie() {
if (this.pending !== null) return this.pending
if (this.now() < this.nextProbeAt) return null
this.pending = this.refresh().finally(() => { this.pending = null })
return this.pending
}
async refresh() {
const readiness = await lifecycleReadiness(this.socketPath)
const publicReady = readiness?.publicReady === true
|| (readiness?.publicReady === undefined && readiness?.ready === true)
if (!publicReady || readiness?.ready !== true || typeof readiness.readyUrl !== 'string') {
if (!publicReady) {
this.cachedCookie = undefined
this.cachedGeneration = null
}
this.nextProbeAt = this.now() + this.legacyProbeIntervalMs
return null
}
if (this.cachedCookie !== undefined && this.cachedGeneration === (readiness.generation ?? null)) {
return this.cachedCookie
}
const cookie = await exchange(this.host, this.port, readiness.readyUrl)
this.cachedCookie = cookie
this.cachedGeneration = readiness.generation ?? null
return cookie
}
}
@@ -0,0 +1,7 @@
export class UsageError extends Error {
constructor(message) {
super(message)
this.name = 'UsageError'
this.exitCode = 64
}
}
@@ -0,0 +1,157 @@
import { createGatewayServer, closeGatewayServer } from './proxy.mjs'
import { createBrowserAuthentication } from './browser-auth.mjs'
import { LocalApiClient } from '../../../modules/updater/lib/client.mjs'
import { safeReturnPath } from './proxy.mjs'
import { DshUpstreamAuthentication } from './dsh-upstream-auth.mjs'
export const EXTERNAL_HOST = '0.0.0.0'
export const EXTERNAL_PORT = 3080
function listen(server, port, host) {
return new Promise((resolve, reject) => {
const onError = (error) => {
server.off('listening', onListening)
reject(error)
}
const onListening = () => {
server.off('error', onError)
resolve()
}
server.once('error', onError)
server.once('listening', onListening)
server.listen(port, host)
})
}
export async function runGateway(config, {
signalSource = process,
gatewayFactory = createGatewayServer,
externalHost = EXTERNAL_HOST,
externalPort = EXTERNAL_PORT,
managementPort = null,
managementHost = externalHost,
managementSocketPath = process.env.DSH_PLATFORM_MANAGEMENT_SOCKET ?? '/run/dsh-platform/management.sock',
managementCliSocketPath = process.env.DSH_PLATFORM_MANAGEMENT_CLI_SOCKET ?? '/run/dsh-platform/management-cli.sock',
maintenanceSocketPath = process.env.DSH_PLATFORM_MAINTENANCE_SOCKET ?? '/run/dsh-platform/maintenance.sock',
lifecycleSocketPath = process.env.DSH_PLATFORM_LIFECYCLE_SOCKET ?? '/run/dsh-platform/dsh-lifecycle.sock',
accessSocketPath = process.env.DSH_PLATFORM_ACCESS_SOCKET ?? '/run/dsh-platform/access/access.sock',
accessClient,
managementClient,
platformStatusPollMs = 250,
platformStatusCacheMs = 10_000,
now = () => Date.now(),
report = async () => {},
} = {}) {
const record = (message, fields = {}) => Promise.resolve().then(() => report(message, fields)).catch(() => {})
const management = managementClient ?? new LocalApiClient(managementCliSocketPath)
const access = accessClient ?? new LocalApiClient(accessSocketPath)
const accessRequest = access.request.bind(access)
const resilientAccess = accessClient === undefined
? { request: async (method, path, body, headers) => {
for (let attempt = 0; ; attempt++) {
try {
return await accessRequest(method, path, body, headers)
} catch (error) {
const retryable = ['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'].includes(error?.code)
if (!retryable || attempt >= 20) throw error
await new Promise(resolve => setTimeout(resolve, Math.min(250, 25 * (attempt + 1))))
}
}
} } : access
const browserAuthentication = createBrowserAuthentication({ access: resilientAccess, safeReturnPath, report: record })
const dshUpstreamAuthentication = new DshUpstreamAuthentication({ socketPath: lifecycleSocketPath })
const isolatedBrowserAuthentication = createBrowserAuthentication({
access: resilientAccess,
safeReturnPath,
report: record,
paths: { authPrefix: '/auth/', accessPrefix: '/access/', transitionPrefix: '/transition/', consolePath: '/' },
})
let lastPlatformStatus
let lastPlatformStatusAt = 0
const refreshPlatformStatus = async () => {
const status = await management.request('GET', '/_dsh_platform/api/v1/status')
lastPlatformStatus = status
lastPlatformStatusAt = now()
return status
}
const platformStatus = async () => {
try {
return await refreshPlatformStatus()
} catch (error) {
if (lastPlatformStatus !== undefined && now() - lastPlatformStatusAt <= platformStatusCacheMs) {
return lastPlatformStatus
}
throw error
}
}
const server = gatewayFactory({
polyfill: config.polyfill,
trustedHosts: config.trustedHosts,
managementSocketPath,
maintenanceSocketPath,
browserAuthentication,
dshUpstreamAuthentication,
platformStatus,
report: record,
surface: 'compat',
})
const managementServer = managementPort === null || managementPort === undefined
? null
: gatewayFactory({
polyfill: false,
trustedHosts: config.trustedHosts,
managementSocketPath,
maintenanceSocketPath,
browserAuthentication: isolatedBrowserAuthentication,
dshUpstreamAuthentication,
platformStatus,
report: record,
surface: 'management',
})
const platformStatusTimer = setInterval(() => {
void refreshPlatformStatus().catch(() => {})
}, platformStatusPollMs)
platformStatusTimer.unref?.()
void refreshPlatformStatus().catch(() => {})
let resolveSignal
const receivedSignal = new Promise(resolve => { resolveSignal = resolve })
const onSignal = signal => resolveSignal(signal)
const onSigint = () => onSignal('SIGINT')
const onSigterm = () => onSignal('SIGTERM')
signalSource.once('SIGINT', onSigint)
signalSource.once('SIGTERM', onSigterm)
try {
await record('gateway.starting', {
externalHost, externalPort,
...(managementServer === null ? {} : { managementHost, managementPort }),
})
await listen(server, externalPort, externalHost)
if (managementServer !== null) await listen(managementServer, managementPort, managementHost)
await record('gateway.ready', {
externalHost, externalPort,
...(managementServer === null ? {} : { managementHost, managementPort }),
})
const serverFailed = new Promise((_, reject) => server.once('error', reject))
const signal = await Promise.race([receivedSignal, serverFailed])
await record('gateway.stopping', { signal })
await closeGatewayServer(server)
if (managementServer !== null) await closeGatewayServer(managementServer)
await record('gateway.stopped')
return 0
} catch (error) {
await record('gateway.fatal', { error })
if (server.listening || managementServer?.listening) {
try {
if (server.listening) await closeGatewayServer(server)
if (managementServer?.listening) await closeGatewayServer(managementServer)
} catch (closeError) {
await record('gateway.stop.failed', { error: closeError, cause: error instanceof Error ? error.message : String(error) })
}
}
throw error
} finally {
clearInterval(platformStatusTimer)
signalSource.off('SIGINT', onSigint)
signalSource.off('SIGTERM', onSigterm)
}
}
@@ -0,0 +1,60 @@
export const RANDOM_UUID_POLYFILL = '<script>(function(){try{var c=globalThis.crypto;if(c&&typeof c.randomUUID!=="function"&&typeof c.getRandomValues==="function"){c.randomUUID=function(){var b=c.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h="";for(var i=0;i<16;i++){h+=b[i].toString(16).padStart(2,"0")}return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}}catch(e){}})();</script>'
export const LIFECYCLE_TRANSITION_GUARD = `<script>(function(){
try{
var api="/_dsh_platform/plugin-api/v1",wait="/_dsh_gateway/wait",navigating=false;
function transition(value){return ["restarting","switching","recovering","restart-failed"].includes(value.operation)||["snapshotting-data","switching","probation","restoring-data"].includes(value.update&&value.update.status)||["starting","stopping","stopped","restarting","recovering","failed"].includes(value.dshLifecycle&&value.dshLifecycle.state)}
function navigate(){if(navigating||location.pathname===wait)return;navigating=true;var back=location.pathname+location.search+location.hash;location.replace(wait+"?return="+encodeURIComponent(back))}
async function inspect(){try{var response=await fetch(api+"/status",{cache:"no-store"}),value=await response.json();if(transition(value))navigate()}catch(e){}}
var events=new EventSource(api+"/events");events.addEventListener("state",inspect);inspect();
}catch(e){}
})();</script>`
export const SESSION_VALIDITY_GUARD = `<script>(function(){
try{
var checking=false;
async function inspect(){if(checking)return;checking=true;try{var response=await fetch("/_dsh_platform/auth/session-context",{cache:"no-store",credentials:"same-origin"});if(response.status===401)location.reload()}catch(e){}finally{checking=false}}
setInterval(inspect,5000);addEventListener("focus",inspect);document.addEventListener("visibilitychange",function(){if(document.visibilityState==="visible")inspect()});
}catch(e){}
})();</script>`
export const PLUGIN_RECOVERY_GUARD = `<script>(function(){
try{
var nativeFetch=globalThis.fetch.bind(globalThis),markerKey="dsh-platform:plugin-load-recovery",waitPath="/_dsh_gateway/wait",failurePath="/_dsh_gateway/plugin-failure",navigating=false;
function plugin(url){var match=url.pathname.match(/^\\/plugins\\/(.+)\\/client\\.js$/);if(!match)return null;try{return decodeURIComponent(match[1])}catch(e){return match[1]}}
function target(input){try{return new URL(typeof input==="string"?input:input.url,location.href)}catch(e){return null}}
function marker(){try{var value=JSON.parse(sessionStorage.getItem(markerKey));return value&&Date.now()-value.createdAt<120000?value:null}catch(e){return null}}
function save(value){try{sessionStorage.setItem(markerKey,JSON.stringify(value))}catch(e){}}
function report(event,fields){nativeFetch("/_dsh_gateway/client-event",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(Object.assign({event:event,pathname:location.pathname},fields)),keepalive:true}).catch(function(){})}
async function json(path){try{var response=await nativeFetch(path,{cache:"no-store"});return await response.json()}catch(e){return {}}}
function transition(value){return ["restarting","switching","recovering","restart-failed"].includes(value.operation)||["snapshotting-data","switching","probation","restoring-data"].includes(value.update&&value.update.status)||["starting","stopping","stopped","restarting","recovering","failed"].includes(value.dshLifecycle&&value.dshLifecycle.state)}
async function recover(url,pluginId,reason){
var values=await Promise.all([json("/_dsh_gateway/readiness"),json("/_dsh_platform/plugin-api/v1/status")]),readiness=values[0],status=values[1],lifecycle=status.dshLifecycle||{},updated=Date.parse(lifecycle.updatedAt),recent=typeof lifecycle.taskId==="string"&&Number.isFinite(updated)&&Date.now()-updated<30000,eligible=transition(status)||recent||readiness.pluginRecoveryEligible===true;
var identity=typeof lifecycle.taskId==="string"?lifecycle.taskId:(lifecycle.updatedAt||readiness.state||"unknown"),previous=marker(),attempt=previous&&previous.identity===identity?previous.attempt+1:1,fields={pluginId:pluginId,revision:url.searchParams.get("rev"),lifecycleState:lifecycle.state||readiness.state||null,lifecycleTaskId:lifecycle.taskId||null,recoveryAttempt:attempt,reason:reason};
if(attempt>=3){report("browser.plugin-load.recovery.failed",Object.assign({level:"error"},fields));save({identity:identity,taskId:lifecycle.taskId||null,url:url.pathname+url.search,pluginId:pluginId,attempt:attempt,createdAt:Date.now(),completed:false});if(!navigating){navigating=true;location.replace(failurePath)}return true}
if(!eligible){report("browser.plugin-load.failed",Object.assign({level:"error"},fields));return false}
report("browser.plugin-load.failed",Object.assign({level:"warning"},fields));
save({identity:identity,taskId:lifecycle.taskId||null,url:url.pathname+url.search,pluginId:pluginId,attempt:attempt,createdAt:Date.now(),completed:false});
report("browser.plugin-load.recovery.started",Object.assign({level:"warning"},fields));
if(!navigating){navigating=true;var back=location.pathname+location.search+location.hash;location.replace(waitPath+"?return="+encodeURIComponent(back))}return true
}
function completed(url,pluginId){var value=marker();if(!value||value.completed||value.url!==url.pathname+url.search)return;value.completed=true;save(value);report("browser.plugin-load.recovery.completed",{level:"info",pluginId:pluginId,revision:url.searchParams.get("rev"),lifecycleTaskId:value.taskId||null,recoveryAttempt:value.attempt})}
globalThis.fetch=function(input,init){var url=target(input),pluginId=url&&plugin(url),pending=nativeFetch(input,init);if(!pluginId)return pending;return pending.then(function(response){if(response.ok){completed(url,pluginId);return response}if(response.status!==502&&response.status!==503)return response;return recover(url,pluginId,"HTTP "+response.status).then(function(active){return active?new Promise(function(){}):response})},function(error){return recover(url,pluginId,error&&error.name||"network error").then(function(active){if(active)return new Promise(function(){});throw error})})}
function errorUrl(value){
var candidates=[value&&value.filename,value&&value.error&&value.error.stack,value&&value.error&&value.error.message,value&&value.reason&&value.reason.stack,value&&value.reason&&value.reason.message];
for(var i=0;i<candidates.length;i++){if(typeof candidates[i]!=="string")continue;var match=candidates[i].match(/(?:https?:\\/\\/[^\\s)]+)?(\\/plugins\\/(?:@[^/\\s]+\\/)?[^/\\s]+\\/client\\.js(?:\\?[^\\s)]+)?)/);if(!match)continue;var url=target(match[1]);if(url)return url}
return null
}
function handleError(value,reason){var url=errorUrl(value);if(!url)return;var pluginId=plugin(url);if(!pluginId)return;recover(url,pluginId,reason).catch(function(){})}
addEventListener("error",function(event){handleError(event,"plugin module error")},true);
addEventListener("unhandledrejection",function(event){handleError({reason:event.reason},"plugin module rejection")},true);
}catch(e){}
})();</script>`
export function injectRandomUuidPolyfill(html) {
const match = /<head(?:\s[^>]*)?>/i.exec(html)
const injected = `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}`
if (match === null) return `${injected}${html}`
const end = match.index + match[0].length
return `${html.slice(0, end)}${injected}${html.slice(end)}`
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,56 @@
function parseRequestAuthority(value) {
if (
value === ''
|| /[\s/@?#]/.test(value)
|| value.endsWith(':')
) return undefined
try {
const parsed = new URL(`http://${value}`)
return parsed.hostname === '' ? undefined : parsed
} catch {
return undefined
}
}
export function isLoopbackHostname(hostname) {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
function matchesTrustedAuthority(requestUrl, trustedHosts) {
if (trustedHosts.wildcard) return true
return trustedHosts.authorities.some((entry) => (
entry.anyPort ? entry.hostname === requestUrl.hostname : entry.matchAuthority === requestUrl.host
))
}
export function inspectExternalRequest(headers, trustedHosts, {
allowCrossOrigin = false,
allowUntrustedAuthority = false,
} = {}) {
const host = headers.host
if (typeof host !== 'string') return Object.freeze({ accepted: false, reason: 'missing-host' })
const hostUrl = parseRequestAuthority(host)
if (hostUrl === undefined) return Object.freeze({ accepted: false, reason: 'invalid-host' })
if (!allowUntrustedAuthority && !isLoopbackHostname(hostUrl.hostname) && !matchesTrustedAuthority(hostUrl, trustedHosts)) {
return Object.freeze({ accepted: false, reason: 'untrusted-host' })
}
if (!allowCrossOrigin && headers['sec-fetch-site'] === 'cross-site') {
return Object.freeze({ accepted: false, reason: 'cross-site' })
}
const origin = headers.origin
if (!allowCrossOrigin && origin !== undefined) {
if (typeof origin !== 'string') return Object.freeze({ accepted: false, reason: 'invalid-origin' })
try {
if (new URL(origin).host !== hostUrl.host) {
return Object.freeze({ accepted: false, reason: 'origin-mismatch' })
}
} catch {
return Object.freeze({ accepted: false, reason: 'invalid-origin' })
}
}
return Object.freeze({ accepted: true, externalAuthority: hostUrl.host })
}
@@ -0,0 +1,11 @@
{
"name": "dsh-docker-gateway",
"private": true,
"type": "module",
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=24"
}
}
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict'
import { createServer, request as httpRequest } from 'node:http'
import { connect as netConnect } from 'node:net'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { parseTrustedHosts } from '../lib/config.mjs'
import { closeGatewayServer, createGatewayServer, HEALTH_PATH } from '../lib/proxy.mjs'
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
return server.address().port
}
async function close(server) {
server.closeAllConnections?.()
await new Promise(resolve => server.close(resolve))
}
function request(port, { path = '/', method = 'GET', headers = {}, body = '' } = {}) {
return new Promise((resolve, reject) => {
const outgoing = httpRequest({ hostname: '127.0.0.1', port, path, method, headers }, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => resolve({
status: response.statusCode,
headers: response.headers,
body: Buffer.concat(chunks).toString('utf8'),
}))
})
outgoing.once('error', reject)
outgoing.end(body)
})
}
const initialized = Object.freeze({ status: async () => ({ state: 'initialized' }), handle: async () => false })
test('Gateway fails closed without an authenticated browser session while health remains available', async () => {
let upstreamRequests = 0
const upstream = createServer((_incoming, response) => { upstreamRequests += 1; response.end('upstream') })
const upstreamPort = await listen(upstream)
const gateway = createGatewayServer({ trustedHosts: parseTrustedHosts({}), upstreamPort })
const port = await listen(gateway)
try {
assert.equal((await request(port, { path: HEALTH_PATH, headers: { host: '127.0.0.1' } })).status, 200)
const navigation = await request(port, { headers: { accept: 'text/html', host: '127.0.0.1' } })
assert.equal(navigation.status, 303)
assert.match(navigation.headers.location, /^\/_dsh_platform\/auth\//)
assert.equal((await request(port, { path: '/api/sessions', headers: { host: '127.0.0.1' } })).status, 401)
assert.equal(upstreamRequests, 0)
} finally {
await Promise.all([closeGatewayServer(gateway), close(upstream)])
}
})
test('Gateway reports an unavailable authentication backend as a service failure', async () => {
const unavailable = new Error('access socket unavailable')
unavailable.browserAuthenticationBackend = true
const browserAuthentication = {
status: async () => { throw unavailable },
handle: async () => false,
}
const gateway = createGatewayServer({
trustedHosts: parseTrustedHosts({}), browserAuthentication, upstreamPort: 1,
})
const port = await listen(gateway)
try {
assert.equal((await request(port, {
path: HEALTH_PATH, headers: { host: '127.0.0.1' },
})).status, 200)
const unavailable = await request(port, { headers: { host: '127.0.0.1', accept: 'text/html' } })
assert.equal(unavailable.status, 503)
assert.match(unavailable.body, /authentication service unavailable/)
} finally { await closeGatewayServer(gateway) }
})
test('DSH and Management sessions authorize only their own route surfaces', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-gateway-session-domains-'))
const socketPath = join(root, 'management.sock')
const management = createServer((incoming, response) => response.end(`management:${incoming.url}`))
await new Promise((resolve, reject) => {
management.once('error', reject)
management.listen(socketPath, resolve)
})
const upstream = createServer((_incoming, response) => response.end('dsh'))
const upstreamPort = await listen(upstream)
const browserAuthentication = {
...initialized,
validateDsh: async request => ({ authenticated: request.headers.cookie === 'dsh=yes' }),
authorizePlugin: async request => request.headers.cookie === 'dsh=yes'
? { authorized: true, capability: { token: 'plugin-capability' } }
: { authorized: false },
validateManagement: async request => ({ authenticated: request.headers.cookie === 'management=yes' }),
enterManagement: async (_request, response) => { response.writeHead(303, { location: '/_dsh_platform/auth/management' }); response.end() },
}
const gateway = createGatewayServer({
trustedHosts: parseTrustedHosts({}), browserAuthentication,
managementSocketPath: socketPath, upstreamPort,
})
const port = await listen(gateway)
try {
assert.equal((await request(port, { headers: { host: '127.0.0.1', cookie: 'dsh=yes' } })).body, 'dsh')
assert.equal((await request(port, {
path: '/_dsh_platform/plugin-api/v1/status', headers: { host: '127.0.0.1', cookie: 'dsh=yes' },
})).body, 'management:/_dsh_platform/api/v1/status')
assert.equal((await request(port, {
path: '/_dsh_platform/api/v1/status', headers: { host: '127.0.0.1', cookie: 'dsh=yes' },
})).status, 401)
assert.equal((await request(port, {
path: '/_dsh_platform/api/v1/status', headers: { host: '127.0.0.1', cookie: 'management=yes' },
})).body, 'management:/_dsh_platform/api/v1/status')
assert.equal((await request(port, {
path: '/', headers: { host: '127.0.0.1', cookie: 'management=yes' },
})).status, 401)
} finally {
await Promise.all([closeGatewayServer(gateway), close(management), close(upstream)])
}
})
test('terminal WebSocket requires Management rather than DSH authentication', async () => {
const browserAuthentication = {
...initialized,
validateDsh: async () => ({ authenticated: true }),
validateManagement: async () => ({ authenticated: false }),
enterManagement: async () => {},
}
const gateway = createGatewayServer({ trustedHosts: parseTrustedHosts({}), browserAuthentication, upstreamPort: 1 })
const port = await listen(gateway)
const client = netConnect(port, '127.0.0.1')
try {
await new Promise((resolve, reject) => { client.once('connect', resolve); client.once('error', reject) })
client.write([
'GET /_dsh_platform/api/v1/terminal/sessions/123e4567-e89b-42d3-a456-426614174000/stream HTTP/1.1',
'Host: 127.0.0.1', 'Connection: Upgrade', 'Upgrade: websocket', '', '',
].join('\r\n'))
const response = await new Promise((resolve, reject) => {
client.once('data', data => resolve(data.toString('utf8')))
client.once('error', reject)
})
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/)
} finally {
client.destroy()
await closeGatewayServer(gateway)
}
})
@@ -0,0 +1,486 @@
import assert from 'node:assert/strict'
import { createServer, request as httpRequest } from 'node:http'
import { connect as netConnect } from 'node:net'
import test from 'node:test'
import { DshAvailability, availabilityPage, language } from '../lib/availability.mjs'
import { closeGatewayServer, createGatewayServer as createGatewayServerBase, PLUGIN_FAILURE_PATH, READINESS_PATH, WAIT_PATH, safeReturnPath } from '../lib/proxy.mjs'
import { parseTrustedHosts } from '../lib/config.mjs'
const AUTHENTICATED_BROWSER = Object.freeze({
status: async () => ({ state: 'initialized' }),
validateDsh: async () => ({ authenticated: true }),
validateManagement: async () => ({ authenticated: true }),
enterManagement: async () => { throw new Error('unexpected Management login') },
handle: async () => false,
})
const UNAUTHENTICATED_BROWSER = Object.freeze({
status: async () => ({ state: 'initialized' }),
validateDsh: async () => ({ authenticated: false }),
validateManagement: async () => ({ authenticated: false }),
enterManagement: async () => { throw new Error('unexpected Management login') },
handle: async () => false,
})
function createGatewayServer(options) {
return createGatewayServerBase({
...options,
browserAuthentication: options.browserAuthentication ?? AUTHENTICATED_BROWSER,
})
}
const ISOLATED_UNAUTHENTICATED_BROWSER = Object.freeze({
...UNAUTHENTICATED_BROWSER,
status: async () => ({ state: 'initialized', account: {
managementAccess: {
mode: 'isolated',
isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://management.example' },
},
} }),
})
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
return server.address().port
}
function request(port, path = '/', { method = 'GET', accept = 'text/html', headers = {} } = {}) {
return new Promise((resolve, reject) => {
const outgoing = httpRequest({
hostname: '127.0.0.1', port, path, method,
headers: { host: 'dsh.example', accept, ...headers },
}, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => resolve({
status: response.statusCode,
headers: response.headers,
body: Buffer.concat(chunks).toString('utf8'),
}))
})
outgoing.once('error', reject)
outgoing.end()
})
}
async function unavailableGateway({ platform = {}, availability = new DshAvailability(), probe = async () => false, report, browserAuthentication } = {}) {
const placeholder = createServer()
const unavailablePort = await listen(placeholder)
await new Promise(resolve => placeholder.close(resolve))
const gateway = createGatewayServer({
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
upstreamPort: unavailablePort,
platformStatus: async () => platform,
availability,
probe,
browserAuthentication,
report,
probeIntervalMs: 60_000,
})
return { gateway, port: await listen(gateway) }
}
test('authenticated DSH traffic waits for authoritative Bootstrap publication', async t => {
let upstreamRequests = 0
const upstream = createServer((_request, response) => {
upstreamRequests += 1
response.writeHead(200, { 'content-type': 'text/plain' })
response.end('published')
})
const upstreamPort = await listen(upstream)
t.after(() => upstream.close())
let published = false
const gateway = createGatewayServer({
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
upstreamPort,
platformStatus: async () => ({ dshLifecycle: { state: 'running' } }),
probe: async () => true,
dshUpstreamAuthentication: {
cookie: async () => null,
publicReady: async () => published,
},
probeIntervalMs: 60_000,
})
const port = await listen(gateway)
try {
const socket = netConnect(port, '127.0.0.1')
await new Promise((resolve, reject) => {
socket.once('connect', resolve)
socket.once('error', reject)
})
socket.write('GET /events HTTP/1.1\r\nHost: dsh.example\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n')
const upgrade = await new Promise((resolve, reject) => {
socket.once('data', data => resolve(data.toString('utf8')))
socket.once('error', reject)
})
assert.match(upgrade, /^HTTP\/1\.1 503 Service Unavailable/)
socket.destroy()
const waiting = await request(port)
assert.equal(waiting.status, 200)
assert.match(waiting.body, /DeepSeek Harness is starting/)
assert.equal(upstreamRequests, 0)
assert.deepEqual(JSON.parse((await request(port, READINESS_PATH, { accept: 'application/json' })).body).ready, false)
const dedicatedWaiting = await request(port, `${WAIT_PATH}?return=%2Fsessions%2Fcurrent`)
assert.equal(dedicatedWaiting.status, 200)
assert.match(dedicatedWaiting.body, /DeepSeek Harness is starting/)
assert.match(dedicatedWaiting.body, /\/sessions\/current/)
published = true
const ready = await request(port)
assert.equal(ready.status, 200)
assert.equal(ready.body, 'published')
assert.equal(upstreamRequests, 1)
assert.equal(JSON.parse((await request(port, READINESS_PATH, { accept: 'application/json' })).body).ready, true)
} finally {
await closeGatewayServer(gateway)
}
})
test('an intentional stopped state does not report an upstream proxy failure', async () => {
const reports = []
const context = await unavailableGateway({
platform: { dshLifecycle: { state: 'stopped' } },
report: (message, fields) => { reports.push({ message, fields }) },
})
try {
const response = await request(context.port)
assert.equal(response.status, 200)
assert.match(response.body, /DeepSeek Harness is stopped/)
await new Promise(resolve => setImmediate(resolve))
assert.equal(reports.some(entry => entry.message === 'gateway.upstream.failed'), false)
} finally {
await closeGatewayServer(context.gateway)
}
})
test('an unauthenticated top-level navigation sees classified DSH recovery before the login page', async () => {
const gateway = createGatewayServer({
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
upstreamPort: 1,
platformStatus: async () => ({ dshLifecycle: { state: 'failed', error: 'private runtime detail' } }),
probe: async () => false,
browserAuthentication: UNAUTHENTICATED_BROWSER,
probeIntervalMs: 60_000,
})
const port = await listen(gateway)
try {
const page = await request(port)
assert.equal(page.status, 200)
assert.match(page.body, /DeepSeek Harness could not start/)
assert.match(page.body, /href="\/_dsh_platform\/console\/"/)
assert.doesNotMatch(page.body, /private runtime detail|autocomplete="current-password"/)
assert.equal((await request(port, '/api/sessions', { accept: 'application/json' })).status, 503)
} finally {
await closeGatewayServer(gateway)
}
})
test('official-style holding page is self-contained and replaces the spinner with platform status', () => {
const page = availabilityPage('switching', { 'accept-language': 'zh-CN' })
assert.match(page, /HARNESS/)
assert.match(page, /正在切换 DeepSeek Harness 运行版本/)
assert.match(page, /href="\/_dsh_platform\/console\/"/)
assert.match(page, /打开 DSH 管理中心进行检查和恢复/)
assert.match(page, new RegExp(READINESS_PATH))
assert.match(page, /prefers-color-scheme:light/)
assert.match(page, /name="viewport"/)
assert.match(page, /html\{height:100%\}body\{min-height:100%;margin:0;display:grid;place-items:center;/)
assert.doesNotMatch(page, /100(?:d|s|l)?vh/)
assert.match(page, /max-width:520px/)
assert.match(page, /management\.href=value\.managementHref/)
assert.doesNotMatch(page, /spinner|Loading plugins/)
assert.doesNotMatch(page, /letter-spacing:-/)
})
test('persistent plugin failure page is terminal, localized, and links to Management', async () => {
const page = availabilityPage('plugin-failed', { 'accept-language': 'zh-CN' }, { poll: false, allowRefresh: true })
assert.match(page, /DeepSeek Harness 插件持续加载失败/)
assert.match(page, /打开 DSH 管理中心进行检查和恢复/)
assert.match(page, /刷新页面/)
assert.doesNotMatch(page, /setTimeout\(check/)
assert.doesNotMatch(availabilityPage('failed', {}, { poll: false }), /Reload page/)
const context = await unavailableGateway()
try {
const response = await request(context.port, PLUGIN_FAILURE_PATH, {
headers: { 'accept-language': 'zh-CN' },
})
assert.equal(response.status, 200)
assert.match(response.body, /DeepSeek Harness 插件持续加载失败/)
assert.equal((await request(context.port, PLUGIN_FAILURE_PATH, { method: 'POST' })).status, 405)
} finally {
await closeGatewayServer(context.gateway)
}
})
test('Gateway temporary DSH pages link to the configured isolated Management entry', async () => {
const context = await unavailableGateway({ browserAuthentication: ISOLATED_UNAUTHENTICATED_BROWSER })
try {
const response = await request(context.port, PLUGIN_FAILURE_PATH)
assert.equal(response.status, 200)
assert.match(response.body, /href="https:\/\/management\.example\/"/)
assert.doesNotMatch(response.body, /href="\/_dsh_platform\/console\/"/)
} finally {
await closeGatewayServer(context.gateway)
}
})
test('Gateway readiness publishes the current Management entry for an already-open temporary page', async () => {
let access = { state: 'initialized', account: { managementAccess: { mode: 'compat' } } }
const browserAuthentication = {
...UNAUTHENTICATED_BROWSER,
status: async () => access,
}
const context = await unavailableGateway({ browserAuthentication })
try {
const compatible = JSON.parse((await request(context.port, READINESS_PATH)).body)
assert.equal(compatible.managementHref, '/_dsh_platform/console/')
access = await ISOLATED_UNAUTHENTICATED_BROWSER.status()
const isolated = JSON.parse((await request(context.port, READINESS_PATH)).body)
assert.equal(isolated.managementHref, 'https://management.example/')
} finally {
await closeGatewayServer(context.gateway)
}
})
test('holding pages prefer the last DSH locale cookie over browser language', () => {
assert.equal(language({ cookie: 'theme=dark; dsh_locale=en', 'accept-language': 'zh-CN' }), 'en')
assert.equal(language({ cookie: 'dsh_locale=zh', 'accept-language': 'en-US' }), 'zh')
assert.equal(language({ cookie: 'dsh_locale=fr', 'accept-language': 'en-US' }), 'en')
assert.equal(language({ 'accept-language': 'fr-FR, zh-CN;q=0.8, en;q=0.5' }), 'zh')
assert.match(availabilityPage('recovering', { cookie: 'dsh_locale=zh', 'accept-language': 'en-US' }), /意外停止/)
assert.match(availabilityPage('recovering', { cookie: 'dsh_locale=en', 'accept-language': 'zh-CN' }), /Open DSH Management Console for diagnostics and recovery/)
})
test('every classified outage page links to the standalone Platform Management console', () => {
for (const state of ['starting', 'stopping', 'stopped', 'restarting', 'switching', 'runtime-recovering', 'recovering', 'failed', 'unavailable']) {
const page = availabilityPage(state, { 'accept-language': 'en-US' })
assert.match(page, /href="\/_dsh_platform\/console\/"/)
}
})
test('intentional DSH restarts have explicit holding and failure states', () => {
const availability = new DshAvailability()
assert.equal(availability.classify({ operation: 'restarting' }), 'restarting')
assert.equal(availability.classify({ operation: 'restart-failed' }), 'failed')
assert.match(availabilityPage('restarting', { 'accept-language': 'zh-CN' }), /正在重新启动/)
assert.equal(availability.classify({ dshLifecycle: { state: 'stopping' } }), 'stopping')
assert.equal(availability.classify({ dshLifecycle: { state: 'stopped' } }), 'stopped')
assert.equal(availability.classify({ dshLifecycle: { state: 'recovering', attempt: 2 } }), 'recovering')
assert.match(availabilityPage('recovering', { 'accept-language': 'zh-CN' }, {
lifecycle: { attempt: 2, maxAttempts: 3 },
}), /第 2 \/ 3 次/)
})
test('holding-page return paths stay same-origin and reject ambiguous inputs', () => {
assert.equal(safeReturnPath('/sessions/current?view=chat#latest'), '/sessions/current?view=chat#latest')
for (const value of [null, '', '//example.com/path', '/\\example.com', 'https://example.com/', '/bad\npath']) {
assert.equal(safeReturnPath(value), '/')
}
const page = availabilityPage('restarting', {}, { returnPath: '/</script><script>alert(1)</script>' })
assert.doesNotMatch(page, /const returnPath="\/<\/script>/)
assert.match(page, /\\u003c\/script>/)
})
test('cold start serves a holding page while API and WebSocket-shaped HTTP requests receive 503', async () => {
const { gateway, port } = await unavailableGateway()
try {
const page = await request(port)
assert.equal(page.status, 200)
assert.match(page.body, /DeepSeek Harness is starting/)
assert.equal(page.headers['cache-control'], 'no-store')
assert.equal((await request(port, '/api/sessions', { accept: 'application/json' })).status, 503)
assert.equal((await request(port, '/', { method: 'POST', accept: 'text/html' })).status, 503)
} finally {
await closeGatewayServer(gateway)
}
})
test('an unresponsive management service does not block the cold-start holding page', async () => {
const placeholder = createServer()
const unavailablePort = await listen(placeholder)
await new Promise(resolve => placeholder.close(resolve))
const gateway = createGatewayServer({
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
upstreamPort: unavailablePort,
platformStatus: () => new Promise(() => {}),
probe: async () => false,
probeIntervalMs: 60_000,
})
const port = await listen(gateway)
try {
const startedAt = Date.now()
const page = await request(port)
assert.equal(page.status, 200)
assert.match(page.body, /DeepSeek Harness is starting/)
assert.ok(Date.now() - startedAt < 1_000)
} finally {
await closeGatewayServer(gateway)
}
})
test('cold-start management status failures remain diagnostic warnings', async () => {
const reported = []
const gateway = createGatewayServer({
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
upstreamPort: 1,
platformStatus: async () => { throw Object.assign(new Error('socket is not ready'), { code: 'ENOENT' }) },
probe: async () => false,
probeIntervalMs: 60_000,
report: async (message, fields) => reported.push({ message, fields }),
})
const port = await listen(gateway)
try {
assert.equal((await request(port)).status, 200)
await new Promise(resolve => setImmediate(resolve))
assert.equal(reported.length, 1)
assert.equal(reported[0].message, 'gateway.platform-status.failed')
assert.equal(reported[0].fields.level, 'warning')
assert.equal(reported[0].fields.error.code, 'ENOENT')
} finally {
await closeGatewayServer(gateway)
}
})
test('platform switching, recovery, and startup failure select distinct page messages', async () => {
for (const [platform, expected] of [
[{ operation: 'switching' }, /Switching the DeepSeek Harness runtime/],
[{ operation: 'recovering' }, /Restoring the DeepSeek Harness runtime/],
[{ recoveryMode: 'candidate failed' }, /DeepSeek Harness could not start/],
]) {
const { gateway, port } = await unavailableGateway({ platform })
try {
const page = await request(port)
assert.equal(page.status, 200)
assert.match(page.body, expected)
assert.equal((await request(port, '/api/status', { accept: 'application/json' })).status, 503)
} finally {
await closeGatewayServer(gateway)
}
}
})
test('dedicated holding route preserves a safe return path and redirects when ready', async () => {
let ready = false
const platform = { dshLifecycle: { state: 'stopping' } }
const context = await unavailableGateway({
platform,
probe: async () => ready,
})
try {
const target = '/sessions/current?view=chat#latest'
const waiting = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`)
assert.equal(waiting.status, 200)
assert.match(waiting.body, /DeepSeek Harness is stopping/)
assert.match(waiting.body, /location\.replace\(returnPath\)/)
assert.match(waiting.body, /\/sessions\/current\?view=chat#latest/)
const invalid = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent('//example.com/')}`)
assert.equal(invalid.status, 200)
assert.match(invalid.body, /const returnPath="\/"/)
ready = true
const premature = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`)
assert.equal(premature.status, 200)
assert.match(premature.body, /DeepSeek Harness is stopping/)
const prematureReadiness = await request(context.port, READINESS_PATH)
assert.equal(prematureReadiness.status, 503)
assert.equal(JSON.parse(prematureReadiness.body).state, 'stopping')
platform.dshLifecycle.state = 'running'
const redirected = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`)
assert.equal(redirected.status, 302)
assert.equal(redirected.headers.location, target)
assert.deepEqual(JSON.parse((await request(context.port, READINESS_PATH)).body), {
ready: true, state: 'ready', pluginRecoveryEligible: false,
managementHref: '/_dsh_platform/console/',
})
assert.equal((await request(context.port, WAIT_PATH, { method: 'POST' })).status, 405)
} finally {
await closeGatewayServer(context.gateway)
}
})
test('WebSocket receives 503 during a classified DSH outage', async () => {
const reports = []
const { gateway, port } = await unavailableGateway({
platform: { operation: 'switching' },
report: (message, fields) => reports.push({ message, fields }),
})
const socket = netConnect(port, '127.0.0.1')
try {
await new Promise((resolve, reject) => {
socket.once('connect', resolve)
socket.once('error', reject)
})
socket.write('GET /events HTTP/1.1\r\nHost: dsh.example\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n')
const response = await new Promise((resolve, reject) => {
socket.once('data', data => resolve(data.toString('utf8')))
socket.once('error', reject)
})
assert.match(response, /^HTTP\/1\.1 503 Service Unavailable/)
await new Promise(resolve => setImmediate(resolve))
const failure = reports.find(entry => entry.message === 'gateway.websocket.failed')
assert.equal(failure?.fields.level, 'warning')
assert.equal(failure?.fields.upstream, 'dsh')
} finally {
socket.destroy()
await closeGatewayServer(gateway)
}
})
test('one unexpected local failure remains 502 until repeated probes confirm unavailability', async () => {
let now = 0
const availability = new DshAvailability({ now: () => now, failures: 3, failureWindowMs: 1_500 })
availability.observe(true)
const context = await unavailableGateway({ availability })
try {
assert.equal((await request(context.port)).status, 502)
availability.observe(false)
now = 1_600
availability.observe(false)
const recovered = await request(context.port)
assert.equal(recovered.status, 200)
assert.match(recovered.body, /temporarily unavailable/)
} finally {
await closeGatewayServer(context.gateway)
}
})
test('a Bootstrap candidate handoff serves the switching page instead of a bare 502', async () => {
const context = await unavailableGateway({ platform: { update: { status: 'building-candidate' } } })
try {
const response = await request(context.port)
assert.equal(response.status, 200)
assert.match(response.body, /Switching the DeepSeek Harness runtime/)
} finally {
await closeGatewayServer(context.gateway)
}
})
test('readiness immediately reports ready and clears confirmed failure state', async () => {
let ready = false
let now = 0
const availability = new DshAvailability({ now: () => now, failures: 1, failureWindowMs: 0 })
availability.observe(true)
availability.observe(false)
const context = await unavailableGateway({ availability, probe: async () => ready })
try {
const unavailable = await request(context.port, READINESS_PATH, { accept: 'application/json' })
assert.equal(unavailable.status, 503)
assert.equal(JSON.parse(unavailable.body).state, 'unavailable')
ready = true
const healthy = await request(context.port, READINESS_PATH, { accept: 'application/json' })
assert.equal(healthy.status, 200)
assert.deepEqual(JSON.parse(healthy.body), {
ready: true, state: 'ready', pluginRecoveryEligible: false,
managementHref: '/_dsh_platform/console/',
})
assert.equal(availability.classify({}), 'unknown')
} finally {
await closeGatewayServer(context.gateway)
}
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { patchBrowserLoopback } from '../../../../environment/resources/patches/browser-loopback.mjs'
const BEFORE = 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),'
const BEFORE_WITH_TRANSPORT = 'isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),'
test('browser loopback patch changes exactly one connection classification', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-browser-loopback-patch-'))
const target = join(directory, 'client.js')
try {
await writeFile(target, `before;${BEFORE}after;`)
patchBrowserLoopback(target)
assert.equal(await readFile(target, 'utf8'), 'before;isLoopback: true,after;')
assert.throws(() => patchBrowserLoopback(target), /found 0/)
await writeFile(target, `${BEFORE}${BEFORE}`)
assert.throws(() => patchBrowserLoopback(target), /found 2/)
} finally {
await rm(directory, { recursive: true })
}
})
test('browser loopback patch accepts the transport-owned Host classification', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-browser-loopback-patch-'))
const target = join(directory, 'client.js')
try {
await writeFile(target, `before;${BEFORE_WITH_TRANSPORT}after;`)
patchBrowserLoopback(target)
assert.equal(await readFile(target, 'utf8'), 'before;isLoopback: true,after;')
await writeFile(target, `${BEFORE}${BEFORE_WITH_TRANSPORT}`)
assert.throws(() => patchBrowserLoopback(target), /found 2/)
} finally {
await rm(directory, { recursive: true })
}
})
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
loadConfig,
parseBoolean,
parseTrustedAuthority,
parseTrustedHosts,
} from '../lib/config.mjs'
import { UsageError } from '../lib/errors.mjs'
test('parseBoolean accepts only explicit Compose booleans', () => {
assert.equal(parseBoolean('FLAG', undefined, true), true)
assert.equal(parseBoolean('FLAG', 'false', true), false)
assert.throws(() => parseBoolean('FLAG', '1', true), UsageError)
})
test('parseTrustedAuthority accepts canonical host authorities', () => {
assert.deepEqual(parseTrustedAuthority('Example.COM'), {
hostname: 'example.com',
authority: 'example.com',
anyPort: true,
matchAuthority: 'example.com',
})
assert.deepEqual(parseTrustedAuthority('example.com:8443'), {
hostname: 'example.com',
authority: 'example.com:8443',
anyPort: false,
matchAuthority: 'example.com:8443',
})
assert.deepEqual(parseTrustedAuthority('[fd00::1]:3080'), {
hostname: '[fd00::1]',
authority: '[fd00::1]:3080',
anyPort: false,
matchAuthority: '[fd00::1]:3080',
})
})
test('parseTrustedAuthority rejects URL-like and noncanonical entries', () => {
for (const value of [
'https://example.com',
'example.com/path',
'user@example.com',
'example.com:',
'*.example.com',
' example.com',
]) {
assert.throws(() => parseTrustedAuthority(value), UsageError, value)
}
})
test('parseTrustedHosts supports empty, lists, deduplication, and wildcard', () => {
assert.deepEqual(parseTrustedHosts({}), { wildcard: false, authorities: [] })
const list = parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'a.example, b.example:8443,a.example' })
assert.equal(list.wildcard, false)
assert.equal(list.authorities.length, 2)
assert.deepEqual(parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*' }), {
wildcard: true,
authorities: [],
})
})
test('parseTrustedHosts rejects malformed lists', () => {
assert.throws(() => parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'a.example,,b.example' }), UsageError)
assert.throws(() => parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*,a.example' }), UsageError)
})
test('loadConfig owns only Gateway settings', async () => {
const config = await loadConfig({
DSH_DEFAULT_WORKSPACE: 'not-a-gateway-setting',
DSH_TELEMETRY_DISABLED: 'not-a-gateway-setting',
DSH_PROXY_USERNAME: 'unused',
DSH_PROXY_PASSWORD: '',
})
assert.equal(Object.hasOwn(config, 'password'), false)
assert.equal(Object.hasOwn(config, 'username'), false)
assert.equal(Object.hasOwn(config, 'platformPassword'), false)
assert.equal(Object.hasOwn(config, 'workspace'), false)
assert.equal(Object.hasOwn(config, 'telemetryDisabled'), false)
assert.equal(config.managementPort, 3081)
assert.doesNotReject(() => loadConfig({
DSH_PROXY_USERNAME: 'invalid:name',
DSH_PROXY_PASSWORD: 'secret',
DSH_PLATFORM_PASSWORD: 'legacy-secret',
}))
})
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { patchDirectoryPicker } from '../../../../environment/resources/patches/directory-picker.mjs'
test('directory picker patch changes exactly one default without touching explicit paths', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-directory-patch-'))
const target = join(directory, 'index.js')
try {
await writeFile(target, 'before;const target = resolve(path ?? home);after;')
patchDirectoryPicker(target)
assert.equal(
await readFile(target, 'utf8'),
'before;const target = resolve(path ?? process.env.DSH_DEFAULT_WORKSPACE ?? home);after;',
)
assert.throws(() => patchDirectoryPicker(target), /found 0/)
await writeFile(target, 'const target = resolve(path ?? home);const target = resolve(path ?? home);')
assert.throws(() => patchDirectoryPicker(target), /found 2/)
} finally {
await rm(directory, { recursive: true })
}
})
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { chmod, mkdtemp } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { DshUpstreamAuthentication } from '../lib/dsh-upstream-auth.mjs'
async function listen(server, target, host) {
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(target, host, resolve)
})
}
test('exchanges and caches the private DSH launch URL', async t => {
const root = await mkdtemp(join(tmpdir(), 'dsh-upstream-auth-'))
const socketPath = join(root, 'lifecycle.sock')
let exchanges = 0
const dsh = createServer((request, response) => {
exchanges += 1
assert.equal(request.url, '/?token=fixture-token')
response.writeHead(303, {
location: '/',
'set-cookie': 'dsh-auth-fixture=signed; Path=/; HttpOnly; SameSite=Strict',
})
response.end()
})
await listen(dsh, 0, '127.0.0.1')
const port = dsh.address().port
let generation = 'generation-one'
let publicReady = false
const lifecycle = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({
generation,
publicReady,
ready: true,
readyUrl: `http://127.0.0.1:${String(port)}/?token=fixture-token`,
}))
})
await listen(lifecycle, socketPath)
await chmod(socketPath, 0o600)
t.after(() => { dsh.close(); lifecycle.close() })
const authentication = new DshUpstreamAuthentication({ socketPath, port })
assert.equal(await authentication.publicReady(), false)
assert.equal(await authentication.cookie(), null)
assert.equal(exchanges, 0)
publicReady = true
assert.equal(await authentication.publicReady(), true)
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
assert.equal(exchanges, 1)
generation = 'generation-two'
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
assert.equal(exchanges, 2)
publicReady = false
assert.equal(await authentication.cookie(), null)
publicReady = true
assert.equal(await authentication.publicReady(), true)
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
assert.equal(exchanges, 3)
})
test('keeps probing legacy DSH readiness without inventing a cookie', async t => {
const root = await mkdtemp(join(tmpdir(), 'dsh-upstream-auth-legacy-'))
const socketPath = join(root, 'lifecycle.sock')
let now = 0
let probes = 0
const lifecycle = createServer((_request, response) => {
probes += 1
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ ready: true, readyUrl: null }))
})
await listen(lifecycle, socketPath)
t.after(() => lifecycle.close())
const authentication = new DshUpstreamAuthentication({ socketPath, now: () => now })
assert.equal(await authentication.cookie(), null)
assert.equal(await authentication.cookie(), null)
assert.equal(probes, 1)
now = 250
assert.equal(await authentication.cookie(), null)
assert.equal(probes, 2)
})
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import test from 'node:test'
import { runGateway } from '../lib/lifecycle.mjs'
class FakeServer extends EventEmitter {
listening = false
listen(port, host) {
this.listening = true
this.bound = { port, host }
queueMicrotask(() => this.emit('listening'))
}
close(callback) {
this.listening = false
queueMicrotask(() => callback())
}
closeAllConnections() {}
}
const config = Object.freeze({
polyfill: true,
trustedHosts: Object.freeze({ wildcard: false, authorities: Object.freeze([]) }),
})
test('gateway owns only its listener and binds browser authentication to Access Manager', async () => {
const signalSource = new EventEmitter()
const server = new FakeServer()
let options
const reports = []
const running = runGateway(config, {
externalHost: '127.0.0.1',
externalPort: 8080,
managementSocketPath: '/run/platform.sock',
maintenanceSocketPath: '/run/maintenance.sock',
accessClient: { request: async () => ({ state: 'recovery-required' }) },
signalSource,
report: (message, fields) => { reports.push({ message, fields }) },
gatewayFactory: value => { options = value; return server },
})
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(server.bound, { host: '127.0.0.1', port: 8080 })
assert.equal(options.managementSocketPath, '/run/platform.sock')
assert.equal(options.maintenanceSocketPath, '/run/maintenance.sock')
assert.equal(typeof options.browserAuthentication.handle, 'function')
assert.equal(typeof options.platformStatus, 'function')
signalSource.emit('SIGTERM')
assert.equal(await running, 0)
assert.equal(server.listening, false)
assert.deepEqual(reports.map(entry => entry.message), [
'gateway.starting', 'gateway.ready', 'gateway.stopping', 'gateway.stopped',
])
assert.equal(reports[2].fields.signal, 'SIGTERM')
})
test('gateway propagates listener failures without creating a DSH process', async () => {
const server = new FakeServer()
const reports = []
server.listen = () => queueMicrotask(() => server.emit('error', new Error('bind failed')))
await assert.rejects(runGateway(config, {
gatewayFactory: () => server,
accessClient: { request: async () => ({ state: 'recovery-required' }) },
signalSource: new EventEmitter(),
report: (message, fields) => { reports.push({ message, fields }) },
}), /bind failed/)
assert.equal(reports.some(entry => entry.message === 'gateway.fatal'
&& entry.fields.error.message === 'bind failed'), true)
})
test('gateway can expose an isolated Management listener from the same process', async () => {
const signalSource = new EventEmitter()
const servers = []
const running = runGateway(config, {
externalHost: '127.0.0.1',
externalPort: 8080,
managementPort: 8081,
managementHost: '127.0.0.1',
gatewayFactory: options => {
const server = new FakeServer()
server.options = options
servers.push(server)
return server
},
accessClient: { request: async () => ({ state: 'recovery-required' }) },
signalSource,
})
await new Promise(resolve => setImmediate(resolve))
assert.equal(servers.length, 2)
assert.deepEqual(servers[0].bound, { host: '127.0.0.1', port: 8080 })
assert.deepEqual(servers[1].bound, { host: '127.0.0.1', port: 8081 })
assert.equal(servers[0].options.surface, 'compat')
assert.equal(servers[1].options.surface, 'management')
signalSource.emit('SIGTERM')
assert.equal(await running, 0)
assert.equal(servers.every(server => server.listening === false), true)
})
test('gateway retains a recent platform status while Management is handed off', async () => {
const signalSource = new EventEmitter()
const server = new FakeServer()
const expected = { update: { status: 'building-candidate' } }
let requests = 0
let options
const running = runGateway(config, {
gatewayFactory: value => { options = value; return server },
accessClient: { request: async () => ({ state: 'recovery-required' }) },
signalSource,
managementClient: {
request: async () => {
requests += 1
if (requests === 1) return expected
throw Object.assign(new Error('Management is switching'), { code: 'ECONNREFUSED' })
},
},
platformStatusPollMs: 5,
})
try {
await new Promise(resolve => setTimeout(resolve, 15))
assert.deepEqual(await options.platformStatus(), expected)
assert.equal(requests >= 2, true)
} finally {
signalSource.emit('SIGTERM')
assert.equal(await running, 0)
}
})
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import vm from 'node:vm'
import { injectRandomUuidPolyfill, LIFECYCLE_TRANSITION_GUARD, PLUGIN_RECOVERY_GUARD, RANDOM_UUID_POLYFILL, SESSION_VALIDITY_GUARD } from '../lib/polyfill.mjs'
async function simulatePluginError(bundlePath, { eventType = 'error', storage = new Map() } = {}) {
let replacement
const events = []
const listeners = new Map()
const context = {
URL,
crypto: globalThis.crypto,
location: {
href: 'http://gateway.local/sessions/current',
pathname: '/sessions/current',
search: '',
hash: '',
replace: value => { replacement = value },
},
sessionStorage: {
getItem: key => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
},
addEventListener: (name, callback) => listeners.set(name, callback),
fetch: async (input, init) => {
if (input === '/_dsh_gateway/readiness') return { json: async () => ({ state: 'ready', pluginRecoveryEligible: true }) }
if (input === '/_dsh_platform/plugin-api/v1/status') return {
json: async () => ({
dshLifecycle: {
state: 'running',
taskId: 'lifecycle-task',
updatedAt: new Date().toISOString(),
},
}),
}
if (input === '/_dsh_gateway/client-event') {
events.push(JSON.parse(init.body))
return { json: async () => ({}) }
}
return { ok: true, json: async () => ({}) }
},
}
vm.runInNewContext(PLUGIN_RECOVERY_GUARD.slice('<script>'.length, -'</script>'.length), context)
if (eventType === 'error') listeners.get('error')?.({ filename: `http://gateway.local${bundlePath}` })
else if (eventType === 'unhandledrejection') listeners.get('unhandledrejection')?.({
reason: { stack: `TypeError: failed to import http://gateway.local${bundlePath}` },
})
await new Promise(resolve => setImmediate(resolve))
return { events, replacement, storage }
}
test('polyfill is inserted immediately after the head tag', () => {
assert.equal(
injectRandomUuidPolyfill('<html><head data-x="1"><title>x</title></head></html>'),
`<html><head data-x="1">${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}<title>x</title></head></html>`,
)
})
test('polyfill falls back to the start when no head exists', () => {
assert.equal(injectRandomUuidPolyfill('<main>x</main>'), `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}<main>x</main>`)
})
test('gateway lifecycle guard moves an already-open DSH page into the holding flow', async () => {
let inspect
let replacement
const context = {
EventSource: class {
addEventListener(name, callback) { if (name === 'state') inspect = callback }
},
URL,
fetch: async () => ({ json: async () => ({ dshLifecycle: { state: 'restarting' } }) }),
location: {
pathname: '/sessions/current', search: '?view=chat', hash: '#latest',
replace: value => { replacement = value },
},
}
vm.runInNewContext(LIFECYCLE_TRANSITION_GUARD.slice('<script>'.length, -'</script>'.length), context)
await new Promise(resolve => setImmediate(resolve))
assert.equal(replacement, '/_dsh_gateway/wait?return=%2Fsessions%2Fcurrent%3Fview%3Dchat%23latest')
replacement = undefined
await inspect()
assert.equal(replacement, undefined)
})
test('plugin recovery guard runs before DSH modules and bounds lifecycle recovery', () => {
assert.doesNotThrow(() => new Function(PLUGIN_RECOVERY_GUARD.slice('<script>'.length, -'</script>'.length)))
assert.match(PLUGIN_RECOVERY_GUARD, /globalThis\.fetch=function/)
assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/client-event/)
assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/wait/)
assert.match(PLUGIN_RECOVERY_GUARD, /attempt>=3/)
assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/plugin-failure/)
assert.match(PLUGIN_RECOVERY_GUARD, /browser\.plugin-load\.recovery\.completed/)
assert.match(PLUGIN_RECOVERY_GUARD, /addEventListener\("error"/)
assert.match(PLUGIN_RECOVERY_GUARD, /unhandledrejection/)
assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /Failed to load plugins/)
assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /localStorage/)
})
test('Gateway session guard reloads DSH pages only after Access Manager rejects the session', () => {
assert.doesNotThrow(() => new Function(SESSION_VALIDITY_GUARD.slice('<script>'.length, -'</script>'.length)))
assert.match(SESSION_VALIDITY_GUARD, /auth\/session-context/)
assert.match(SESSION_VALIDITY_GUARD, /response\.status===401/)
assert.doesNotMatch(SESSION_VALIDITY_GUARD, /innerText|textContent/)
})
test('plugin recovery guard catches official and third-party dynamic import failures', async () => {
for (const [path, pluginId] of [
['/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123', '@deepseek-ai/dsh-client-ui-input-trigger'],
['/plugins/community-plugin/client.js?rev=def456', 'community-plugin'],
]) {
const result = await simulatePluginError(path)
assert.match(result.replacement, /^\/_dsh_gateway\/wait\?return=/)
assert.deepEqual(result.events.map(value => [value.event, value.pluginId]), [
['browser.plugin-load.failed', pluginId],
['browser.plugin-load.recovery.started', pluginId],
])
}
})
test('plugin recovery guard retries twice and opens the Gateway failure page after the third failure', async () => {
const path = '/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123'
const storage = new Map()
const first = await simulatePluginError(path, { storage })
const second = await simulatePluginError(path, { storage })
const third = await simulatePluginError(path, { storage })
assert.match(first.replacement, /^\/_dsh_gateway\/wait\?return=/)
assert.match(second.replacement, /^\/_dsh_gateway\/wait\?return=/)
assert.equal(third.replacement, '/_dsh_gateway/plugin-failure')
assert.equal(first.events.at(-1).recoveryAttempt, 1)
assert.equal(second.events.at(-1).recoveryAttempt, 2)
assert.deepEqual(third.events.map(value => [value.event, value.recoveryAttempt]), [
['browser.plugin-load.recovery.failed', 3],
])
})
test('plugin recovery guard does not inspect page text', async () => {
const result = await simulatePluginError('/plugins/example/client.js?rev=abc123', { eventType: 'none' })
assert.equal(result.replacement, undefined)
assert.deepEqual(result.events, [])
})
test('polyfill is feature guarded and uses no weak random source', () => {
assert.match(RANDOM_UUID_POLYFILL, /typeof c\.randomUUID!=="function"/)
assert.match(RANDOM_UUID_POLYFILL, /c\.getRandomValues/)
assert.doesNotMatch(RANDOM_UUID_POLYFILL, /Math\.random/)
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseTrustedHosts } from '../lib/config.mjs'
import { inspectExternalRequest, isLoopbackHostname } from '../lib/trust.mjs'
test('isLoopbackHostname follows DSH loopback semantics', () => {
for (const hostname of ['localhost', '127.0.0.1', '127.255.255.255', '[::1]']) {
assert.equal(isLoopbackHostname(hostname), true, hostname)
}
for (const hostname of ['example.com', '128.0.0.1', '127.0.0.999', '[::2]']) {
assert.equal(isLoopbackHostname(hostname), false, hostname)
}
})
test('empty trusted Hosts accepts loopback only', () => {
const trusted = parseTrustedHosts({})
assert.equal(inspectExternalRequest({ host: '127.0.0.1:3080' }, trusted).accepted, true)
assert.equal(inspectExternalRequest({ host: 'localhost:3080' }, trusted).accepted, true)
assert.deepEqual(inspectExternalRequest({ host: '192.168.1.10:3080' }, trusted), {
accepted: false,
reason: 'untrusted-host',
})
})
test('trusted Hosts match optional ports and wildcard', () => {
const trusted = parseTrustedHosts({
DSH_TRUSTED_HOSTS: 'lan.example,exact.example:8443,default.example:80',
})
assert.equal(inspectExternalRequest({ host: 'lan.example:9999' }, trusted).accepted, true)
assert.equal(inspectExternalRequest({ host: 'exact.example:8443' }, trusted).accepted, true)
assert.equal(inspectExternalRequest({ host: 'exact.example:3080' }, trusted).accepted, false)
assert.equal(inspectExternalRequest({ host: 'default.example' }, trusted).accepted, true)
assert.equal(inspectExternalRequest({ host: 'default.example:80' }, trusted).accepted, true)
const wildcard = parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*' })
assert.equal(inspectExternalRequest({ host: 'anything.example' }, wildcard).accepted, true)
})
test('request inspection rejects cross-site and mismatched origins', () => {
const trusted = parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' })
assert.deepEqual(inspectExternalRequest({
host: 'dsh.example',
'sec-fetch-site': 'cross-site',
}, trusted), { accepted: false, reason: 'cross-site' })
assert.deepEqual(inspectExternalRequest({
host: 'dsh.example',
origin: 'https://evil.example',
}, trusted), { accepted: false, reason: 'origin-mismatch' })
assert.equal(inspectExternalRequest({
host: 'dsh.example',
origin: 'https://dsh.example',
'sec-fetch-site': 'same-origin',
}, trusted).accepted, true)
})
test('explicit cross-origin inspection still requires a trusted Host', () => {
const trusted = parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'management.example' })
assert.equal(inspectExternalRequest({
host: 'management.example',
origin: 'https://dsh.example',
'sec-fetch-site': 'cross-site',
}, trusted, { allowCrossOrigin: true }).accepted, true)
assert.equal(inspectExternalRequest({
host: 'untrusted.example',
origin: 'https://dsh.example',
'sec-fetch-site': 'cross-site',
}, trusted, { allowCrossOrigin: true }).accepted, false)
})
test('request inspection rejects missing and malformed browser authorities', () => {
const trusted = parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*' })
assert.deepEqual(inspectExternalRequest({}, trusted), { accepted: false, reason: 'missing-host' })
assert.deepEqual(inspectExternalRequest({ host: 'bad host' }, trusted), {
accepted: false,
reason: 'invalid-host',
})
for (const host of ['ok.example/path', 'user@ok.example', 'ok.example?', 'ok.example#x', 'ok.example:']) {
assert.deepEqual(inspectExternalRequest({ host }, trusted), {
accepted: false,
reason: 'invalid-host',
}, host)
}
assert.deepEqual(inspectExternalRequest({ host: 'ok.example', origin: 'null' }, trusted), {
accepted: false,
reason: 'invalid-origin',
})
})
@@ -0,0 +1,442 @@
import { access, rm } from 'node:fs/promises'
import { createInterface } from 'node:readline/promises'
import { stdin, stdout } from 'node:process'
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
const API_PREFIX = '/_dsh_platform/api/v1'
function usage() {
return `usage: dsh-platform <command> [options]
commands:
status
check
update [--wait]
start|stop|restart [--wait]
channel [stable|experimental]
retry|rollback|return-stable
recover
logs [--source NAME] [--since ISO] [--limit N]
access status|reset|set-username|reset-password
access reset-management-password|disable-management-password|disable-two-factor|clear-sessions [--management-only]|generate-key
access clear-retry [--global-only] [--main-password|--management-password|--two-factor]
trust status|reset`
}
export function parseCli(argv) {
const [command, ...rest] = argv
if (['status', 'check', 'retry', 'rollback', 'return-stable', 'recover'].includes(command)) {
if (rest.length !== 0) throw new Error(usage())
return { command }
}
if (command === 'channel' && (rest.length === 0 || (rest.length === 1 && ['stable', 'experimental'].includes(rest[0])))) {
return { command, channel: rest[0] }
}
if (['update', 'start', 'stop', 'restart'].includes(command)) {
if (rest.some(value => value !== '--wait') || rest.filter(value => value === '--wait').length > 1) throw new Error(usage())
return { command, wait: rest.includes('--wait') }
}
if (command === 'logs') {
const options = { command, sources: [] }
for (let index = 0; index < rest.length; index += 2) {
const name = rest[index]
const value = rest[index + 1]
if (value === undefined) throw new Error(usage())
if (name === '--source') options.sources.push(value)
else if (name === '--since') options.since = value
else if (name === '--limit') options.limit = Number(value)
else throw new Error(usage())
}
return options
}
if (command === 'trust' && rest.length === 1 && ['status', 'reset'].includes(rest[0])) {
return { command, operation: rest[0] }
}
if (command === 'access' && rest.length === 1 && ['status', 'reset', 'set-username', 'reset-password', 'reset-management-password', 'disable-management-password', 'disable-two-factor', 'clear-sessions', 'generate-key', 'image-baseline', 'clear-retry'].includes(rest[0])) {
return { command, operation: rest[0] }
}
if (command === 'access' && rest[0] === 'clear-retry') {
const options = rest.slice(1)
const credentialOptions = options.filter(value => ['--main-password', '--management-password', '--two-factor'].includes(value))
if (options.length > 0 && options.length <= 2 && new Set(options).size === options.length
&& credentialOptions.length <= 1
&& options.every(value => ['--global-only', '--main-password', '--management-password', '--two-factor'].includes(value))) {
return {
command,
operation: 'clear-retry',
...(options.includes('--global-only') ? { globalOnly: true } : {}),
...(options.includes('--main-password') ? { credential: 'main' } : {}),
...(options.includes('--management-password') ? { credential: 'management' } : {}),
...(options.includes('--two-factor') ? { credential: 'totp' } : {}),
}
}
}
if (command === 'access' && rest[0] === 'clear-sessions') {
const options = rest.slice(1)
if (options.length <= 1 && (options.length === 0 || options[0] === '--management-only')) {
return { command, operation: 'clear-sessions', ...(options.length === 1 ? { managementOnly: true } : {}) }
}
}
throw new Error(usage())
}
export async function recoverImageBaseline({
recovery,
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
ask,
} = {}) {
if (getuid() !== 0) throw new Error('image baseline recovery must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('image baseline recovery requires an interactive container console')
const status = await recovery.request('GET', '/v1/status')
const imageBuildId = status.imageBaseline?.imageBuildId
if (typeof imageBuildId !== 'string') throw new Error('image baseline is unavailable')
const expected = `RECOVER IMAGE BASELINE ${imageBuildId}`
let answer
if (ask !== undefined) answer = await ask(expected, status)
else {
const prompt = createInterface({ input, output })
answer = await prompt.question(
`Current Deployment may be incompatible with image ${status.imageBaseline.dsh}. Type ${expected}: `,
)
prompt.close()
}
if (answer !== expected) throw new Error('image baseline recovery cancelled')
return recovery.request('POST', '/v1/recover-image-baseline', { confirm: imageBuildId })
}
export async function resetTrust({
dataRoot = '/data/platform',
runRoot = '/run/dsh-platform',
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
} = {}) {
if (getuid() !== 0) throw new Error('trust reset must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('trust reset requires an interactive container console')
const paths = new PlatformPaths(dataRoot, runRoot)
const socket = paths.trustSocket
if (await access(socket).then(() => true, () => false)) throw new Error('stop Stage-0 before resetting trust')
const prompt = createInterface({ input, output })
const answer = await prompt.question('Type RESET DSH TRUST to clear accepted trust state: ')
prompt.close()
if (answer !== 'RESET DSH TRUST') throw new Error('trust reset cancelled')
await rm(paths.trustStateRoot, { recursive: true, force: true })
return { status: 'reset', recoveryRoot: 'image' }
}
function queryString(options) {
const query = new URLSearchParams()
for (const source of options.sources ?? []) query.append('source', source)
if (options.since !== undefined) query.set('since', options.since)
if (options.limit !== undefined) query.set('limit', String(options.limit))
const value = query.toString()
return value === '' ? '' : `?${value}`
}
function json(value) {
return JSON.stringify(value)
}
function managementPasswordAction(value) {
const normalized = value.trim().toLowerCase()
if (['', 'n'].includes(normalized)) return 'preserve'
if (normalized === 'd') return 'disable'
if (normalized === 'y') return 'reset'
throw new Error('management password choice must be y, n, or d')
}
function affirmative(value, label) {
const normalized = value.trim().toLowerCase()
if (normalized === 'y') return true
if (['', 'n'].includes(normalized)) return false
throw new Error(`${label} choice must be y or n`)
}
export async function readSecret(input, output, prompt) {
output.write(prompt)
if (typeof input.setRawMode !== 'function') throw new Error('password input must be an interactive TTY')
input.setRawMode(true)
input.resume()
return new Promise((resolve, reject) => {
let value = ''
const cleanup = () => {
input.off('data', onData)
input.off('end', onEnd)
input.off('error', onError)
input.setRawMode(false)
input.pause()
}
const finish = (error, result) => {
cleanup()
if (error === undefined) resolve(result)
else reject(error)
}
const onData = chunk => {
for (const character of String(chunk)) {
if (character === '\r' || character === '\n') {
output.write('\n')
finish(undefined, value)
return
}
if (character === '\u0003') {
finish(new Error('access recovery cancelled'))
return
}
if (character === '\u007f') value = value.slice(0, -1)
else if (character >= ' ') value += character
}
}
const onEnd = () => finish(new Error('password input ended before confirmation'))
const onError = error => finish(error)
input.on('data', onData)
input.once('end', onEnd)
input.once('error', onError)
})
}
const RETRYABLE_CONTROL_PLANE_ERRORS = new Set(['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'])
function isRetryableControlPlaneError(error) {
return RETRYABLE_CONTROL_PLANE_ERRORS.has(error?.code)
}
export async function runCli({
argv = process.argv.slice(2),
management = new LocalApiClient(process.env.DSH_PLATFORM_MANAGEMENT_CLI_SOCKET ?? '/run/dsh-platform/management-cli.sock'),
trust = new LocalApiClient(process.env.DSH_PLATFORM_TRUST_SOCKET ?? '/run/dsh-platform/stage0-trust.sock'),
reset = resetTrust,
recovery = new LocalApiClient(process.env.DSH_PLATFORM_RECOVERY_SOCKET ?? '/run/dsh-platform/recovery.sock'),
access = new LocalApiClient(process.env.DSH_PLATFORM_ACCESS_RECOVERY_SOCKET ?? '/run/dsh-platform/access/recovery.sock'),
recover = recoverImageBaseline,
write = value => process.stdout.write(`${value}\n`),
delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
readPassword = readSecret,
ask = async question => {
const prompt = createInterface({ input, output })
try { return await prompt.question(question) } finally { prompt.close() }
},
} = {}) {
const parsed = parseCli(argv)
if (parsed.command === 'recover') {
if (getuid() !== 0) throw new Error('image baseline recovery must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('image baseline recovery requires an interactive container console')
write(json(await recover({ recovery, input, output })))
return 0
}
if (parsed.command === 'access') {
if (getuid() !== 0) throw new Error('access recovery requires root')
if (!input.isTTY || !output.isTTY) throw new Error('access recovery requires an interactive container console')
const current = await access.request('GET', '/v1/recovery/status')
if (parsed.operation === 'status') {
write(json(current))
return 0
}
const confirm = async question => affirmative(await ask(question), 'confirmation')
const cancel = () => {
write(json({ status: 'cancelled' }))
return 0
}
if (parsed.operation === 'generate-key') {
if (!await confirm('Generate an administrator authentication reset key? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/generate-key')))
return 0
}
if (parsed.operation === 'clear-sessions') {
const question = parsed.managementOnly
? 'Clear all Management console sessions? y/[n]: '
: 'Clear all DSH and Management console sessions? y/[n]: '
if (!await confirm(question)) return cancel()
write(json(await access.request('POST', '/v1/recovery/clear-sessions', {
managementOnly: parsed.managementOnly === true,
})))
return 0
}
if (parsed.operation === 'clear-retry') {
const credentialLabel = parsed.credential === 'main'
? 'main password' : parsed.credential === 'management' ? 'Management console password'
: parsed.credential === 'totp' ? 'two-factor' : 'all administrator'
const question = parsed.credential === 'totp'
? parsed.globalOnly
? 'Clear the instance-wide two-factor daily limit? y/[n]: '
: 'Clear two-factor daily limits? The fixed 10-second retry remains active. y/[n]: '
: parsed.globalOnly
? 'Clear instance-wide administrator login rate limits? y/[n]: '
: `Clear ${credentialLabel} login retry limits? y/[n]: `
if (!await confirm(question)) return cancel()
const authenticationRetry = await access.request('POST', '/v1/recovery/clear-retry', {
scope: parsed.globalOnly ? 'global' : 'all',
...(parsed.credential === undefined ? {} : { credential: parsed.credential }),
})
write(json(authenticationRetry))
return 0
}
if (current.account === null) throw new Error('administrator account is unavailable')
const revision = current.account.revision
if (parsed.operation === 'set-username') {
if (!await confirm('Change administrator username? y/[n]: ')) return cancel()
const username = await ask('New administrator username: ')
write(json(await access.request('POST', '/v1/recovery/set-username', { revision, username })))
return 0
}
if (parsed.operation === 'disable-management-password') {
if (!await confirm('Disable management password? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/disable-management-password', { revision })))
return 0
}
if (parsed.operation === 'disable-two-factor') {
if (!await confirm('Disable two-factor authentication? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/reset-access', {
revision,
managementPasswordAction: 'preserve',
totpAction: 'disable',
})))
return 0
}
if (parsed.operation === 'reset') {
let username
let password
if (affirmative(await ask('Change administrator username? y/[n]: '), 'username')) {
username = await ask('New administrator username: ')
}
if (affirmative(await ask('Change administrator password? y/[n]: '), 'password')) {
password = await readPassword(input, output, 'New administrator password: ')
}
let action = 'preserve'
let managementPassword
if (current.account.managementAdditionalCredential.enabled) {
action = managementPasswordAction(await ask(
'Change Management console password? (y)es/[n]o/(d)isable: ',
))
if (action === 'reset') {
managementPassword = await readPassword(input, output, 'New management password: ')
}
}
let totpAction = 'preserve'
if (current.account.totp?.enabled === true) {
if (affirmative(await ask('Disable two-factor authentication? y/[n]: '), 'two-factor')) {
totpAction = 'disable'
}
}
if (username === undefined && password === undefined && action === 'preserve' && totpAction === 'preserve') return cancel()
write(json(await access.request('POST', '/v1/recovery/reset-access', {
revision,
...(username === undefined ? {} : { username }),
...(password === undefined ? {} : { password }),
managementPasswordAction: action,
...(totpAction === 'preserve' ? {} : { totpAction }),
...(managementPassword === undefined ? {} : { managementPassword }),
})))
return 0
}
const confirmed = parsed.operation === 'reset-management-password'
? await confirm('Set or change management password? y/[n]: ')
: await confirm('Change administrator password? y/[n]: ')
if (!confirmed) return cancel()
const password = await readPassword(input, output, parsed.operation === 'reset-management-password'
? 'New management password: ' : 'New administrator password: ')
const route = parsed.operation === 'reset-management-password'
? '/v1/recovery/reset-management-password' : '/v1/recovery/reset-password'
const value = await access.request('POST', route, { revision, password })
write(json(value))
return 0
}
if (parsed.command === 'trust') {
const value = parsed.operation === 'status' ? await trust.status() : await reset()
write(json(value))
return 0
}
if (parsed.command === 'logs') {
const value = await management.request('GET', `${API_PREFIX}/logs${queryString(parsed)}`)
for (const entry of value.entries) write(JSON.stringify(entry))
return 0
}
if (parsed.command === 'update') {
const started = await management.request('POST', `${API_PREFIX}/update`)
write(json(started))
if (!parsed.wait) return 0
let controlPlaneRestarted = false
for (;;) {
let value
try {
value = await management.request('GET', `${API_PREFIX}/status`)
} catch (error) {
if (!isRetryableControlPlaneError(error)) throw error
controlPlaneRestarted = true
await delay(1_000)
continue
}
const terminal = ['success', 'failed'].includes(value.update.status)
const sameTask = value.update.taskId === started.taskId
const resumedTask = controlPlaneRestarted
&& value.update.taskId !== null
&& value.update.operation === 'update'
if (terminal && (sameTask || resumedTask)) {
write(json(value.update))
return value.update.status === 'success' ? 0 : 1
}
await delay(1_000)
}
}
if (['start', 'stop', 'restart'].includes(parsed.command)) {
const started = await management.request('POST', `${API_PREFIX}/${parsed.command}-dsh`)
write(json(started))
if (!parsed.wait) return 0
for (;;) {
const value = await management.request('GET', `${API_PREFIX}/status`)
const lifecycle = value.dshLifecycle
const successState = parsed.command === 'stop' ? 'stopped' : 'running'
if (lifecycle.taskId === started.taskId && [successState, 'failed'].includes(lifecycle.state)) {
write(json(lifecycle))
return lifecycle.state === successState ? 0 : 1
}
await delay(1_000)
}
}
if (parsed.command === 'channel') {
if (parsed.channel === undefined) {
const value = await management.request('GET', `${API_PREFIX}/status`)
write(value.updateChannel)
} else {
const value = await management.request('PUT', `${API_PREFIX}/channel`, { channel: parsed.channel })
write(value.updateChannel)
}
return 0
}
if (parsed.command === 'retry') {
const status = await management.request('GET', `${API_PREFIX}/status`)
const choices = [...status.holds, ...(status.experimentalBlocked === null ? [] : [status.experimentalBlocked])]
const unique = [...new Map(choices.map(value => [value.id, value])).values()]
if (unique.length !== 1) throw new Error('retry requires exactly one active Hold or Blocked combination')
const value = await management.request('POST', `${API_PREFIX}/holds/retry`, { id: unique[0].id })
write(json(value))
return 0
}
if (parsed.command === 'rollback' || parsed.command === 'return-stable') {
const { plan } = await management.request('GET', `${API_PREFIX}/rollback-plan`)
if (plan === null) throw new Error('no complete rollback plan is available')
let confirmDataLoss = false
if (parsed.command === 'return-stable') {
if (!input.isTTY || !output.isTTY) throw new Error('return-stable requires an interactive container console')
const prompt = createInterface({ input, output })
const answer = await prompt.question(`Restore data snapshot from ${plan.snapshot.createdAt}? Type RETURN STABLE AND LOSE NEWER DATA: `)
prompt.close()
if (answer !== 'RETURN STABLE AND LOSE NEWER DATA') throw new Error('return-stable cancelled')
confirmDataLoss = true
}
const value = await management.request('POST', `${API_PREFIX}/${parsed.command}`, {
planId: plan.planId,
...(parsed.command === 'return-stable' ? { confirmDataLoss } : {}),
})
write(json(value))
return 0
}
const method = parsed.command === 'status' ? 'GET' : 'POST'
const value = await management.request(method, `${API_PREFIX}/${parsed.command}`)
write(json(value))
return 0
}
@@ -0,0 +1,30 @@
{
"schema": 1,
"id": "platform-management",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/management/index.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "exec",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/management/healthcheck.mjs"],
"timeoutSeconds": 5
},
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import { runCli } from './cli.mjs'
try {
process.exitCode = await runCli()
} catch (error) {
console.error(error instanceof Error ? error.message : 'dsh-platform failed')
process.exitCode = 1
}
@@ -0,0 +1,20 @@
import { request } from 'node:http'
const socketPath = process.env.DSH_PLATFORM_MANAGEMENT_SOCKET ?? '/run/dsh-platform/management.sock'
const status = await new Promise((resolve, reject) => {
const probe = request({
socketPath,
path: '/_dsh_platform/internal/health',
method: 'GET',
timeout: 2_000,
}, response => {
response.resume()
resolve(response.statusCode)
})
probe.once('timeout', () => probe.destroy(new Error('management health check timed out')))
probe.once('error', reject)
probe.end()
})
if (status !== 200) throw new Error(`management health check returned ${String(status)}`)
@@ -0,0 +1,437 @@
#!/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'))
@@ -0,0 +1,81 @@
import { request } from 'node:http'
const MAX_RESPONSE_BYTES = 256 * 1024
const UNAVAILABLE_CODES = new Set(['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ABORT_ERR'])
export class OutboundProxyControlError extends Error {
constructor(error, statusCode, path) {
super(error?.message ?? `Outbound Proxy returned HTTP ${String(statusCode)}`)
this.name = 'OutboundProxyControlError'
this.statusCode = statusCode
this.code = error?.code ?? 'PROXY_MANAGER_UNAVAILABLE'
this.stage = error?.stage ?? 'unknown'
this.retryable = error?.retryable === true
this.localApiPath = path
this.proxyError = Object.freeze({
code: this.code,
message: this.message,
stage: this.stage,
retryable: this.retryable,
})
}
}
export class OutboundProxyControlClient {
constructor(socketPath, { timeoutMs = 10_000 } = {}) {
this.socketPath = socketPath
this.timeoutMs = timeoutMs
}
request(method, path, body) {
return new Promise((resolve, reject) => {
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
const outgoing = request({
socketPath: this.socketPath,
method,
path,
headers: bytes === undefined ? {} : {
'content-type': 'application/json',
'content-length': bytes.byteLength,
},
signal: AbortSignal.timeout(this.timeoutMs),
}, response => {
const chunks = []
let size = 0
response.on('data', chunk => {
size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES) outgoing.destroy(new Error('Outbound Proxy response is too large'))
else chunks.push(chunk)
})
response.on('end', () => {
let value
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
reject(new Error('Outbound Proxy response is invalid'))
return
}
const statusCode = response.statusCode ?? 500
if (statusCode >= 400) reject(new OutboundProxyControlError(value.error, statusCode, path))
else resolve(value)
})
})
outgoing.once('error', error => {
if (UNAVAILABLE_CODES.has(error?.code) || error?.name === 'TimeoutError' || error?.name === 'AbortError') {
reject(new OutboundProxyControlError({
code: 'PROXY_MANAGER_UNAVAILABLE',
message: 'Outbound Proxy control service is unavailable',
stage: 'control',
retryable: true,
}, 503, path))
} else reject(error)
})
outgoing.end(bytes)
})
}
configuration() { return this.request('GET', '/v1/configuration') }
updateConfiguration(value) { return this.request('PUT', '/v1/configuration', value) }
startTest(value) { return this.request('POST', '/v1/test', value) }
test(taskId) { return this.request('GET', `/v1/test/tasks/${encodeURIComponent(taskId)}`) }
cancelTest(taskId) { return this.request('DELETE', `/v1/test/tasks/${encodeURIComponent(taskId)}`) }
status() { return this.request('GET', '/v1/status') }
}
@@ -0,0 +1,84 @@
{
"name": "dsh-docker-management",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dsh-docker-management",
"dependencies": {
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"qrcode-generator": "2.0.4",
"undici": "8.10.0",
"ws": "8.21.3",
"yaml": "2.9.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
@@ -0,0 +1,13 @@
{
"name": "dsh-docker-management",
"private": true,
"type": "module",
"dependencies": {
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"qrcode-generator": "2.0.4",
"undici": "8.10.0",
"ws": "8.21.3",
"yaml": "2.9.0"
}
}
@@ -0,0 +1,230 @@
import { isIP } from 'node:net'
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { validProviderId } from '../outbound-proxy/lib/contracts.mjs'
const CACHE_SCHEMA = 1
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3079'
const RPC_TIMEOUT_MS = 5_000
function object(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function rpcValue(value, method) {
if (!object(value) || value.type !== 'server-response' || !object(value.result)) {
throw new Error(`DSH ${method} response is invalid`)
}
if (value.result.ok !== true || !Object.hasOwn(value.result, 'value')) {
throw new Error(`DSH ${method} request failed`)
}
return value.result.value
}
async function rpc(fetchImpl, endpoint, method, signal, headers) {
const rpcId = `dsh-proxy-${method}`
const response = await fetchImpl(`${endpoint}/api/${method}`, {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: {} } }),
signal,
})
if (!response.ok) throw new Error(`DSH ${method} returned HTTP ${String(response.status)}`)
return rpcValue(await response.json(), method)
}
function nestedValue(value, path) {
let current = value
for (const part of path) {
if (!object(current) || !(part in current)) return undefined
current = current[part]
}
return current
}
function baseUrlFor(provider, namespaces) {
const namespace = namespaces.get(provider.settingsNs)
if (namespace === undefined) return undefined
const path = Array.isArray(provider.settingsPath) ? provider.settingsPath : []
const configured = nestedValue(namespace.value, path)
const defaults = nestedValue(namespace.base, path)
for (const candidate of [configured, defaults]) {
if (object(candidate) && typeof candidate.baseURL === 'string' && candidate.baseURL.trim() !== '') {
return candidate.baseURL.trim()
}
}
return undefined
}
function configuredProvider(provider, namespaces) {
const namespace = namespaces.get(provider.settingsNs)
if (namespace === undefined) return false
const path = Array.isArray(provider.settingsPath) ? provider.settingsPath : []
return path.length === 0 || nestedValue(namespace.value, path) !== undefined
}
function loopbackUrl(value) {
if (value === undefined) return false
let parsed
try { parsed = new URL(value) } catch { return false }
const host = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
? parsed.hostname.slice(1, -1)
: parsed.hostname.toLowerCase()
if (host === 'localhost' || host.endsWith('.localhost')) return true
if (isIP(host) === 4) return host.startsWith('127.')
if (isIP(host) === 6) return host === '::1' || host === '0:0:0:0:0:0:0:1'
return false
}
function sanitizeProvider(provider, namespaces) {
if (!object(provider) || !validProviderId(provider.provider)) return undefined
if (!configuredProvider(provider, namespaces)) return undefined
const local = loopbackUrl(baseUrlFor(provider, namespaces))
const routingCapability = local
? 'forced-direct'
: provider.routingCapability === 'shared-dsh' || provider.supportsIndependentRouting === false
? 'shared-dsh'
: 'provider'
return Object.freeze({
id: provider.provider,
displayName: typeof provider.displayName === 'string' && provider.displayName.trim() !== ''
? provider.displayName.trim()
: provider.provider,
type: provider.provider,
active: provider.active === true,
declared: provider.declared !== false,
configured: true,
routingCapability,
reason: routingCapability === 'forced-direct'
? 'local-provider'
: routingCapability === 'shared-dsh' ? 'client-uses-shared-dsh-route' : null,
})
}
function decorate(provider, snapshot) {
if (provider.routingCapability !== 'forced-direct') {
const requestedPolicy = snapshot.configuration.modelApi.providers[provider.id]
?? snapshot.configuration.modelApi.default
return Object.freeze({
...provider,
requestedPolicy,
effectivePolicy: requestedPolicy.proxyEnabled
? provider.routingCapability === 'shared-dsh' ? 'shared-dsh' : 'proxy'
: 'direct',
})
}
return Object.freeze({
...provider,
requestedPolicy: null,
effectivePolicy: 'direct',
})
}
function validateCache(value) {
if (!object(value) || value.schema !== CACHE_SCHEMA || !Array.isArray(value.providers) || typeof value.updatedAt !== 'string') {
throw new Error('cached Provider inventory is invalid')
}
const providers = value.providers.map(provider => {
if (!object(provider) || !validProviderId(provider.id) || typeof provider.displayName !== 'string'
|| typeof provider.type !== 'string' || typeof provider.active !== 'boolean' || typeof provider.declared !== 'boolean'
|| provider.configured !== true
|| !['provider', 'shared-dsh', 'forced-direct'].includes(provider.routingCapability)
|| !(provider.reason === null || typeof provider.reason === 'string')) {
throw new Error('cached Provider inventory entry is invalid')
}
return Object.freeze({ ...provider })
})
return Object.freeze({ schema: CACHE_SCHEMA, updatedAt: value.updatedAt, providers: Object.freeze(providers) })
}
export class ProviderInventory {
constructor({
cachePath,
endpoint = DEFAULT_ENDPOINT,
fetchImpl = fetch,
authentication = Object.freeze({ cookie: async () => null }),
timeoutMs = RPC_TIMEOUT_MS,
now = () => new Date(),
}) {
if (typeof cachePath !== 'string' || cachePath === '') throw new TypeError('Provider inventory cache path is required')
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('Provider inventory timeout is invalid')
if (!object(authentication) || typeof authentication.cookie !== 'function') {
throw new TypeError('Provider inventory authentication is invalid')
}
this.cachePath = cachePath
this.endpoint = endpoint.replace(/\/$/, '')
this.fetchImpl = fetchImpl
this.authentication = authentication
this.timeoutMs = timeoutMs
this.now = now
}
async refresh() {
const signal = AbortSignal.timeout(this.timeoutMs)
const cookie = await this.authentication.cookie()
if (!(cookie === null || typeof cookie === 'string')) throw new Error('DSH authentication cookie is invalid')
const headers = cookie === null ? {} : { cookie }
const [registeredValue, directoryValue, settingsValue] = await Promise.all([
rpc(this.fetchImpl, this.endpoint, 'llm/listProviders', signal, headers),
rpc(this.fetchImpl, this.endpoint, 'llm/listConfigurableProviders', signal, headers),
rpc(this.fetchImpl, this.endpoint, 'settings/describe', signal, headers),
])
if (!Array.isArray(registeredValue) || !Array.isArray(directoryValue) || !Array.isArray(settingsValue.namespaces)) {
throw new Error('DSH Provider inventory response is invalid')
}
const namespaces = new Map(settingsValue.namespaces
.filter(namespace => object(namespace) && typeof namespace.ns === 'string')
.map(namespace => [namespace.ns, namespace]))
const active = new Set(registeredValue
.filter(provider => object(provider) && validProviderId(provider.id))
.map(provider => provider.id))
const providers = directoryValue
.map(provider => sanitizeProvider({ ...provider, active: active.has(provider?.provider) }, namespaces))
.filter(provider => provider !== undefined)
.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.id.localeCompare(right.id))
const cache = Object.freeze({
schema: CACHE_SCHEMA,
updatedAt: this.now().toISOString(),
providers: Object.freeze(providers),
})
await durableReplace(this.cachePath, canonicalJson(cache))
return cache
}
async cached() {
return validateCache(JSON.parse(await readFile(this.cachePath, 'utf8')))
}
async list(snapshot, { refresh = true } = {}) {
let cache
let source = 'live'
let error = null
if (refresh) {
try { cache = await this.refresh() } catch (failure) {
source = 'cache'
error = failure instanceof Error ? failure.message : String(failure)
}
}
if (cache === undefined) {
try { cache = await this.cached() } catch (failure) {
return Object.freeze({
schema: CACHE_SCHEMA,
source: 'unavailable',
updatedAt: null,
error: error ?? (failure instanceof Error ? failure.message : String(failure)),
providers: Object.freeze([]),
})
}
}
return Object.freeze({
schema: CACHE_SCHEMA,
source,
updatedAt: cache.updatedAt,
error,
providers: Object.freeze(cache.providers.map(provider => decorate(provider, snapshot))),
})
}
}
export const providerInventoryInternals = Object.freeze({ baseUrlFor, configuredProvider, loopbackUrl, validateCache })
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,377 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23f4f4f5'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial,sans-serif' font-size='20' font-weight='700' fill='%2318181b'%3EDSH%3C/text%3E%3C/svg%3E">
<script src="./theme-init.js"></script>
<title>DSH Management Console</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">DSH</span>
<div><strong data-i18n="title">DSH Management Console</strong><span data-i18n="consoleLabel">Standalone console</span></div>
</div>
<div class="topbar-actions">
<button id="return-dsh" class="secondary return-dsh" type="button" aria-label="返回 DSH" title="返回 DSH" data-i18n-aria-label="returnDsh" data-i18n-title="returnDsh">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 18l-6-6 6-6"/></svg>
<span class="return-dsh-label" data-i18n="returnDsh">返回 DSH</span>
</button>
<button id="theme-switch" class="theme-switch" type="button" aria-label="Theme" title="Theme">
<span class="theme-icon" data-theme-icon="system" aria-hidden="true"><svg viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg></span>
<span class="theme-icon" data-theme-icon="light" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.42"/></svg></span>
<span class="theme-icon" data-theme-icon="dark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg></span>
</button>
<select id="language-switch" class="language-switch" aria-label="Language" title="Language" data-i18n-aria-label="switchLanguage" data-i18n-title="switchLanguage">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
<div class="connection" id="connection" data-state="connecting" role="status">
<span aria-hidden="true"></span><strong data-i18n="connecting">Reconnecting</strong>
</div>
</div>
</div>
</header>
<main>
<div class="management-navigation">
<nav class="tabs" role="tablist" aria-label="Platform management sections" data-i18n-aria-label="managementSections">
<button id="tab-maintenance" type="button" role="tab" aria-selected="true" aria-controls="panel-maintenance" data-tab="maintenance" data-i18n="maintenanceTab">Maintenance</button>
<button id="tab-files" type="button" role="tab" aria-selected="false" aria-controls="panel-files" data-tab="files" data-i18n="filesTab">Files</button>
<button id="tab-terminal" type="button" role="tab" aria-selected="false" aria-controls="panel-terminal" data-tab="terminal" data-i18n="terminalTab">Terminal</button>
<button id="tab-plugins" type="button" role="tab" aria-selected="false" aria-controls="panel-plugins" data-tab="plugins" data-i18n="pluginsTab">System plugins</button>
<button id="tab-skills" type="button" role="tab" aria-selected="false" aria-controls="panel-skills" data-tab="skills" data-i18n="skillsTab">System skills</button>
<button id="tab-user-plugins" type="button" role="tab" aria-selected="false" aria-controls="panel-user-plugins" data-tab="user-plugins" data-i18n="userPluginsTab">User plugins</button>
<button id="tab-user-skills" type="button" role="tab" aria-selected="false" aria-controls="panel-user-skills" data-tab="user-skills" data-i18n="userSkillsTab">User skills</button>
<button id="tab-proxy" type="button" role="tab" aria-selected="false" aria-controls="panel-proxy" data-tab="proxy" data-i18n="proxyTab">Proxy</button>
<button id="tab-updates" type="button" role="tab" aria-selected="false" aria-controls="panel-updates" data-tab="updates" data-i18n="updatesTab">Updates</button>
<button id="tab-auth-settings" type="button" role="tab" aria-selected="false" aria-controls="panel-auth-settings" data-tab="auth-settings" data-i18n="authSettingsTab">Authentication</button>
</nav>
</div>
<p id="error" class="message error" role="alert" hidden></p>
<div id="panel-auth-settings" class="tab-panel" role="tabpanel" aria-labelledby="tab-auth-settings" hidden>
<section aria-labelledby="auth-settings-title">
<div class="section-heading"><div><h2 id="auth-settings-title" data-i18n="authSettingsTitle">Authentication settings</h2><p data-i18n="authSettingsDetail">Manage the local administrator account and Management sessions.</p></div></div>
<form id="auth-settings-form" class="auth-settings-form" method="post" action="" novalidate>
<div class="auth-subsection"><div class="auth-subsection-heading"><h3 data-i18n="authMainPasswordTitle">Administrator account</h3><button id="auth-account-save" type="button" class="primary" data-i18n="authSaveApply">Save and apply</button></div><label><span data-i18n="authUsername">Username</span><input id="auth-username" name="username" autocomplete="username" maxlength="256" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{1,64}" required><small id="auth-username-error" class="field-error" data-i18n="authUsernameFormat" role="alert" hidden></small></label><label><span data-i18n="authCurrentPassword">Current main password</span><input id="auth-current-password" type="password" autocomplete="current-password" maxlength="1024" data-i18n-placeholder="authCurrentPasswordPlaceholder"><small id="auth-current-password-error" class="field-error" role="alert" hidden></small></label><div class="auth-password-grid"><label><span data-i18n="authNewPassword">New password</span><input id="auth-password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-password-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label><label><span data-i18n="authConfirmPassword">Confirm password</span><input id="auth-password-confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-password-confirm-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label></div><div class="auth-two-factor auth-additional-heading"><h3 data-i18n="authTotpTitle">Two-factor authentication (2FA)</h3><div class="auth-totp-control"><small id="auth-totp-pending" data-i18n="authTotpPending" hidden>Authentication code not verified</small><label class="toggle" data-i18n-aria-label="authTotpEnabled"><input id="auth-totp-enabled" type="checkbox"><span aria-hidden="true"></span></label></div></div><div class="auth-additional"><div class="auth-additional-heading"><h3 data-i18n="authAdditionalTitle">Management console password</h3><label class="toggle" data-i18n-aria-label="authAdditionalEnabled"><input id="auth-additional-enabled" type="checkbox"><span aria-hidden="true"></span></label></div><div id="auth-additional-fields" class="auth-password-grid" hidden><label><span data-i18n="authAdditionalPassword">New password</span><input id="auth-additional-password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-additional-password-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label><label><span data-i18n="authConfirmAdditionalPassword">Confirm password</span><input id="auth-additional-password-confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-additional-password-confirm-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label></div></div></div>
<div class="auth-subsection">
<div class="auth-subsection-heading"><h3 data-i18n="authOriginTitle">Management origin</h3><div class="auth-subsection-actions"><button id="auth-isolation-compare" type="button" class="secondary" data-i18n="authIsolationCompare">Compare security</button><button id="auth-origin-save" type="button" class="primary" data-i18n="authSaveApply">Save and apply</button></div></div>
<div id="auth-root-warning" class="message warning" hidden></div>
<label><span data-i18n="authMode">Access mode</span><select id="auth-mode"><option value="compat" data-i18n="authModeCompat">Compatibility mode</option><option value="isolated-public" data-i18n="authModeIsolated">Public separate origin</option><option value="isolated-local" data-i18n="authModeLocal">Local separate origin</option></select></label>
<div id="auth-origin-settings" hidden><div class="auth-origin-field"><label id="auth-public-origin-field"><span data-i18n="authPublicOrigin">Management origin</span><input id="auth-origin" type="url" data-i18n-placeholder="authPublicOriginPlaceholder"></label><label id="auth-local-port-field" hidden><span data-i18n="authLocalPort">Host port</span><input id="auth-local-port" type="number" min="1" max="65535" step="1" inputmode="numeric" value="3081" data-i18n-placeholder="authLocalPortPlaceholder"><small id="auth-local-port-error" class="field-error" role="alert" hidden data-i18n="authLocalPortFormat">请输入 1 至 65535 的宿主机端口。</small></label><button id="auth-origin-detect" type="button" class="secondary" data-i18n="authAutoDetect">Use detected address</button></div><p id="auth-origin-detail" class="auth-security-note" data-i18n="authOriginDetail">The candidate origin is verified against this platform instance before the access mode changes.</p></div>
</div>
<div class="auth-subsection"><h3 data-i18n="authSessionsTitle">Browser sessions</h3><p id="auth-session-summary" class="auth-security-note"></p><div id="auth-session-list" class="auth-session-list"></div></div>
<p id="auth-settings-status" class="operation-result" role="status" aria-live="polite" hidden></p>
</form>
</section>
</div>
<div id="panel-proxy" class="tab-panel" role="tabpanel" aria-labelledby="tab-proxy" hidden>
<section aria-labelledby="proxy-master-title">
<div class="proxy-setting-row proxy-master-row"><div><div class="proxy-title-line"><h2 id="proxy-master-title" data-i18n="proxyMaster">Use proxy</h2><button id="proxy-component-alert" class="proxy-component-alert" type="button" data-i18n-aria-label="proxyComponentIssueLabel" data-i18n-title="proxyComponentIssueLabel" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6.5v7"/><path d="M12 17.5h.01"/></svg></button></div><small data-i18n="proxyMasterDetail">When off, all managed scopes connect directly.</small></div><label class="toggle" data-i18n-aria-label="proxyMaster"><input id="proxy-enabled" type="checkbox"><span aria-hidden="true"></span></label></div>
</section>
<section aria-labelledby="proxy-title">
<div class="section-heading">
<div><div class="proxy-title-line"><h2 id="proxy-title" data-i18n="proxyTitle">Proxy settings</h2><span id="proxy-save-state" class="proxy-save-state" role="status" aria-live="polite" hidden></span></div><p data-i18n="proxyDetail">Use an existing HTTP or SOCKS5 proxy for selected outbound traffic.</p></div>
<div class="actions"><button id="proxy-test" type="button" class="secondary" data-i18n="proxyTestStart">Test connection</button><button id="proxy-save" type="button" class="primary" data-i18n="proxySave">Save and apply</button></div>
</div>
<div class="proxy-form-grid">
<label><span data-i18n="proxyProtocol">Protocol</span><select id="proxy-protocol"><option value="http">HTTP</option><option value="socks5">SOCKS5</option></select></label>
<label><span data-i18n="proxyHost">Host</span><input id="proxy-host" type="text" maxlength="253" autocomplete="off" spellcheck="false" placeholder="172.17.0.1"></label>
<label><span data-i18n="proxyPort">Port</span><input id="proxy-port" type="number" min="1" max="65535" inputmode="numeric" placeholder="7890"></label>
<label><span data-i18n="proxyUsername">Username</span><input id="proxy-username" type="text" maxlength="255" autocomplete="off" spellcheck="false"></label>
<div class="proxy-password-row"><div class="proxy-password-field"><label><span data-i18n="proxyPassword">Password</span><input id="proxy-password" type="password" maxlength="255" autocomplete="new-password" data-i18n-placeholder="proxyPasswordPlaceholder"></label><button id="proxy-clear-password" type="button" class="secondary proxy-clear-password" aria-pressed="false" data-i18n="proxyClearPassword" hidden>Clear password</button></div><button id="proxy-advanced-toggle" type="button" class="secondary" aria-expanded="false" aria-controls="proxy-advanced" data-i18n="proxyAdvanced">Advanced</button></div>
</div>
<div id="proxy-advanced" class="proxy-advanced" hidden>
<div class="section-heading"><div><h3 data-i18n="proxyRules">Direct rules</h3><p data-i18n="proxyRulesDetail">Destinations listed here bypass the external proxy.</p></div><button id="proxy-system-rules" type="button" class="secondary" data-i18n="proxySystemRules">Built-in rules</button></div>
<label class="proxy-direct-rules-field"><textarea id="proxy-direct-rules" rows="5" spellcheck="false" data-i18n-aria-label="proxyDirectRules" data-i18n-placeholder="proxyDirectRulesPlaceholder"></textarea><small data-i18n="proxyDirectRulesDetail">One host, domain suffix, IP address, or CIDR per line. Use .example.com, not *.example.com.</small></label>
<div class="proxy-setting-row proxy-advanced-setting"><div><h3 data-i18n="proxyAllProxy">Set ALL_PROXY where supported</h3><small data-i18n="proxyAllProxyDetail">Only clients with verified ALL_PROXY support receive it.</small></div><label class="toggle" data-i18n-aria-label="proxyAllProxy"><input id="proxy-all-proxy" type="checkbox"><span aria-hidden="true"></span></label></div>
<div id="proxy-remote-dns-row" class="proxy-setting-row proxy-advanced-setting" hidden><div><h3 data-i18n="proxyRemoteDns">Resolve target DNS through SOCKS5</h3><small data-i18n="proxyRemoteDnsDetail">Avoids resolving target names inside the container.</small></div><label class="toggle" data-i18n-aria-label="proxyRemoteDns"><input id="proxy-remote-dns" type="checkbox"><span aria-hidden="true"></span></label></div>
</div>
<p id="proxy-transport-warning" class="message notice" data-i18n="proxyTransportWarning" hidden>This page is not using HTTPS. Proxy credentials are protected only by the current network boundary.</p>
</section>
<section aria-labelledby="proxy-scopes-title">
<div class="section-heading"><div><h2 id="proxy-scopes-title" data-i18n="proxyScopes">Traffic scopes</h2><p data-i18n="proxyScopesDetail">Select which managed outbound traffic uses this proxy.</p></div><button id="proxy-scope-help" type="button" class="secondary" data-i18n="proxyScopeHelp">Scope guide</button></div>
<div id="proxy-scope-list" class="proxy-scope-grid">
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeUpdates">Updates</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeUpdatesDetail">Metadata checks and remote Artifact downloads.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeUpdates"><input type="checkbox" data-proxy-scope="updates"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopePlatform">Platform components</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopePlatformDetail">External requests made by DSH Docker components and System Plugins.</button></span><label class="toggle" data-i18n-aria-label="proxyScopePlatform"><input type="checkbox" data-proxy-scope="platform"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeDshCore">DSH core</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeDshCoreDetail">DSH core traffic excluding model Provider APIs.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeDshCore"><input type="checkbox" data-proxy-scope="dshCore"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeDshPlugins">DSH plugins</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeDshPluginsDetail">Official and user-installed DSH plugins.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeDshPlugins"><input type="checkbox" data-proxy-scope="dshPlugins"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeAgent">Agent network operations</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeAgentDetail">Agent network tools, commands, and their child processes.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeAgent"><input type="checkbox" data-proxy-scope="agentNetwork"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeTerminal">Container terminal</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeTerminalDetail">Shell sessions created by this Management Console.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeTerminal"><input type="checkbox" data-proxy-scope="managementTerminal"><span aria-hidden="true"></span></label></div>
</div>
</section>
<section aria-labelledby="proxy-providers-title">
<div class="proxy-provider-heading"><h2 id="proxy-providers-title" data-i18n="proxyProviders">Model Providers</h2><div><p data-i18n="proxyProvidersDetail">Identifiable DSH llm/stream Providers can independently select direct access or a dedicated proxy route.</p><input id="proxy-provider-search" type="search" data-i18n-placeholder="proxyProviderSearch" data-i18n-aria-label="proxyProviderSearch" placeholder="Search configured Providers"></div></div>
<div id="proxy-provider-list" class="proxy-provider-list"></div>
<p id="proxy-provider-empty" class="empty-state" data-i18n="proxyNoProviders" hidden>No model Providers were found.</p>
</section>
</div>
<div id="panel-updates" class="tab-panel" role="tabpanel" aria-labelledby="tab-updates" hidden>
<section aria-labelledby="channel-title">
<div class="section-heading">
<div><h2 id="channel-title" data-i18n="channel">Update channel</h2><p data-i18n="channelDetail">Experimental updates DSH only; the platform Environment remains on the supported release.</p></div>
<div class="segmented" role="group" aria-label="Update channel" data-i18n-aria-label="channel">
<button type="button" data-channel="stable" aria-pressed="true" data-i18n="stable">Stable</button>
<button type="button" data-channel="experimental" data-i18n="experimental">Experimental</button>
</div>
</div>
<div class="versions" id="versions">
<div><span data-i18n="current">Current</span><strong id="current-dsh">-</strong><small id="current-env">-</small></div>
<div><span data-i18n="supported">Supported</span><strong id="supported-dsh">-</strong><small id="supported-env">-</small></div>
<div id="upstream-version" hidden><span data-i18n="upstream">Upstream</span><strong id="upstream-dsh">-</strong><small data-i18n="officialNpm">Official npm</small></div>
</div>
<p id="notice" class="message notice" hidden></p>
</section>
<section aria-labelledby="actions-title">
<div class="section-heading action-heading">
<div><h2 id="actions-title" data-i18n="actions">Update actions</h2><p id="checked-at" data-i18n="notChecked">Not checked yet</p></div>
<div class="actions">
<button type="button" id="check" class="secondary"><span id="check-spinner" class="spinner" aria-hidden="true" hidden></span><span id="check-label" data-i18n="check">Check for updates</span></button>
<button type="button" id="update" class="primary" data-i18n="updateSupported" disabled>Update to latest supported</button>
<button type="button" id="rollback" class="secondary" data-i18n="rollback" hidden>Roll back previous</button>
<button type="button" id="return-stable" class="danger-text" data-i18n="returnStable" hidden>Return to Stable now</button>
</div>
</div>
<div class="update-state" aria-live="polite">
<p id="update-result" hidden></p>
<div id="update-progress" class="update-progress" hidden>
<div class="progress-heading"><strong id="progress-title"></strong><span class="progress-heading-actions"><output id="progress-value">0%</output><button type="button" id="progress-dismiss" class="compact" data-i18n="dismissProgress" hidden>Close</button></span></div>
<div id="progress" class="progress" role="progressbar" aria-label="Update progress" data-i18n-aria-label="progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="progress-bar"></span></div>
<div id="progress-stage-log" class="progress-stage-log" hidden>
<div id="progress-log-list" class="progress-log-list" role="log" aria-live="polite"></div>
<button type="button" id="progress-full-log" class="link-button" data-i18n="viewFullTransactionLog">View full transaction log</button>
</div>
</div>
</div>
<p id="metadata-notice" class="message notice" data-i18n="metadataUnavailable" hidden>Signed update metadata has not been published yet. Try again later.</p>
<p id="remote-check-notice" class="message notice" data-i18n="remoteCheckFailed" hidden>Remote check failed. Showing the last verified result.</p>
<div id="holds" class="holds" hidden></div>
</section>
<section aria-labelledby="automatic-title">
<div class="section-heading">
<div><h2 id="automatic-title" data-i18n="automaticChecks">Automatic checks</h2><p data-i18n="automaticChecksDetail">Checks for available versions without downloading or updating.</p></div>
<label class="toggle"><input id="automatic-enabled" type="checkbox"><span aria-hidden="true"></span><strong id="automatic-enabled-label">On</strong></label>
</div>
<div class="setting-rows">
<label class="setting-row"><span data-i18n="checkInterval">Check frequency</span><select id="automatic-interval"><option value="3600" data-i18n="interval3600">Every hour</option><option value="10800" data-i18n="interval10800">Every 3 hours</option><option value="21600" selected data-i18n="interval21600">Every 6 hours</option><option value="43200" data-i18n="interval43200">Every 12 hours</option><option value="86400" data-i18n="interval86400">Every 24 hours</option></select></label>
<div class="setting-row"><span><strong data-i18n="updateNotifications">DSH page update notifications</strong><small data-i18n="updateNotificationsDetail">Shown on DSH pages when an automatic check finds a new version.</small></span><label class="toggle" data-i18n-aria-label="updateNotifications"><input id="notifications-enabled" type="checkbox"><span aria-hidden="true"></span></label></div>
</div>
</section>
</div>
<div id="panel-maintenance" class="tab-panel" role="tabpanel" aria-labelledby="tab-maintenance">
<section aria-labelledby="maintenance-title">
<div class="section-heading">
<div><h2 id="maintenance-title" data-i18n="maintenance">DSH lifecycle</h2><p data-i18n="maintenanceDetail">Start, stop, or restart DSH while the container and management console remain running.</p></div>
<div class="lifecycle-actions"><button type="button" id="start-dsh" class="secondary" data-i18n="startDsh">Start DSH</button><button type="button" id="stop-dsh" class="secondary" data-i18n="stopDsh">Stop DSH</button><button type="button" id="restart-dsh" class="secondary" data-i18n="restartDsh">Restart DSH</button></div>
</div>
<p id="restart-state" class="operation-result" hidden></p>
</section>
<section aria-labelledby="runtime-reset-title">
<div class="section-heading">
<div><h2 id="runtime-reset-title" data-i18n="runtimeReset">Reset runtime</h2><p data-i18n="runtimeResetDetail">Rebuild the runtime from verified DSH files and current platform patches without deleting user data.</p></div>
<button type="button" id="runtime-reset" class="secondary" aria-controls="runtime-reset-confirmation" aria-expanded="false" data-i18n="runtimeReset" disabled>Reset runtime</button>
</div>
<div id="runtime-reset-confirmation" class="inline-confirmation" role="alertdialog" aria-labelledby="runtime-reset-confirmation-title" hidden>
<h3 id="runtime-reset-confirmation-title" data-i18n="runtimeResetConfirmTitle">Reset the current runtime?</h3>
<p data-i18n="runtimeResetWarning">DSH stops briefly. The current version and user data remain unchanged.</p>
<div class="dialog-actions"><button type="button" id="cancel-runtime-reset" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-runtime-reset" class="primary" data-i18n="confirmRuntimeReset" disabled>Reset and restart DSH</button></div>
</div>
<div id="runtime-reset-progress" class="runtime-reset-progress" hidden>
<div class="status-line"><p id="runtime-reset-state" class="operation-result"></p><output id="runtime-reset-progress-value">0%</output></div>
<div id="runtime-reset-progress-track" class="progress" role="progressbar" aria-label="Runtime reset progress" data-i18n-aria-label="runtimeResetProgress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="runtime-reset-progress-bar"></span></div>
</div>
<p id="runtime-reset-result" class="operation-result" hidden></p>
</section>
<section class="log-section" aria-labelledby="logs-title">
<div class="section-heading">
<div><h2 id="logs-title" data-i18n="logs">Live logs</h2><p data-i18n="logsDetail">View runtime logs from DSH and platform modules.</p></div>
<div class="log-tools"><span id="log-connection" class="log-connection" data-state="connecting"><span aria-hidden="true"></span><strong data-i18n="logsConnecting">Connecting</strong></span><button id="refresh-logs" type="button" class="compact" data-i18n="refreshLogs">Refresh logs</button><button id="export-logs" type="button" class="compact" data-i18n="exportLogs" disabled>Export logs</button><button id="clear-logs" type="button" class="compact" data-i18n="clearLogView">Clear view</button></div>
</div>
<div class="log-filters"><input id="log-search" type="search" placeholder="Search logs" aria-label="Search logs" data-i18n-placeholder="searchLogs" data-i18n-aria-label="searchLogs"><select id="log-source" aria-label="Log module" data-i18n-aria-label="logSource"><option value="all" data-i18n="allSources">All modules</option></select><select id="log-level" aria-label="Log level" data-i18n-aria-label="logLevel"><option value="all" data-i18n="levelAll">All levels</option><option value="debug" data-i18n="levelDebug">Debug</option><option value="info" data-i18n="levelInfo">Info</option><option value="warning" data-i18n="levelWarning">Warning</option><option value="error" data-i18n="levelError">Error</option></select><select id="log-limit" aria-label="Entries shown" data-i18n-aria-label="logDisplayLimit"><option value="100" data-log-limit="100">Latest 100</option><option value="250" data-log-limit="250">Latest 250</option><option value="500" data-log-limit="500">Latest 500</option><option value="1000" data-log-limit="1000">Latest 1000</option></select></div>
<div class="log-summary-row"><p id="log-summary" class="log-summary"></p><label class="log-auto-scroll"><input id="auto-scroll" type="checkbox" checked aria-label="Auto-scroll" data-i18n-aria-label="autoScroll"><span aria-hidden="true"></span><strong data-i18n="autoScroll">Auto-scroll</strong></label></div>
<div id="log-resize-frame" class="log-resize-frame">
<div id="log-list" class="log-list" tabindex="0"></div>
<span id="log-resize-handle" class="log-resize-handle" aria-hidden="true"></span>
</div>
<p id="empty-logs" class="empty-state" data-i18n="noLogs">No logs yet</p>
</section>
</div>
<div id="panel-plugins" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-plugins" hidden>
<section aria-labelledby="plugins-title">
<div class="section-heading resource-section-heading"><h2 id="plugins-title" data-i18n="systemPlugins">System plugins</h2><div class="resource-heading-detail"><p data-i18n="systemPluginsConsoleDetail">Manage all bundled System Plugins, including recovery of the Platform Management integration in DSH.</p><input id="plugins-search" class="resource-search" type="search" placeholder="Search System Plugins" aria-label="Search System Plugins" data-i18n-placeholder="searchSystemPlugins" data-i18n-aria-label="searchSystemPlugins"></div></div>
<div id="system-plugin-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="system-plugin-recovery-detail"></p></div>
<div id="plugin-restart-required" class="plugin-restart-notice" role="status" hidden><div><strong data-i18n="pluginChangesPending">Changes pending</strong><p id="system-plugin-draft-summary"></p></div><div class="plugin-draft-actions"><button id="cancel-system-plugin-changes" type="button" class="secondary" data-i18n="cancelChanges">Cancel changes</button><button id="apply-system-plugin-changes" type="button" class="primary" data-i18n="applyUserPluginChanges">Apply and restart DSH</button></div></div>
<div id="bundled-plugins" class="plugin-list"></div>
<p id="empty-plugins" class="empty-state" data-i18n="noSystemPlugins" hidden>The current Environment provides no System Plugins.</p>
<div id="plugins-pagination" class="list-pagination" hidden><span id="plugins-page-status"></span><label><select id="plugins-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="plugins-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="plugins-page-current">1</strong><button id="plugins-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="plugins-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="plugin-operation" class="operation-result" hidden></p>
</section>
</div>
<div id="panel-user-plugins" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-user-plugins" hidden>
<section aria-labelledby="user-plugins-title">
<div class="section-heading resource-section-heading"><h2 id="user-plugins-title" data-i18n="userPlugins">User plugins</h2><div class="resource-heading-detail"><p data-i18n="userPluginsDetail">Recover user-installed Web Profile plugins without starting DSH.</p><input id="user-plugins-search" class="resource-search" type="search" placeholder="Search User Plugins" aria-label="Search User Plugins" data-i18n-placeholder="searchUserPlugins" data-i18n-aria-label="searchUserPlugins"></div></div>
<div id="user-plugin-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="user-plugin-recovery-detail"></p></div>
<div id="user-plugin-draft-actions" class="plugin-restart-notice" role="status" hidden><div><strong data-i18n="pluginChangesPending">Changes pending</strong><p id="user-plugin-draft-summary"></p></div><div class="plugin-draft-actions"><button id="cancel-user-plugin-changes" type="button" class="secondary" data-i18n="cancelChanges">Cancel changes</button><button id="apply-user-plugin-changes" type="button" class="primary" data-i18n="applyUserPluginChanges">Apply and restart DSH</button></div></div>
<div id="user-plugin-list" class="user-plugin-list"></div>
<p id="empty-user-plugins" class="empty-state" data-i18n="noUserPlugins" hidden>No managed user plugins were found in the Web Profile.</p>
<div id="user-plugins-pagination" class="list-pagination" hidden><span id="user-plugins-page-status"></span><label><select id="user-plugins-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="user-plugins-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="user-plugins-page-current">1</strong><button id="user-plugins-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="user-plugins-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="user-plugin-operation" class="operation-result" role="status" aria-live="polite" hidden></p>
</section>
</div>
<div id="panel-skills" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-skills" hidden>
<section aria-labelledby="skills-title">
<div class="section-heading resource-section-heading"><h2 id="skills-title" data-i18n="systemSkills">System skills</h2><div class="resource-heading-detail"><p data-i18n="systemSkillsConsoleDetail">Manage signed Agent guidance supplied by the current Bootstrap.</p><input id="system-skills-search" class="resource-search" type="search" placeholder="Search System Skills" aria-label="Search System Skills" data-i18n-placeholder="searchSystemSkills" data-i18n-aria-label="searchSystemSkills"></div></div>
<div id="system-skill-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="system-skill-recovery-detail"></p></div>
<div id="system-skills" class="plugin-list"></div>
<p id="empty-skills" class="empty-state" data-i18n="noSystemSkills" hidden>The current Bootstrap provides no System Skills.</p>
<div id="system-skills-pagination" class="list-pagination" hidden><span id="system-skills-page-status"></span><label><select id="system-skills-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="system-skills-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="system-skills-page-current">1</strong><button id="system-skills-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="system-skills-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="skill-operation" class="operation-result" role="status" aria-live="polite" hidden></p>
</section>
</div>
<div id="panel-user-skills" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-user-skills" hidden>
<section aria-labelledby="user-skills-title">
<div class="section-heading resource-section-heading"><h2 id="user-skills-title" data-i18n="userSkills">User skills</h2><div class="resource-heading-detail"><p data-i18n="userSkillsDetail">Enable, disable, or permanently delete skills from the DSH user roots.</p><input id="user-skills-search" class="resource-search" type="search" placeholder="Search User Skills" aria-label="Search User Skills" data-i18n-placeholder="searchUserSkills" data-i18n-aria-label="searchUserSkills"></div></div>
<div id="user-skill-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="user-skill-recovery-detail"></p></div>
<div id="user-skills" class="plugin-list"></div>
<p id="empty-user-skills" class="empty-state" data-i18n="noUserSkills" hidden>No user skills were found.</p>
<div id="user-skills-pagination" class="list-pagination" hidden><span id="user-skills-page-status"></span><label><select id="user-skills-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="user-skills-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="user-skills-page-current">1</strong><button id="user-skills-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="user-skills-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="user-skill-operation" class="operation-result" role="status" aria-live="polite" hidden></p>
</section>
</div>
<div id="panel-terminal" class="tab-panel" role="tabpanel" aria-labelledby="tab-terminal" hidden>
<section class="terminal-section" aria-labelledby="terminal-title">
<div class="section-heading terminal-heading">
<div><h2 id="terminal-title" data-i18n="terminal">Terminal</h2><p data-i18n="terminalDetail">Open a container shell that remains available while DSH restarts.</p></div>
<div class="terminal-actions">
<button id="new-terminal" type="button" class="primary" data-i18n="newTerminal">New session</button>
<button id="close-terminal" type="button" class="secondary" data-i18n="closeTerminal" disabled>Close session</button>
</div>
</div>
<div class="terminal-status" id="terminal-status" data-state="idle" role="status" aria-live="polite"><span aria-hidden="true"></span><strong data-i18n="terminalIdle">No active session</strong></div>
<div id="terminal-frame" class="terminal-frame">
<div id="terminal-screen" aria-label="Container terminal" data-i18n-aria-label="terminalScreen"></div>
<p id="terminal-placeholder"><span id="terminal-loader" class="terminal-loader" aria-hidden="true" hidden></span><span id="terminal-placeholder-label" data-i18n="terminalPlaceholder">Start a session to open an interactive Bash shell.</span></p>
</div>
</section>
</div>
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
<section class="file-section" aria-labelledby="files-title">
<div class="section-heading file-heading">
<div><h2 id="files-title" data-i18n="files">File management</h2><p data-i18n="filesDetail">Manage container files with administrator permissions.</p></div>
<div class="file-heading-actions"><button id="file-new" type="button" class="secondary" aria-controls="file-create-panel" aria-expanded="false" data-i18n="newItem">New</button><button id="file-upload" type="button" class="primary" data-i18n="uploadFiles">Upload files</button><button id="file-upload-directory" type="button" class="secondary" data-i18n="uploadDirectory">Upload folder</button><input id="file-upload-input" type="file" multiple hidden><input id="file-upload-directory-input" type="file" webkitdirectory multiple hidden></div>
</div>
<form id="file-create-panel" class="file-create-panel" hidden>
<div class="segmented file-create-kind" role="group" aria-label="Item type" data-i18n-aria-label="itemType">
<button type="button" data-file-create-kind="touch" aria-pressed="true" data-i18n="newFile">File</button>
<button type="button" data-file-create-kind="mkdir" aria-pressed="false" data-i18n="newDirectory">Directory</button>
</div>
<label class="file-create-name"><span data-i18n="itemName">Name</span><input id="file-create-name" type="text" maxlength="255" autocomplete="off" spellcheck="false" data-i18n-placeholder="enterName" aria-label="Name" data-i18n-aria-label="itemName"></label>
<div class="file-create-actions"><button id="file-create-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-create-submit" type="submit" class="primary" data-i18n="createItem" disabled>Create</button></div>
<p id="file-create-location" class="file-create-location"></p>
<p id="file-create-error" class="file-create-error" role="alert" hidden></p>
</form>
<div class="file-pathbar">
<button id="file-back" type="button" class="compact file-nav-button" title="Back" data-i18n-title="back" aria-label="Back" data-i18n-aria-label="back" disabled><span class="file-nav-icon back" aria-hidden="true"></span></button>
<button id="file-forward" type="button" class="compact file-nav-button" title="Forward" data-i18n-title="forward" aria-label="Forward" data-i18n-aria-label="forward" disabled><span class="file-nav-icon forward" aria-hidden="true"></span></button>
<button id="file-up" type="button" class="compact file-nav-button" title="Parent directory" data-i18n-title="parentDirectory" aria-label="Parent directory" data-i18n-aria-label="parentDirectory" disabled><span class="file-nav-icon up" aria-hidden="true"></span></button>
<input id="file-path" type="text" value="" spellcheck="false" aria-label="Path" data-i18n-aria-label="path">
<button id="file-refresh" type="button" class="compact" data-i18n="refresh">Refresh</button>
</div>
<div class="file-searchbar">
<input id="file-search" type="search" placeholder="Filter files" data-i18n-placeholder="filterFiles" aria-label="Filter files" data-i18n-aria-label="filterFiles">
<button id="file-search-recursive" type="button" class="compact" data-i18n="searchDirectory">Search this directory</button>
<label><input id="file-hidden" type="checkbox" checked> <span data-i18n="showHidden">Show hidden</span></label>
</div>
<div id="file-managed-warning" class="message notice" data-i18n="managedPathWarning" hidden>This path is platform-managed. Changes may be replaced and can damage the current deployment.</div>
<div class="file-browser">
<nav id="file-shortcuts" class="file-shortcuts" aria-label="Locations" data-i18n-aria-label="locations"></nav>
<div class="file-main">
<div class="file-drop-overlay" aria-hidden="true"><strong data-i18n="dropToUpload">Drop to upload here</strong><span data-i18n="dropUploadDestination">Files and folders will be uploaded to the current directory.</span></div>
<table class="file-table"><thead><tr><th class="file-check"><input id="file-select-all" type="checkbox" aria-label="Select all" data-i18n-aria-label="selectAll"></th><th aria-sort="ascending"><button type="button" data-file-sort="name" data-sort-order="asc"><span data-i18n="fileName">Name</span><span class="file-sort-arrows" aria-hidden="true"></span></button></th><th aria-sort="none"><button type="button" data-file-sort="size"><span data-i18n="fileSize">Size</span><span class="file-sort-arrows" aria-hidden="true"></span></button></th><th data-i18n="fileOwner">User:group</th><th aria-sort="none"><button type="button" data-file-sort="modifiedAt"><span data-i18n="fileModified">Modified</span><span class="file-sort-arrows" aria-hidden="true"></span></button></th><th data-i18n="fileMode">Mode</th></tr></thead><tbody id="file-list"></tbody></table>
<p id="file-empty" class="empty-state" data-i18n="emptyDirectory" hidden>This directory is empty.</p>
<div class="file-pagination"><span id="file-page-status"></span><label><select id="file-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="50">50</option><option value="100" selected>100</option><option value="200">200</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="file-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage" disabled>‹</button><strong id="file-page-current">1</strong><button id="file-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage" disabled>›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="file-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
</div>
</div>
<div class="file-selection"><span id="file-selection-count" data-i18n="noFilesSelected">No files selected</span><div><button id="file-copy" type="button" class="compact" data-i18n="copy" disabled>Copy</button><button id="file-cut" type="button" class="compact" data-i18n="cut" disabled>Cut</button><button id="file-paste" type="button" class="compact" data-i18n="paste" disabled>Paste</button><button id="file-archive" type="button" class="compact" data-i18n="compress" disabled>Compress</button><button id="file-extract" type="button" class="compact" data-i18n="extract" disabled>Extract</button><button id="file-rename" type="button" class="compact" data-i18n="rename" disabled>Rename</button><button id="file-download" type="button" class="compact" data-i18n="download" disabled>Download</button><button id="file-attributes" type="button" class="compact" data-i18n="editPermissions" aria-controls="file-attributes-panel" aria-expanded="false" disabled>Edit permissions</button><button id="file-delete" type="button" class="danger-text compact" data-i18n="deletePermanently" disabled>Delete permanently</button></div></div>
<div id="file-task-state" class="file-task-state" role="status" hidden><div class="file-task-heading"><strong data-i18n="fileOperations">File operations</strong><span id="file-task-label"></span></div><div id="file-task-list" class="file-task-list"></div><button id="file-task-cancel" type="button" class="compact" data-i18n="cancel" hidden>Cancel</button></div>
<form id="file-archive-panel" class="file-archive-panel" hidden>
<div><h3 id="file-archive-title" data-i18n="compressItems">Compress selected items</h3><p id="file-archive-detail"></p></div>
<label><span data-i18n="archiveFormat">Archive format</span><select id="file-archive-format"><option value="zip">ZIP</option><option value="7z">7z</option><option value="tar.gz">tar.gz</option></select></label>
<label id="file-archive-name-row"><span data-i18n="archiveName">Archive name</span><input id="file-archive-name" type="text" maxlength="255" spellcheck="false"></label>
<div class="file-archive-actions"><button id="file-archive-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-archive-submit" type="submit" class="primary" data-i18n="compress">Compress</button></div>
</form>
<section id="file-attributes-panel" class="file-attributes-panel" aria-labelledby="file-attributes-title" hidden>
<div class="file-attributes-heading"><div><h3 id="file-attributes-title" data-i18n="editPermissions">Edit permissions</h3><p id="file-attributes-path"></p></div><button id="file-attributes-close" type="button" class="compact" data-i18n="close">Close</button></div>
<div class="file-permission-grid" role="group" aria-label="Permissions" data-i18n-aria-label="permissions">
<span></span><strong id="permission-read" data-i18n="permissionRead">Read</strong><strong id="permission-write" data-i18n="permissionWrite">Write</strong><strong id="permission-execute" data-i18n="permissionExecute">Execute</strong>
<span id="permission-owner" data-i18n="permissionOwner">Owner</span><input type="checkbox" data-permission-bit="256" aria-labelledby="permission-owner permission-read"><input type="checkbox" data-permission-bit="128" aria-labelledby="permission-owner permission-write"><input type="checkbox" data-permission-bit="64" aria-labelledby="permission-owner permission-execute">
<span id="permission-group" data-i18n="permissionGroup">Group</span><input type="checkbox" data-permission-bit="32" aria-labelledby="permission-group permission-read"><input type="checkbox" data-permission-bit="16" aria-labelledby="permission-group permission-write"><input type="checkbox" data-permission-bit="8" aria-labelledby="permission-group permission-execute">
<span id="permission-others" data-i18n="permissionOthers">Others</span><input type="checkbox" data-permission-bit="4" aria-labelledby="permission-others permission-read"><input type="checkbox" data-permission-bit="2" aria-labelledby="permission-others permission-write"><input type="checkbox" data-permission-bit="1" aria-labelledby="permission-others permission-execute">
</div>
<div class="file-attribute-fields">
<label><span data-i18n="fileMode">Mode</span><input id="file-attributes-mode" type="text" inputmode="numeric" maxlength="4" spellcheck="false" placeholder="0755"></label>
<label><span data-i18n="fileUser">User</span><input id="file-attributes-user" type="text" maxlength="256" spellcheck="false"></label>
<label><span data-i18n="fileGroup">Group</span><input id="file-attributes-group" type="text" maxlength="256" spellcheck="false"></label>
</div>
<label id="file-attributes-recursive-row" class="file-attributes-recursive"><input id="file-attributes-recursive" type="checkbox"> <span data-i18n="recursiveAttributes">Also change child attributes</span></label>
<div class="file-attributes-actions"><button id="file-attributes-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-attributes-save" type="button" class="primary" data-i18n="applyPermissions">Apply permissions</button></div>
</section>
<p id="file-operation" class="operation-result" role="status" hidden></p>
</section>
</div>
</main>
<dialog id="stable-dialog" aria-labelledby="stable-dialog-title"><form method="dialog"><h2 id="stable-dialog-title" data-i18n="returnStableTitle">Restore Stable state</h2><p><span data-i18n="returnStableWarning">The following data snapshot will be restored and newer data will be lost:</span> <strong id="stable-snapshot">-</strong></p><label class="confirm"><input type="checkbox" id="confirm-data-loss"><span data-i18n="confirmDataLoss">I understand and confirm the loss of newer data</span></label><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-stable" class="danger" data-i18n="confirm" disabled>Restore</button></div></form></dialog>
<dialog id="restart-dialog" aria-labelledby="restart-dialog-title"><form method="dialog"><h2 id="restart-dialog-title" data-i18n="restartTitle">Restart DSH?</h2><p data-i18n="restartWarning">The current DSH connection will be interrupted briefly. This console remains available.</p><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-restart" class="primary" data-i18n="confirmRestart">Restart</button></div></form></dialog>
<dialog id="stop-dialog" aria-labelledby="stop-dialog-title"><form method="dialog"><h2 id="stop-dialog-title" data-i18n="stopTitle">Stop DSH?</h2><p data-i18n="stopWarning">DSH remains stopped until it is started here or the container restarts. The management console remains available.</p><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-stop" class="danger" data-i18n="confirmStop">Stop DSH</button></div></form></dialog>
<dialog id="file-conflict-dialog" class="file-conflict-dialog" aria-labelledby="file-conflict-title">
<form id="file-conflict-form">
<h2 id="file-conflict-title" data-i18n="fileConflictTitle">Destination already exists</h2>
<p><span data-i18n="fileConflictDetail">Choose how to handle:</span> <strong id="file-conflict-path"></strong></p>
<div class="file-conflict-options">
<label><input type="radio" name="file-conflict-choice" value="overwrite"><span><strong data-i18n="conflictOverwrite">Overwrite</strong><small data-i18n="conflictOverwriteDetail">Replace the existing item.</small></span></label>
<label><input type="radio" name="file-conflict-choice" value="rename"><span><strong data-i18n="conflictRename">Auto rename</strong><small data-i18n="conflictRenameDetail">Keep both items with a new name.</small></span></label>
<label><input type="radio" name="file-conflict-choice" value="skip"><span><strong data-i18n="conflictSkip">Skip</strong><small data-i18n="conflictSkipDetail">Leave the existing item unchanged.</small></span></label>
</div>
<label id="file-conflict-all-row" class="confirm"><input id="file-conflict-all" type="checkbox"><span data-i18n="conflictApplyAll">Apply to all remaining conflicts</span></label>
<div class="dialog-actions"><button id="file-conflict-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-conflict-confirm" type="submit" class="primary" data-i18n="confirmChoice" disabled>Confirm</button></div>
</form>
</dialog>
<dialog id="file-editor-dialog" class="file-editor-dialog" aria-labelledby="file-editor-title"><form method="dialog"><header><div><h2 id="file-editor-title" data-i18n="editFile">Edit file</h2><p id="file-editor-path"></p></div><button id="file-editor-close" value="cancel" class="secondary" data-i18n="close">Close</button></header><div class="file-editor-frame"><pre id="file-editor-lines" aria-hidden="true">1</pre><textarea id="file-editor-content" spellcheck="false" aria-label="File content" data-i18n-aria-label="fileContent"></textarea></div><p id="file-editor-meta"></p><footer class="file-editor-footer"><span id="file-editor-status" role="status" aria-live="polite"></span><div class="dialog-actions"><button id="file-editor-reload" type="button" class="secondary" data-i18n="reload">Reload</button><button id="file-editor-save-as" type="button" class="secondary" data-i18n="saveAs">Save as</button><button id="file-editor-save" type="button" class="primary" data-i18n="save">Save</button></div></footer></form></dialog>
<dialog id="text-input-dialog" class="page-input-dialog" aria-labelledby="text-input-dialog-title"><form id="text-input-dialog-form"><h2 id="text-input-dialog-title"></h2><p id="text-input-dialog-detail"></p><input id="text-input-dialog-value" type="text" autocomplete="off" spellcheck="false"><p id="text-input-dialog-error" class="dialog-error" role="alert" hidden></p><div class="dialog-actions"><button id="text-input-dialog-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="text-input-dialog-confirm" type="submit" class="primary" data-i18n="confirmChoice">Confirm</button></div></form></dialog>
<dialog id="confirmation-dialog" aria-labelledby="confirmation-dialog-title"><form id="confirmation-dialog-form"><h2 id="confirmation-dialog-title"></h2><p id="confirmation-dialog-detail"></p><div class="dialog-actions"><button id="confirmation-dialog-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="confirmation-dialog-confirm" type="submit" class="primary" data-i18n="confirmChoice">Confirm</button></div></form></dialog>
<dialog id="proxy-scope-dialog" class="proxy-scope-dialog" aria-labelledby="proxy-scope-dialog-title"><form method="dialog"><header><div><h2 id="proxy-scope-dialog-title" data-i18n="proxyScopeGuideTitle">Proxy scope guide</h2><p data-i18n="proxyScopeGuideDetail">How managed network sources are classified.</p></div><button value="cancel" class="secondary" data-i18n="close">Close</button></header><div id="proxy-scope-catalog" class="proxy-scope-catalog"></div><div id="proxy-scope-summaries" class="proxy-scope-summaries"></div></form></dialog>
<dialog id="proxy-provider-info-dialog" class="proxy-provider-info-dialog" aria-labelledby="proxy-provider-info-title"><form method="dialog"><header><h2 id="proxy-provider-info-title"></h2><button value="cancel" class="secondary" data-i18n="close">Close</button></header><p id="proxy-provider-info-detail"></p></form></dialog>
<dialog id="proxy-component-dialog" class="proxy-provider-info-dialog" aria-labelledby="proxy-component-dialog-title"><form method="dialog"><header><h2 id="proxy-component-dialog-title" data-i18n="proxyComponentIssueTitle">Outbound proxy component unavailable</h2><button value="cancel" class="secondary" data-i18n="close">Close</button></header><p data-i18n="proxyComponentIssueDetail">The DSH Docker outbound proxy component is unavailable. Settings can still be saved, but managed proxy traffic and connection tests may fail. This does not indicate whether the configured external proxy is reachable.</p></form></dialog>
<dialog id="proxy-system-rules-dialog" class="proxy-provider-info-dialog" aria-labelledby="proxy-system-rules-title"><form method="dialog"><header><h2 id="proxy-system-rules-title" data-i18n="proxySystemRulesTitle">Built-in direct rules</h2><button value="cancel" class="secondary" data-i18n="close">Close</button></header><div class="proxy-system-rules-body"><p data-i18n="proxySystemRulesDetail">These platform-managed local destinations are always direct and do not need to be entered again.</p><pre id="proxy-system-rules-list"></pre></div></form></dialog>
<dialog id="proxy-test-dialog" class="proxy-test-dialog" aria-labelledby="proxy-test-dialog-title"><div><header><div><h2 id="proxy-test-dialog-title" data-i18n="proxyTest">Connection test</h2><p data-i18n="proxyTestDetail">Tests the form values without saving or activating them.</p></div></header><ol id="proxy-test-stages" class="proxy-test-stages"></ol><footer><p id="proxy-test-result" class="operation-result" role="status" aria-live="polite" hidden></p><button id="proxy-test-cancel" type="button" class="secondary" hidden>Cancel</button></footer></div></dialog>
<dialog id="auth-totp-dialog" class="auth-totp-dialog" aria-labelledby="auth-totp-dialog-title"><form id="auth-totp-form"><header><h2 id="auth-totp-dialog-title" data-i18n="authTotpSetupTitle">Set up two-factor authentication</h2><button id="auth-totp-close" type="button" class="icon-button" data-i18n-aria-label="close" data-i18n-title="close">×</button></header><p id="auth-totp-dialog-detail" data-i18n="authTotpSetupDetail">Scan the QR code with an authenticator app, then enter its current code.</p><img id="auth-totp-qr" alt="" data-i18n-alt="authTotpQrAlt"><button id="auth-totp-secret-toggle" type="button" class="secondary" aria-expanded="false" data-i18n="authTotpShowSecret">Show 2FA secret</button><div id="auth-totp-secret-row" class="auth-totp-secret" hidden><code id="auth-totp-secret"></code></div><label><span data-i18n="authTotpCode">Authentication code</span><input id="auth-totp-code" type="text" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"></label><small id="auth-totp-error" class="field-error" role="alert" hidden></small><div class="dialog-actions"><button id="auth-totp-confirm" type="submit" class="primary" data-i18n="authTotpConfirm">Confirm code</button></div></form></dialog>
<dialog id="auth-isolation-dialog" class="auth-isolation-dialog" aria-labelledby="auth-isolation-dialog-title"><form method="dialog"><h2 id="auth-isolation-dialog-title" data-i18n="authComparisonTitle">Management isolation comparison</h2><p data-i18n="authComparisonDetail">Both modes require authentication and final-execution authorization. A separate origin additionally isolates the full Management session from DSH client plugins.</p><div class="auth-comparison-table"><div></div><strong data-i18n="authModeCompatShort">Compatibility</strong><strong data-i18n="authModeIsolatedShort">Separate origin</strong><span data-i18n="authComparisonSessions">Separate DSH and Management sessions</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonPlugins">Protect from same-origin DSH client plugins</span><span data-i18n="authComparisonNo">No</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonCapabilities">Final-execution capabilities</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonRoot">Protection after DSH obtains container Root</span><span data-i18n="authComparisonNo">No</span><span data-i18n="authComparisonNo">No</span></div><div class="dialog-actions"><button value="cancel" class="primary" data-i18n="understood">Understood</button></div></form></dialog>
<dialog id="auth-isolation-confirm-dialog" aria-labelledby="auth-isolation-confirm-title"><form method="dialog"><h2 id="auth-isolation-confirm-title" data-i18n="authIsolationConfirmTitle">Enable separate Management origin?</h2><p data-i18n="authIsolationConfirmDetail">Publish container port 3081 or route a separate address to it. The platform verifies the candidate before changing the current mode.</p><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button value="confirm" class="primary" data-i18n="authIsolationConfirm">I have configured the entry</button></div></form></dialog>
<script type="module" src="./app.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,4 @@
try {
const theme = localStorage.getItem('dsh-platform:console-theme')
if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme
} catch {}
@@ -0,0 +1,55 @@
import { createServer, request as httpRequest } from 'node:http'
const ROUTES = new Set([
'GET /_dsh_platform/api/v1/status',
'POST /_dsh_platform/api/v1/check',
'POST /_dsh_platform/api/v1/update',
'GET /_dsh_platform/api/v1/channel',
'PUT /_dsh_platform/api/v1/channel',
'POST /_dsh_platform/api/v1/holds/retry',
'GET /_dsh_platform/api/v1/rollback-plan',
'POST /_dsh_platform/api/v1/rollback',
'POST /_dsh_platform/api/v1/return-stable',
'POST /_dsh_platform/api/v1/start-dsh',
'POST /_dsh_platform/api/v1/stop-dsh',
'POST /_dsh_platform/api/v1/restart-dsh',
'GET /_dsh_platform/api/v1/logs',
])
export function restrictedCliRoute(method, target) {
let pathname
try { pathname = new URL(target, 'http://management-cli.internal').pathname } catch { return false }
return ROUTES.has(`${method} ${pathname}`)
}
export function createRestrictedCliServer({ managementSocketPath, token }) {
if (typeof token !== 'string' || token.length < 32) throw new Error('restricted CLI token is invalid')
return createServer((request, response) => {
if (!restrictedCliRoute(request.method ?? 'GET', request.url ?? '/')) {
response.writeHead(404, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: 'CLI route is not available', code: 'CLI_ROUTE_NOT_AVAILABLE' }))
return
}
const headers = { 'x-dsh-restricted-cli': token }
for (const name of ['accept', 'content-length', 'content-type']) {
if (request.headers[name] !== undefined) headers[name] = request.headers[name]
}
const upstream = httpRequest({
socketPath: managementSocketPath,
method: request.method,
path: request.url,
headers,
}, upstreamResponse => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers)
upstreamResponse.pipe(response)
})
upstream.once('error', error => {
if (response.headersSent) response.destroy(error)
else {
response.writeHead(503, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: 'Management service is unavailable', code: 'MANAGEMENT_UNAVAILABLE' }))
}
})
request.pipe(upstream)
})
}
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { Agent, fetch as undiciFetch, ProxyAgent } from 'undici'
import { OUTBOUND_PROXY_PORTS, outboundProxyScopeEnabled } from '../../../platform/lib/outbound-proxy.mjs'
const LOOPBACK = '127.0.0.1'
const DEFAULT_ROUTING_STATE = '/run/dsh-platform/outbound-proxy-routing.json'
export const PROXY_SCOPE_PORTS = Object.freeze({
updates: OUTBOUND_PROXY_PORTS.updates,
platform: OUTBOUND_PROXY_PORTS.platform,
dshCore: OUTBOUND_PROXY_PORTS.dshCore,
dshPlugins: OUTBOUND_PROXY_PORTS.dshPlugins,
agent: OUTBOUND_PROXY_PORTS.agentNetwork,
terminal: OUTBOUND_PROXY_PORTS.managementTerminal,
provider: OUTBOUND_PROXY_PORTS.modelApi,
sharedDsh: OUTBOUND_PROXY_PORTS.sharedDsh,
})
const CONFIGURATION_SCOPE = Object.freeze({
updates: 'updates', platform: 'platform', dshCore: 'dshCore', dshPlugins: 'dshPlugins',
agent: 'agentNetwork', terminal: 'managementTerminal',
})
function proxyEnabled(path, scope) {
try {
const state = JSON.parse(readFileSync(path, 'utf8'))
if (state?.schema !== 1 || typeof state.enabled !== 'boolean') return true
const configurationScope = CONFIGURATION_SCOPE[scope]
return configurationScope === undefined ? true : outboundProxyScopeEnabled(state, configurationScope)
} catch {
return true
}
}
export function createScopedFetch(scope, {
fetchImpl = undiciFetch,
routingStatePath = DEFAULT_ROUTING_STATE,
createDirectDispatcher = () => new Agent(),
createProxyDispatcher = options => new ProxyAgent(options),
} = {}) {
const port = PROXY_SCOPE_PORTS[scope]
if (port === undefined) throw new TypeError(`unknown outbound proxy scope ${JSON.stringify(scope)}`)
const directDispatcher = createDirectDispatcher()
const proxyDispatcher = createProxyDispatcher({ uri: `http://${LOOPBACK}:${String(port)}` })
const scopedFetch = (input, init = {}) => fetchImpl(input, {
...init,
dispatcher: proxyEnabled(routingStatePath, scope) ? proxyDispatcher : directDispatcher,
})
scopedFetch.close = async () => {
await Promise.all([directDispatcher.close(), proxyDispatcher.close()])
}
return scopedFetch
}
export const scopedFetchInternals = Object.freeze({ proxyEnabled })
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,82 @@
import { createHash, randomUUID } from 'node:crypto'
import { chown, lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
export const MAX_SETTINGS_DOCUMENT_BYTES = 1024 * 1024
const UTF8 = new TextDecoder('utf-8', { fatal: true })
function revision(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function assertRegularFile(details) {
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error('settings document must be a regular file')
}
}
export class SettingsDocumentConflictError extends Error {}
export class SettingsDocumentStore {
constructor(dshHome, { maxBytes = MAX_SETTINGS_DOCUMENT_BYTES } = {}) {
this.dshHome = resolve(dshHome)
this.path = join(this.dshHome, 'settings.yaml')
this.maxBytes = maxBytes
}
async read() {
let details
try {
details = await lstat(this.path)
} catch (error) {
if (error?.code === 'ENOENT') {
const bytes = Buffer.alloc(0)
return Object.freeze({ content: '', revision: revision(bytes), exists: false })
}
throw error
}
assertRegularFile(details)
if (details.size > this.maxBytes) throw new Error('settings document is too large')
const bytes = await readFile(this.path)
if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large')
let content
try {
content = UTF8.decode(bytes)
} catch {
throw new Error('settings document is not valid UTF-8')
}
return Object.freeze({ content, revision: revision(bytes), exists: true })
}
async write(content, expectedRevision) {
if (typeof content !== 'string') throw new Error('settings document content must be a string')
if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(expectedRevision)) {
throw new Error('settings document revision is invalid')
}
const bytes = Buffer.from(content)
if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large')
const current = await this.read()
if (current.revision !== expectedRevision) {
throw new SettingsDocumentConflictError('settings document changed since it was loaded')
}
await mkdir(this.dshHome, { recursive: true, mode: 0o700 })
const owner = current.exists ? await lstat(this.path) : await stat(this.dshHome)
if (current.exists) assertRegularFile(owner)
const temporary = join(this.dshHome, `.settings.yaml.${randomUUID()}.tmp`)
try {
await writeFile(temporary, bytes, {
flag: 'wx',
mode: current.exists ? owner.mode & 0o777 : 0o600,
})
if (process.getuid?.() === 0) await chown(temporary, owner.uid, owner.gid)
if ((await this.read()).revision !== expectedRevision) {
throw new SettingsDocumentConflictError('settings document changed while it was being saved')
}
await rename(temporary, this.path)
} finally {
await rm(temporary, { force: true })
}
return Object.freeze({ content, revision: revision(bytes), exists: true })
}
}
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
import base64
import fcntl
import json
import os
import pty
import select
import signal
import struct
import sys
import termios
def emit(value):
sys.stdout.write(json.dumps(value, separators=(",", ":")) + "\n")
sys.stdout.flush()
def resize(master, cols, rows):
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
def write_all(target, data):
remaining = memoryview(data)
while remaining:
written = os.write(target, remaining)
remaining = remaining[written:]
def terminate(pid, requested_signal):
try:
os.killpg(pid, requested_signal)
except ProcessLookupError:
return
except PermissionError:
os.kill(pid, requested_signal)
def main():
if len(sys.argv) != 4:
raise RuntimeError("usage: pty-helper.py <cwd> <cols> <rows>")
cwd = sys.argv[1]
cols = int(sys.argv[2])
rows = int(sys.argv[3])
pid, master = pty.fork()
if pid == 0:
os.chdir(cwd)
os.umask(0o002)
os.execvpe("/bin/bash", ["/bin/bash", "-i"], os.environ)
def stop_child(received_signal, _frame):
terminate(pid, signal.SIGTERM if received_signal in (signal.SIGINT, signal.SIGTERM) else received_signal)
signal.signal(signal.SIGINT, stop_child)
signal.signal(signal.SIGTERM, stop_child)
resize(master, cols, rows)
stdin_fd = sys.stdin.fileno()
stdin_buffer = b""
stdin_open = True
while True:
readable, _, _ = select.select([master] + ([stdin_fd] if stdin_open else []), [], [], 0.25)
if master in readable:
try:
data = os.read(master, 16384)
except OSError:
data = b""
if not data:
break
emit({"type": "output", "data": base64.b64encode(data).decode("ascii")})
if stdin_open and stdin_fd in readable:
data = os.read(stdin_fd, 65536)
if not data:
stdin_open = False
terminate(pid, signal.SIGHUP)
else:
stdin_buffer += data
while b"\n" in stdin_buffer:
line, stdin_buffer = stdin_buffer.split(b"\n", 1)
command = json.loads(line.decode("utf-8"))
command_type = command.get("type")
if command_type == "input":
write_all(master, base64.b64decode(command["data"], validate=True))
elif command_type == "resize":
resize(master, int(command["cols"]), int(command["rows"]))
elif command_type == "signal":
terminate(pid, int(command["signal"]))
else:
raise RuntimeError("unknown PTY command")
finished, status = os.waitpid(pid, os.WNOHANG)
if finished == pid:
if os.WIFEXITED(status):
emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None})
else:
emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)})
os.close(master)
return
_, status = os.waitpid(pid, 0)
if os.WIFEXITED(status):
emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None})
else:
emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)})
os.close(master)
if __name__ == "__main__":
try:
main()
except Exception as error:
emit({"type": "error", "message": str(error)})
sys.exit(1)
@@ -0,0 +1,288 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { EventEmitter } from 'node:events'
import { userInfo } from 'node:os'
import { resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { WebSocketServer } from 'ws'
import { OUTBOUND_PROXY_ENVIRONMENT_KEYS } from '../../../../platform/lib/outbound-proxy.mjs'
const SESSION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const MAX_INPUT_BYTES = 64 * 1024
const MAX_OUTPUT_BYTES = 256 * 1024
const MAX_SOCKET_BUFFER_BYTES = 1024 * 1024
const MAX_HELPER_INPUT_BUFFER_BYTES = 256 * 1024
const MAX_SESSIONS = 32
const MIN_COLS = 2
const MAX_COLS = 500
const MIN_ROWS = 1
const MAX_ROWS = 200
function dimensions(value = {}) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).some(key => !['cols', 'rows'].includes(key))) throw new Error('Terminal dimensions are invalid')
const cols = value.cols ?? 80
const rows = value.rows ?? 24
if (!Number.isSafeInteger(cols) || cols < MIN_COLS || cols > MAX_COLS
|| !Number.isSafeInteger(rows) || rows < MIN_ROWS || rows > MAX_ROWS) {
throw new Error('Terminal dimensions are outside the supported range')
}
return { cols, rows }
}
function exactMessage(value, keys) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).sort().join(',') === [...keys].sort().join(',')
}
class TerminalSession extends EventEmitter {
constructor({ id, helperPath, cwd, env, cols, rows, reconnectMs, report, python }) {
super()
this.id = id
this.createdAt = new Date().toISOString()
this.updatedAt = this.createdAt
this.status = 'running'
this.exit = null
this.output = Buffer.alloc(0)
this.connections = new Set()
this.everConnected = false
this.reconnectMs = reconnectMs
this.report = report
this.decoder = new StringDecoder('utf8')
this.outputDecoder = new StringDecoder('utf8')
this.lineBuffer = ''
this.closed = false
this.terminationTimers = []
this.child = spawn(python, [helperPath, cwd, String(cols), String(rows)], {
cwd,
env: { ...env, TERM: 'xterm-256color' },
stdio: ['pipe', 'pipe', 'pipe'],
})
this.childClosed = new Promise(resolveClosed => this.child.once('close', resolveClosed))
this.child.stdout.on('data', chunk => this.consume(chunk))
this.child.stderr.on('data', chunk => this.report('terminal.helper.stderr', {
sessionId: this.id,
bytes: chunk.byteLength,
level: 'warning',
}))
this.child.once('error', error => this.fail(error))
this.child.once('exit', (code, signal) => {
if (this.status === 'running') this.finish({ code, signal })
})
this.child.once('close', () => {
for (const timer of this.terminationTimers) clearTimeout(timer)
})
this.scheduleExpiry()
}
publicStatus() {
return Object.freeze({
sessionId: this.id,
status: this.status,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
connected: this.connections.size > 0,
exit: this.exit,
})
}
scheduleExpiry() {
clearTimeout(this.expiry)
this.expiry = setTimeout(() => {
if (this.connections.size === 0) this.terminate('reconnect-expired')
}, this.reconnectMs)
this.expiry.unref()
}
sendCommand(value) {
if (this.status !== 'running' || !this.child.stdin.writable) return false
this.child.stdin.write(`${JSON.stringify(value)}\n`)
return true
}
consume(chunk) {
this.lineBuffer += this.decoder.write(chunk)
for (let newline = this.lineBuffer.indexOf('\n'); newline !== -1; newline = this.lineBuffer.indexOf('\n')) {
const line = this.lineBuffer.slice(0, newline)
this.lineBuffer = this.lineBuffer.slice(newline + 1)
try {
const value = JSON.parse(line)
if (value.type === 'output' && typeof value.data === 'string') this.broadcastOutput(Buffer.from(value.data, 'base64'))
else if (value.type === 'exit') this.finish({ code: value.code ?? null, signal: value.signal ?? null })
else if (value.type === 'error') this.fail(new Error(String(value.message)))
else this.fail(new Error('PTY helper emitted an invalid message'))
} catch (error) {
this.fail(error)
}
}
}
broadcastOutput(bytes) {
this.output = Buffer.concat([this.output, bytes])
if (this.output.byteLength > MAX_OUTPUT_BYTES) this.output = this.output.subarray(this.output.byteLength - MAX_OUTPUT_BYTES)
const data = this.outputDecoder.write(bytes)
if (data === '') return
const message = JSON.stringify({ type: 'output', data })
for (const socket of this.connections) if (socket.readyState === 1) {
if (socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) socket.close(1013, 'terminal client is too slow')
else socket.send(message)
}
}
attach(socket) {
clearTimeout(this.expiry)
const reconnect = this.everConnected
this.everConnected = true
this.connections.add(socket)
void this.report(reconnect ? 'terminal.session.reconnected' : 'terminal.session.connected', { sessionId: this.id })
if (this.output.byteLength > 0) socket.send(JSON.stringify({ type: 'output', data: this.output.toString('utf8') }))
if (this.exit !== null) socket.send(JSON.stringify({ type: 'exit', ...this.exit }))
socket.on('message', data => this.handleMessage(socket, data))
socket.once('close', () => {
this.connections.delete(socket)
void this.report('terminal.session.disconnected', { sessionId: this.id })
if (!this.closed && this.connections.size === 0) this.scheduleExpiry()
})
socket.once('error', error => this.report('terminal.session.connection.failed', { error, sessionId: this.id }))
}
handleMessage(socket, data) {
try {
if (Buffer.byteLength(data) > MAX_INPUT_BYTES + 1024) throw new Error('Terminal message is too large')
const value = JSON.parse(data.toString())
if (value.type === 'input') {
if (!exactMessage(value, ['type', 'data']) || typeof value.data !== 'string'
|| Buffer.byteLength(value.data) > MAX_INPUT_BYTES) throw new Error('Terminal input is invalid')
if (this.child.stdin.writableLength > MAX_HELPER_INPUT_BUFFER_BYTES) {
socket.close(1013, 'terminal input is too fast')
return
}
this.sendCommand({ type: 'input', data: Buffer.from(value.data).toString('base64') })
} else if (value.type === 'resize') {
if (!exactMessage(value, ['type', 'cols', 'rows'])) throw new Error('Terminal resize is invalid')
this.sendCommand({ type: 'resize', ...dimensions({ cols: value.cols, rows: value.rows }) })
} else throw new Error('Terminal message type is invalid')
} catch {
socket.close(1008, 'invalid terminal message')
}
}
finish(exit) {
if (this.status !== 'running') return
this.status = 'exited'
this.exit = Object.freeze({ code: exit.code ?? null, signal: exit.signal ?? null })
this.updatedAt = new Date().toISOString()
for (const socket of this.connections) if (socket.readyState === 1) {
socket.send(JSON.stringify({ type: 'exit', ...this.exit }))
}
void this.report('terminal.session.exited', { sessionId: this.id, ...this.exit })
if (this.connections.size === 0) this.scheduleExpiry()
this.emit('terminal-exit')
}
fail(error) {
if (this.status !== 'running') return
void this.report('terminal.session.failed', { error, sessionId: this.id })
this.terminate('failed')
}
terminate(reason = 'closed') {
if (this.closed) return
this.closed = true
clearTimeout(this.expiry)
if (this.status === 'running') {
this.sendCommand({ type: 'signal', signal: 15 })
for (const [delay, signal] of [[2_000, 'SIGTERM'], [4_000, 'SIGKILL']]) {
const timer = setTimeout(() => this.child.kill(signal), delay)
timer.unref()
this.terminationTimers.push(timer)
}
}
this.child.stdin.end()
for (const socket of this.connections) socket.close(1001, 'terminal session closed')
this.connections.clear()
void this.report('terminal.session.closed', { reason, sessionId: this.id })
this.emit('terminal-close')
}
async stop(reason) {
this.terminate(reason)
let timeout
await Promise.race([
this.childClosed,
new Promise(resolveTimeout => { timeout = setTimeout(resolveTimeout, 5_000) }),
])
clearTimeout(timeout)
}
}
export class TerminalSessionManager {
constructor({
helperPath = new URL('./pty-helper.py', import.meta.url).pathname,
cwd = '/root',
dshHome = '/data/dsh',
env = process.env,
python = '/usr/bin/python3',
reconnectMs = 30_000,
report = async () => {},
} = {}) {
this.helperPath = resolve(helperPath)
this.cwd = resolve(cwd)
this.env = { ...env, HOME: userInfo().homedir, DSH_HOME: dshHome }
for (const key of OUTBOUND_PROXY_ENVIRONMENT_KEYS) delete this.env[key]
this.python = python
this.reconnectMs = reconnectMs
this.report = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {})
this.sessions = new Map()
this.webSockets = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_INPUT_BYTES + 1024 })
}
create(value = {}, environment = {}) {
if (this.sessions.size >= MAX_SESSIONS) {
const error = new Error('Terminal session limit reached')
error.statusCode = 409
throw error
}
const { cols, rows } = dimensions(value)
const sessionId = randomUUID()
const session = new TerminalSession({
id: sessionId, helperPath: this.helperPath, cwd: this.cwd, env: { ...this.env, ...environment },
cols, rows, reconnectMs: this.reconnectMs, report: this.report, python: this.python,
})
this.sessions.set(sessionId, session)
session.once('terminal-close', () => this.sessions.delete(sessionId))
void this.report('terminal.session.created', { sessionId })
return session.publicStatus()
}
require(id) {
if (!SESSION_PATTERN.test(id) || !this.sessions.has(id)) {
const error = new Error('Terminal session was not found')
error.statusCode = 404
throw error
}
return this.sessions.get(id)
}
status(id) {
return this.require(id).publicStatus()
}
close(id) {
const session = this.require(id)
session.terminate('deleted')
return { sessionId: id, closed: true }
}
upgrade(request, socket, head, id) {
const session = this.require(id)
this.webSockets.handleUpgrade(request, socket, head, webSocket => session.attach(webSocket))
}
async shutdown() {
await Promise.all([...this.sessions.values()].map(session => session.stop('management-stopping')))
this.webSockets.close()
}
}
export const terminalSessionInternals = Object.freeze({ dimensions, SESSION_PATTERN })
@@ -0,0 +1,226 @@
import { createHash, randomUUID } from 'node:crypto'
import { chmod, lchown, lstat, mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { parseDocument } from 'yaml'
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DISABLED_DIRECTORY = '.disabled'
const MAX_SKILL_BYTES = 2 * 1024 * 1024
async function adoptSkillTree(path, uid, gid) {
const metadata = await lstat(path)
await lchown(path, uid ?? -1, gid ?? -1)
if (!metadata.isDirectory() || metadata.isSymbolicLink()) return
for (const entry of await readdir(path)) await adoptSkillTree(join(path, entry), uid, gid)
}
async function adoptSkillRoot(root, uid, gid, { skipSystem = false } = {}) {
await lchown(root, uid ?? -1, gid ?? -1)
for (const entry of await readdir(root)) {
if (skipSystem && entry === '.system') continue
const path = join(root, entry)
const metadata = await lstat(path)
if ((uid === undefined || metadata.uid === uid) && (gid === undefined || metadata.gid === gid)) continue
await adoptSkillTree(path, uid, gid)
}
}
export async function prepareUserSkillRoots({ dshHome = '/data/dsh', agentsHome = '/home/node/.agents', uid, gid } = {}) {
const roots = [join(resolve(dshHome), 'skills'), join(resolve(agentsHome), 'skills')]
for (const [index, root] of roots.entries()) {
await mkdir(root, { recursive: true })
const metadata = await lstat(root)
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`User Skill root ${root} must be a directory`)
if (uid !== undefined || gid !== undefined) await adoptSkillRoot(root, uid, gid, { skipSystem: index === 0 })
await chmod(root, 0o2775)
}
return Object.freeze(roots)
}
export class UserSkillConflictError extends Error {
constructor(message) {
super(message)
this.statusCode = 409
}
}
function entryId(source, location, entryName) {
return `sha256:${createHash('sha256').update(`${source}\0${location}\0${entryName}`).digest('hex')}`
}
function revisionFor(entries) {
const hash = createHash('sha256')
for (const entry of entries) {
hash.update(`${entry.entryId}\0${entry.fingerprint}\0`)
}
return `sha256:${hash.digest('hex')}`
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error)
}
async function entryType(path, name) {
const link = await lstat(path)
let target = link
if (link.isSymbolicLink()) target = await stat(path)
if (target.isDirectory()) return { kind: 'directory', documentPath: join(path, 'SKILL.md'), link }
if (target.isFile() && name.endsWith('.md')) return { kind: 'file', documentPath: path, link }
return undefined
}
async function inspectEntry(root, source, location, entryName) {
const path = location === 'active' ? join(root, entryName) : join(root, DISABLED_DIRECTORY, entryName)
let type
let bytes
let damaged = false
let metadataError = null
let name = null
let description = null
try {
type = await entryType(path, entryName)
if (type === undefined) return undefined
const metadata = await stat(type.documentPath)
if (!metadata.isFile() || metadata.size > MAX_SKILL_BYTES) {
throw new Error(metadata.size > MAX_SKILL_BYTES ? 'SKILL.md is too large' : 'SKILL.md is not a regular file')
}
bytes = await readFile(type.documentPath)
const text = bytes.toString('utf8')
if (text.includes('\uFFFD') || !text.startsWith('---\n')) throw new Error('SKILL.md frontmatter is missing')
const close = text.indexOf('\n---', 4)
if (close < 0) throw new Error('SKILL.md frontmatter is not closed')
const document = parseDocument(text.slice(4, close), { prettyErrors: false, strict: true })
if (document.errors.length > 0) throw new Error(document.errors[0].message)
const value = document.toJS()
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('SKILL.md frontmatter is invalid')
if (typeof value.name !== 'string' || !SKILL_NAME_PATTERN.test(value.name)) throw new Error('Skill name is invalid')
if (typeof value.description !== 'string' || value.description.trim() === '') throw new Error('Skill description is invalid')
name = value.name
description = value.description.trim()
} catch (error) {
if (error?.code === 'ENOENT' && type === undefined) return undefined
damaged = true
metadataError = errorMessage(error)
}
const link = type?.link ?? await lstat(path)
const fingerprint = createHash('sha256')
.update(`${link.dev}:${link.ino}:${link.mode}:${link.size}:${link.mtimeNs ?? BigInt(Math.trunc(link.mtimeMs * 1e6))}:`)
.update(bytes ?? Buffer.alloc(0))
.digest('hex')
return Object.freeze({
entryId: entryId(source, location, entryName),
entryName,
source,
kind: type?.kind ?? (entryName.endsWith('.md') ? 'file' : 'directory'),
name,
description,
enabled: location === 'active',
damaged,
metadataError,
symbolicLink: link.isSymbolicLink(),
path,
root,
fingerprint,
})
}
async function rootEntries(root, source) {
const result = []
const locations = [
{ name: 'active', path: root },
{ name: 'disabled', path: join(root, DISABLED_DIRECTORY) },
]
for (const location of locations) {
let entries
try {
entries = await readdir(location.path, { withFileTypes: true })
} catch (error) {
if (error?.code === 'ENOENT') continue
throw error
}
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (location.name === 'active' && (entry.name === DISABLED_DIRECTORY || (source === 'user-dsh' && entry.name === '.system'))) continue
const inspected = await inspectEntry(root, source, location.name, entry.name)
if (inspected !== undefined) result.push(inspected)
}
}
return result
}
async function removeEntry(path) {
const metadata = await lstat(path)
if (metadata.isDirectory() && !metadata.isSymbolicLink()) await rm(path, { recursive: true })
else await rm(path)
}
export class UserSkillManager {
constructor({ dshHome = '/data/dsh', agentsHome = '/home/node/.agents' } = {}) {
this.roots = Object.freeze([
Object.freeze({ source: 'user-dsh', path: join(resolve(dshHome), 'skills') }),
Object.freeze({ source: 'user-agents', path: join(resolve(agentsHome), 'skills') }),
])
}
async inspect() {
const entries = (await Promise.all(this.roots.map(root => rootEntries(root.path, root.source))))
.flat()
.sort((left, right) => left.source.localeCompare(right.source) || left.entryName.localeCompare(right.entryName) || Number(right.enabled) - Number(left.enabled))
return Object.freeze({
schema: 1,
revision: revisionFor(entries),
skills: Object.freeze(entries.map(({ path: _path, root: _root, fingerprint: _fingerprint, ...entry }) => Object.freeze(entry))),
internal: entries,
})
}
async list() {
const { internal: _internal, ...inventory } = await this.inspect()
return inventory
}
async validate({ entryId: requestedId, revision, action }) {
if (typeof requestedId !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(requestedId)) throw new Error('User Skill entry ID is invalid')
if (typeof revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(revision)) throw new Error('User Skill revision is invalid')
if (!['enable', 'disable', 'delete'].includes(action)) throw new Error('User Skill action is invalid')
const inventory = await this.inspect()
if (inventory.revision !== revision) throw new UserSkillConflictError('User Skill state changed; reload and try again')
const entry = inventory.internal.find(value => value.entryId === requestedId)
if (entry === undefined) throw new UserSkillConflictError('User Skill entry no longer exists')
if (action === 'enable' && entry.enabled) throw new UserSkillConflictError('User Skill is already enabled')
if (action === 'disable' && !entry.enabled) throw new UserSkillConflictError('User Skill is already disabled')
return Object.freeze({ action, entry, revision })
}
async configure(request) {
const { action, entry } = await this.validate(request)
if (action === 'delete') {
const trash = join(entry.root, `.user-skill-delete-${randomUUID()}`)
await rename(entry.path, trash)
try {
await removeEntry(trash)
} catch (error) {
try {
await rename(trash, entry.path)
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'User Skill deletion and rollback failed')
}
throw error
}
} else {
const destination = entry.enabled
? join(entry.root, DISABLED_DIRECTORY, entry.entryName)
: join(entry.root, entry.entryName)
await mkdir(join(entry.root, DISABLED_DIRECTORY), { recursive: true })
try {
await lstat(destination)
throw new UserSkillConflictError(`User Skill destination ${basename(destination)} already exists`)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
await rename(entry.path, destination)
}
return this.list()
}
}
export const userSkillInternals = Object.freeze({ DISABLED_DIRECTORY, SKILL_NAME_PATTERN, entryId })
@@ -0,0 +1,30 @@
{
"schema": 1,
"id": "outbound-proxy",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/outbound-proxy/supervisor.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "exec",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/outbound-proxy/supervisor.mjs", "health"],
"timeoutSeconds": 5
},
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
import { PROXY_PORTS } from './lib/contracts.mjs'
import { createOutboundProxyControl } from './lib/control.mjs'
import { createScopedProxyServer, ProxyAgentPool } from './lib/data-plane.mjs'
import { ProxyDnsCache } from './lib/dns-cache.mjs'
import { probeProxyEntry } from './lib/readiness.mjs'
import { ProxyRouteHealth } from './lib/route-health.mjs'
import { ProxyConfigurationStore } from './lib/store.mjs'
import { ProviderHandleStore } from './lib/provider-handles.mjs'
import { ProxyTestManager } from './lib/test-manager.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
const store = new ProxyConfigurationStore(paths.proxyStateRoot)
let snapshot = await store.load()
const listeners = []
const dnsCache = new ProxyDnsCache()
const agentPool = new ProxyAgentPool()
const routeHealth = new ProxyRouteHealth()
const providerHandles = new ProviderHandleStore()
const proxyTests = new ProxyTestManager({
statePath: paths.proxyTestStatePath,
})
await proxyTests.initialize(async path => JSON.parse(await readFile(path, 'utf8')))
const ENTRY_BIND_ATTEMPTS = 21
const ENTRY_BIND_RETRY_MS = 50
async function listenProxyEntry(server, port) {
for (let attempt = 1; attempt <= ENTRY_BIND_ATTEMPTS; attempt += 1) {
try {
await new Promise((resolve, reject) => {
const cleanup = () => {
server.off('error', onError)
server.off('listening', onListening)
}
const onError = error => {
cleanup()
reject(error)
}
const onListening = () => {
cleanup()
resolve()
}
server.once('error', onError)
server.once('listening', onListening)
server.listen(port, '127.0.0.1')
})
return
} catch (error) {
if (error?.code !== 'EADDRINUSE' || attempt === ENTRY_BIND_ATTEMPTS) throw error
await new Promise(resolve => setTimeout(resolve, ENTRY_BIND_RETRY_MS))
}
}
}
async function publishRoutingState(current) {
const staging = `${paths.proxyRoutingStatePath}.${String(process.pid)}.tmp`
const value = {
schema: 1,
revision: current.revision,
enabled: current.configuration.enabled,
scopes: current.configuration.scopes,
modelApi: current.configuration.modelApi,
environment: current.configuration.environment,
noProxy: current.configuration.noProxy,
bypass: current.configuration.bypass,
}
await writeFile(staging, `${JSON.stringify(value)}\n`, { mode: 0o644 })
await chmod(staging, 0o644)
await rename(staging, paths.proxyRoutingStatePath)
}
await publishRoutingState(snapshot)
for (const [scope, port] of Object.entries(PROXY_PORTS)) {
const server = createScopedProxyServer({
scope, getSnapshot: () => snapshot, dnsCache, agentPool, routeHealth, providerHandles,
})
await listenProxyEntry(server, port)
listeners.push(server)
}
await Promise.all(Object.values(PROXY_PORTS).map(port => probeProxyEntry(port)))
const control = createOutboundProxyControl({
getSnapshot: () => snapshot,
routeHealth,
providerHandles,
proxyTests,
getTestState: () => proxyTests.getState(),
commitConfiguration: async request => {
const activated = await store.commit(request)
snapshot = Object.freeze({ ...activated, recovery: 'none' })
await publishRoutingState(snapshot)
return snapshot
},
})
await mkdir(dirname(paths.proxyControlSocket), { recursive: true })
await unlink(paths.proxyControlSocket).catch(error => {
if (error?.code !== 'ENOENT') throw error
})
await new Promise((resolve, reject) => {
control.once('error', reject)
control.listen(paths.proxyControlSocket, resolve)
})
await chmod(paths.proxyControlSocket, 0o600)
process.send?.({
type: 'ready',
componentReady: true,
revision: snapshot.revision,
ports: Object.values(PROXY_PORTS),
})
let resolveSignal
const signal = new Promise(resolve => { resolveSignal = resolve })
process.once('SIGINT', () => resolveSignal())
process.once('SIGTERM', () => resolveSignal())
await signal
agentPool.close()
await Promise.all(listeners.map(server => new Promise(resolve => server.close(resolve))))
await new Promise(resolve => control.close(resolve))
await unlink(paths.proxyControlSocket).catch(error => {
if (error?.code !== 'ENOENT') throw error
})
@@ -0,0 +1,185 @@
import { isIP } from 'node:net'
import { domainToASCII } from 'node:url'
import { ProxyConfigurationError } from './errors.mjs'
import { normalizeProxyRules, PLATFORM_NO_PROXY } from './rules.mjs'
import { OUTBOUND_PROXY_PORTS } from '../../../../platform/lib/outbound-proxy.mjs'
export const PROXY_SCHEMA = 1
export const PROXY_SCOPES = Object.freeze([
'updates', 'platform', 'dshCore', 'dshPlugins', 'agentNetwork', 'managementTerminal',
])
export const PROXY_PORTS = OUTBOUND_PROXY_PORTS
function deepFreeze(value) {
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
for (const child of Object.values(value)) deepFreeze(child)
Object.freeze(value)
}
return value
}
function object(value, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new ProxyConfigurationError(`${label} must be an object`)
}
return value
}
function allowedKeys(value, allowed, label) {
const extras = Object.keys(value).filter(key => !allowed.includes(key))
if (extras.length > 0) throw new ProxyConfigurationError(`${label} contains unsupported fields: ${extras.sort().join(', ')}`)
}
function requiredKeys(value, required, label) {
const missing = required.filter(key => !(key in value))
if (missing.length > 0) throw new ProxyConfigurationError(`${label} is missing fields: ${missing.join(', ')}`)
}
function normalizeProxyHost(value) {
if (typeof value !== 'string' || value !== value.trim() || /[\u0000-\u0020\u007f\/@?#]/.test(value)) {
throw new ProxyConfigurationError('proxy host is invalid')
}
if (value === '') return ''
if (isIP(value) !== 0) return value.toLowerCase()
const ascii = domainToASCII(value.toLowerCase().replace(/\.$/, ''))
if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) {
throw new ProxyConfigurationError('proxy host is invalid')
}
return ascii
}
function normalizeScopes(value) {
object(value, 'proxy scopes')
requiredKeys(value, PROXY_SCOPES, 'proxy scopes')
allowedKeys(value, PROXY_SCOPES, 'proxy scopes')
return Object.fromEntries(PROXY_SCOPES.map(scope => {
if (typeof value[scope] !== 'boolean') throw new ProxyConfigurationError(`proxy scope ${scope} must be boolean`)
return [scope, value[scope]]
}))
}
function normalizeProviderPolicy(value, label) {
object(value, label)
requiredKeys(value, ['proxyEnabled'], label)
allowedKeys(value, ['proxyEnabled'], label)
if (typeof value.proxyEnabled !== 'boolean') throw new ProxyConfigurationError(`${label} proxyEnabled must be boolean`)
return { proxyEnabled: value.proxyEnabled }
}
export function validProviderId(value) {
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value)
}
function normalizeModelApi(value) {
object(value, 'model API proxy policy')
requiredKeys(value, ['default', 'providers'], 'model API proxy policy')
allowedKeys(value, ['default', 'providers'], 'model API proxy policy')
object(value.providers, 'model API provider policies')
const providers = {}
for (const [id, policy] of Object.entries(value.providers)) {
if (!validProviderId(id)) throw new ProxyConfigurationError('model API provider ID is invalid')
providers[id] = normalizeProviderPolicy(policy, `model API provider ${id}`)
}
return { default: normalizeProviderPolicy(value.default, 'model API default'), providers }
}
export function defaultProxyConfiguration() {
return Object.freeze({
schema: PROXY_SCHEMA,
enabled: false,
proxy: Object.freeze({
protocol: 'http', host: '', port: null, username: '', passwordConfigured: false, remoteDns: true,
}),
scopes: Object.freeze(Object.fromEntries(PROXY_SCOPES.map(scope => [scope, false]))),
environment: Object.freeze({ allProxy: null }),
modelApi: Object.freeze({
default: Object.freeze({ proxyEnabled: false }),
providers: Object.freeze({}),
}),
noProxy: Object.freeze({ system: PLATFORM_NO_PROXY, user: Object.freeze([]) }),
bypass: Object.freeze({ additional: Object.freeze([]) }),
})
}
export function validateProxyConfiguration(value, { existingPassword = null } = {}) {
object(value, 'proxy configuration')
requiredKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
allowedKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
if (value.schema !== PROXY_SCHEMA) throw new ProxyConfigurationError('proxy configuration schema must be 1')
if (typeof value.enabled !== 'boolean') throw new ProxyConfigurationError('proxy enabled must be boolean')
const proxy = object(value.proxy, 'proxy endpoint')
requiredKeys(proxy, ['protocol', 'host', 'port', 'username', 'remoteDns'], 'proxy endpoint')
allowedKeys(proxy, ['protocol', 'host', 'port', 'username', 'password', 'clearPassword', 'passwordConfigured', 'remoteDns'], 'proxy endpoint')
if (!['http', 'socks5'].includes(proxy.protocol)) throw new ProxyConfigurationError('proxy protocol must be http or socks5')
const host = normalizeProxyHost(proxy.host)
const port = proxy.port === null ? null : Number(proxy.port)
if (port !== null && (!Number.isSafeInteger(port) || port < 1 || port > 65535)) throw new ProxyConfigurationError('proxy port is invalid')
if (value.enabled && (host === '' || port === null)) throw new ProxyConfigurationError('enabled proxy requires a host and port')
if ((host === '') !== (port === null)) throw new ProxyConfigurationError('proxy host and port must be configured together')
if (typeof proxy.username !== 'string' || Buffer.byteLength(proxy.username, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.username)) {
throw new ProxyConfigurationError('proxy username is invalid')
}
if (typeof proxy.remoteDns !== 'boolean') throw new ProxyConfigurationError('proxy remoteDns must be boolean')
if (proxy.clearPassword === true && proxy.password !== undefined && proxy.password !== null) {
throw new ProxyConfigurationError('proxy password cannot be set and cleared together')
}
let password = existingPassword
if (proxy.clearPassword === true) password = null
else if (proxy.password !== undefined && proxy.password !== null) {
if (typeof proxy.password !== 'string' || proxy.password === '' || Buffer.byteLength(proxy.password, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.password)) {
throw new ProxyConfigurationError('proxy password is invalid')
}
password = proxy.password
}
const environment = object(value.environment, 'proxy environment')
requiredKeys(environment, ['allProxy'], 'proxy environment')
allowedKeys(environment, ['allProxy'], 'proxy environment')
if (environment.allProxy !== null && environment.allProxy !== 'scope-proxy') {
throw new ProxyConfigurationError('proxy environment allProxy must be null or scope-proxy')
}
const noProxy = object(value.noProxy, 'NO_PROXY')
requiredKeys(noProxy, ['user'], 'NO_PROXY')
allowedKeys(noProxy, ['user', 'system'], 'NO_PROXY')
const bypass = object(value.bypass, 'proxy bypass')
requiredKeys(bypass, ['additional'], 'proxy bypass')
allowedKeys(bypass, ['additional'], 'proxy bypass')
const normalized = {
schema: PROXY_SCHEMA,
enabled: value.enabled,
proxy: {
protocol: proxy.protocol,
host,
port,
username: proxy.username,
passwordConfigured: password !== null,
remoteDns: proxy.remoteDns,
},
scopes: normalizeScopes(value.scopes),
environment: { allProxy: environment.allProxy },
modelApi: normalizeModelApi(value.modelApi),
noProxy: {
system: PLATFORM_NO_PROXY,
user: normalizeProxyRules(noProxy.user, { allowWildcard: true, label: 'NO_PROXY' }).map(rule => rule.value),
},
bypass: {
additional: normalizeProxyRules(bypass.additional, { allowCidr: true, label: 'proxy bypass' }).map(rule => rule.value),
},
}
return Object.freeze({
configuration: deepFreeze(normalized),
credentials: deepFreeze({ username: proxy.username, password }),
})
}
export function sanitizedProxyConfiguration(configuration, revision, state = {}) {
return Object.freeze({
...configuration,
revision,
componentReady: state.componentReady === true,
routeHealth: state.routeHealth ?? Object.fromEntries([...PROXY_SCOPES, 'modelApi', 'sharedDsh'].map(scope => [scope, 'unknown'])),
lastTest: state.lastTest ?? null,
})
}
@@ -0,0 +1,130 @@
import { createServer } from 'node:http'
import { outboundProxyEnvironment, outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs'
import { sanitizedProxyConfiguration } from './contracts.mjs'
import { ProxyConfigurationError, proxyErrorBody } from './errors.mjs'
const MAX_CONTROL_BODY_BYTES = 64 * 1024
const MAX_PROVIDER_HANDLE_BODY_BYTES = 4096
const TEST_TASK_ROUTE = /^\/v1\/test\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/
async function jsonBody(request, maximum = MAX_CONTROL_BODY_BYTES) {
const chunks = []
let total = 0
for await (const chunk of request) {
total += chunk.byteLength
if (total > maximum) throw new ProxyConfigurationError('control request body is too large')
chunks.push(chunk)
}
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
throw new ProxyConfigurationError('control request body is invalid')
}
}
function send(response, status, value) {
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
response.end(`${JSON.stringify(value)}\n`)
}
function configurationView(snapshot, routeHealth, state = {}) {
return sanitizedProxyConfiguration(snapshot.configuration, snapshot.revision, {
componentReady: true,
routeHealth: routeHealth.status(snapshot),
lastTest: state.lastTest ?? null,
})
}
export function createOutboundProxyControl({
getSnapshot,
routeHealth,
providerHandles,
proxyTests,
commitConfiguration,
getTestState = () => ({ lastTest: null }),
}) {
return createServer(async (request, response) => {
const url = new URL(request.url ?? '/', 'http://outbound-proxy.internal')
const snapshot = getSnapshot()
if (request.method === 'GET' && url.pathname === '/v1/status') {
send(response, 200, {
componentReady: true,
revision: snapshot.revision,
recovery: snapshot.recovery,
routeHealth: routeHealth.status(snapshot),
})
return
}
if (request.method === 'GET' && url.pathname === '/v1/configuration') {
send(response, 200, configurationView(snapshot, routeHealth, getTestState()))
return
}
if (request.method === 'PUT' && url.pathname === '/v1/configuration') {
try {
if (commitConfiguration === undefined) throw new Error('proxy configuration updates are unavailable')
const body = await jsonBody(request)
if (body === null || typeof body !== 'object' || Array.isArray(body)
|| Object.keys(body).sort().join(',') !== 'baseRevision,value') {
throw new ProxyConfigurationError('proxy configuration update is invalid')
}
const activated = await commitConfiguration(body)
providerHandles?.clear()
send(response, 200, configurationView(activated, routeHealth, getTestState()))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'POST' && url.pathname === '/v1/test') {
try {
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
send(response, 202, await proxyTests.start(await jsonBody(request), snapshot))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'GET' && TEST_TASK_ROUTE.test(url.pathname)) {
try {
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
send(response, 200, proxyTests.get(TEST_TASK_ROUTE.exec(url.pathname)[1]))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'DELETE' && TEST_TASK_ROUTE.test(url.pathname)) {
try {
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
send(response, 202, await proxyTests.cancel(TEST_TASK_ROUTE.exec(url.pathname)[1]))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'GET' && url.pathname === '/v1/environment') {
try {
const scope = url.searchParams.get('scope')
const environment = outboundProxyEnvironment(scope, {
noProxy: [...snapshot.configuration.noProxy.system, ...snapshot.configuration.noProxy.user],
allProxy: snapshot.configuration.environment.allProxy === 'scope-proxy',
enabled: outboundProxyScopeEnabled(snapshot.configuration, scope),
})
send(response, 200, { scope, revision: snapshot.revision, environment })
} catch (error) {
send(response, 400, { error: error instanceof Error ? error.message : 'invalid scope' })
}
return
}
if (request.method === 'POST' && url.pathname === '/v1/provider-handles') {
try {
if (providerHandles === undefined) throw new Error('provider handles are unavailable')
send(response, 201, providerHandles.issue(await jsonBody(request, MAX_PROVIDER_HANDLE_BODY_BYTES), snapshot))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
send(response, 404, { error: 'not found' })
})
}
export const outboundProxyControlInternals = Object.freeze({ configurationView })
@@ -0,0 +1,459 @@
import { Agent, createServer, request as httpRequest } from 'node:http'
import { ProxyDnsCache } from './dns-cache.mjs'
import { connectTcp, basicProxyAuthorization, connectThroughHttpProxy, connectThroughSocks5, PROXY_TIMEOUTS, ProxyTransportError } from './transport.mjs'
import { selectProxyRoute } from './policy.mjs'
import { ProxyRouteHealth } from './route-health.mjs'
const MAX_HEADER_LINE_BYTES = 64 * 1024
const MAX_CONNECT_AUTHORITY_BYTES = 1024
const HOP_BY_HOP = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
])
function proxyError(response, statusCode, code, message) {
if (response.destroyed) return
if (response.headersSent) return response.destroy()
const body = `${message}\n`
response.writeHead(statusCode, {
'content-type': 'text/plain; charset=utf-8',
'content-length': Buffer.byteLength(body),
'x-dsh-proxy-error': code,
connection: 'close',
...statusCode === 407 ? { 'proxy-authenticate': 'DSH-Provider' } : {},
})
response.end(body)
}
function socketError(socket, statusCode, code, message) {
if (socket.destroyed) return
const body = `${message}\n`
socket.end([
`HTTP/1.1 ${statusCode} ${statusCode === 504 ? 'Gateway Timeout' : statusCode === 431 ? 'Request Header Fields Too Large' : statusCode === 407 ? 'Proxy Authentication Required' : statusCode === 400 ? 'Bad Request' : 'Bad Gateway'}`,
'Content-Type: text/plain; charset=utf-8',
`Content-Length: ${Buffer.byteLength(body)}`,
`X-DSH-Proxy-Error: ${code}`,
...statusCode === 407 ? ['Proxy-Authenticate: DSH-Provider'] : [],
'Connection: close',
'',
body,
].join('\r\n'))
}
function errorFields(error) {
if (error instanceof ProxyTransportError) {
return { statusCode: error.statusCode, code: error.code, message: error.message }
}
return { statusCode: 502, code: 'UPSTREAM_CONNECT_FAILED', message: 'upstream connection failed' }
}
function observeRouteHealth(context, snapshot, outcome) {
if (context.getSnapshot().revision === snapshot.revision) {
context.routeHealth.observe(snapshot, context.scope, outcome)
}
}
function authorizeProvider(context, headers, snapshot) {
if (context.scope !== 'modelApi') return undefined
try {
if (context.providerHandles === undefined) throw new Error('provider handles are unavailable')
return context.providerHandles.resolve(headers['proxy-authorization'], snapshot)
} catch {
throw new ProxyTransportError('provider policy handle is invalid', {
code: 'PROVIDER_HANDLE_INVALID', statusCode: 407,
})
}
}
function checkHeaderLimits(request) {
if (request.rawHeaders.length / 2 > 256) return false
for (let index = 0; index < request.rawHeaders.length; index += 2) {
if (Buffer.byteLength(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`, 'latin1') > MAX_HEADER_LINE_BYTES) return false
}
return true
}
function connectionTokens(headers) {
return new Set(String(headers.connection ?? '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean))
}
function forwardHeaders(headers, { upgrade = false, authorization = null, host }) {
const tokens = connectionTokens(headers)
const result = {}
for (const [name, value] of Object.entries(headers)) {
const lower = name.toLowerCase()
if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue
if (value !== undefined) result[lower] = value
}
result.host = host
if (upgrade) {
result.connection = 'Upgrade'
result.upgrade = headers.upgrade
}
if (authorization !== null) result['proxy-authorization'] = authorization
return result
}
function responseHeaders(headers) {
const tokens = connectionTokens(headers)
const result = {}
for (const [name, value] of Object.entries(headers)) {
const lower = name.toLowerCase()
if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue
if (value !== undefined) result[lower] = value
}
return result
}
function parseAbsoluteTarget(raw) {
let target
try { target = new URL(raw) } catch { return null }
if (target.username !== '' || target.password !== '' || target.hostname === '' || target.hash !== '') return null
if (!['http:', 'ws:'].includes(target.protocol)) return Object.freeze({ unsupported: true, url: target })
const port = target.port === '' ? 80 : Number(target.port)
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
const host = target.hostname.startsWith('[') && target.hostname.endsWith(']')
? target.hostname.slice(1, -1)
: target.hostname
return Object.freeze({ url: target, host, port, authority: target.host })
}
function parseAuthority(raw) {
if (Buffer.byteLength(raw) > MAX_CONNECT_AUTHORITY_BYTES || raw.includes('/') || raw.includes('@')) return null
try {
const value = new URL(`tcp://${raw}`)
if (value.hostname === '' || value.port === '') return null
const port = Number(value.port)
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
const host = value.hostname.startsWith('[') && value.hostname.endsWith(']')
? value.hostname.slice(1, -1)
: value.hostname
return Object.freeze({ host, port })
} catch { return null }
}
function requestPath(target) {
return `${target.url.pathname || '/'}${target.url.search}`
}
function idle(socket, timeoutMs) {
socket.setTimeout(timeoutMs, () => socket.destroy(new ProxyTransportError('upstream stream timed out', {
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
})))
}
function bridge(left, right, { leftHead = Buffer.alloc(0), rightHead = Buffer.alloc(0), timeoutMs }) {
const close = () => {
if (!left.destroyed) left.destroy()
if (!right.destroyed) right.destroy()
}
left.on('error', close)
right.on('error', close)
idle(left, timeoutMs)
idle(right, timeoutMs)
if (leftHead.byteLength > 0) right.write(leftHead)
if (rightHead.byteLength > 0) left.write(rightHead)
left.pipe(right)
right.pipe(left)
}
async function tryTargets(targets, connectTarget, { retry } = {}) {
let failure
for (const target of targets) {
try { return await connectTarget(target) } catch (error) {
failure = error
if (error?.code === 'REQUEST_CANCELLED' || retry?.(error) === false) throw error
}
}
throw failure ?? new ProxyTransportError('no usable target addresses', { code: 'TARGET_DNS_FAILED' })
}
async function routeSocket(route, target, signal, timeouts) {
if (route.mode === 'direct') return tryTargets(route.targets ?? [{ address: target.host }], async candidate => Object.freeze({
socket: await connectTcp({ host: candidate.address, port: target.port, timeoutMs: timeouts.connectMs, signal }),
remainder: Buffer.alloc(0),
}))
if (route.mode === 'http') return connectThroughHttpProxy({
endpoint: route.endpoint,
targetHost: target.host,
targetPort: target.port,
connectTimeoutMs: timeouts.connectMs,
handshakeTimeoutMs: timeouts.handshakeMs,
signal,
})
if (route.mode === 'socks5') return tryTargets(route.targets ?? [{ address: target.host }], candidate => connectThroughSocks5({
endpoint: route.endpoint,
targetHost: candidate.address,
targetPort: target.port,
connectTimeoutMs: timeouts.connectMs,
handshakeTimeoutMs: timeouts.handshakeMs,
signal,
}), { retry: error => error?.code === 'UPSTREAM_PROXY_REJECTED' })
throw new ProxyTransportError('configured proxy protocol is not available', {
code: 'PROXY_PROTOCOL_UNAVAILABLE',
})
}
async function httpConnection(route, target, signal, timeouts) {
if (route.mode === 'http') return Object.freeze({
socket: await connectTcp({
host: route.endpoint.host,
port: route.endpoint.port,
timeoutMs: timeouts.connectMs,
signal,
}),
remainder: Buffer.alloc(0),
})
return routeSocket(route, target, signal, timeouts)
}
function agentKey(route) {
const endpoint = route.endpoint === undefined
? ''
: `${route.endpoint.host}:${route.endpoint.port}:${route.endpoint.username}`
return `${route.revision}\u0000${route.mode}\u0000${endpoint}`
}
export class ProxyAgentPool {
constructor() {
this.entries = new Map()
this.currentRevision = null
}
retireOtherRevisions(revision) {
if (this.currentRevision === revision) return
this.currentRevision = revision
for (const [key, entry] of this.entries) {
if (entry.revision === revision) continue
entry.retired = true
for (const sockets of Object.values(entry.agent.freeSockets)) {
for (const socket of sockets) socket.destroy()
}
this.entries.delete(key)
}
}
agentFor(route, timeouts = PROXY_TIMEOUTS) {
this.retireOtherRevisions(route.revision)
const key = agentKey(route)
const existing = this.entries.get(key)
if (existing !== undefined) return existing.agent
const entry = { revision: route.revision, retired: false, agent: null }
const agent = new Agent({ keepAlive: true, maxSockets: 32, maxFreeSockets: 8, timeout: timeouts.streamIdleMs })
entry.agent = agent
agent.createConnection = (options, callback) => {
const target = {
host: options.dshProxyTargetHost,
port: Number(options.dshProxyTargetPort),
}
void httpConnection(route, target, options.dshProxySignal, timeouts).then(connection => {
if (connection.remainder.byteLength > 0) connection.socket.unshift(connection.remainder)
connection.socket.resume()
callback(null, connection.socket)
}, callback)
}
agent.on('free', socket => {
if (entry.retired) socket.destroy()
})
this.entries.set(key, entry)
return agent
}
close() {
for (const entry of this.entries.values()) entry.agent.destroy()
this.entries.clear()
this.currentRevision = null
}
}
async function handleHttp(request, response, context) {
if (!checkHeaderLimits(request)) return proxyError(response, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
const target = parseAbsoluteTarget(request.url ?? '')
if (target === null || target.unsupported === true || target.url.protocol !== 'http:') {
const invalid = target === null
return proxyError(response, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form HTTP target required' : 'target scheme is not supported')
}
let upstream
let route
let snapshot
const cancellation = new AbortController()
request.once('aborted', () => cancellation.abort())
response.once('close', () => {
if (!response.writableEnded) cancellation.abort()
})
try {
request.pause()
snapshot = context.getSnapshot()
const providerId = authorizeProvider(context, request.headers, snapshot)
route = await selectProxyRoute({
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
dnsCache: context.dnsCache, signal: cancellation.signal,
})
const proxied = route.mode === 'http'
const agent = context.agentPool.agentFor(route, context.timeouts)
const headers = forwardHeaders(request.headers, {
host: target.authority,
authorization: proxied ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null,
})
upstream = httpRequest({
host: proxied ? route.endpoint.host : target.host,
port: proxied ? route.endpoint.port : target.port,
method: request.method,
path: proxied ? target.url.href : requestPath(target),
headers,
agent,
signal: cancellation.signal,
maxHeaderSize: 256 * 1024,
dshProxyTargetHost: target.host,
dshProxyTargetPort: target.port,
dshProxySignal: cancellation.signal,
})
upstream.setTimeout(context.timeouts.responseHeaderMs, () => upstream.destroy(new ProxyTransportError('upstream response timed out', {
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
})))
request.once('aborted', () => upstream.destroy())
upstream.once('response', incoming => {
if (proxied && incoming.statusCode === 407) {
observeRouteHealth(context, snapshot, 'degraded')
incoming.socket.destroy()
return proxyError(response, 502, 'UPSTREAM_PROXY_AUTH_FAILED', 'upstream proxy authentication failed')
}
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
incoming.socket.setTimeout(context.timeouts.streamIdleMs, () => incoming.destroy(new ProxyTransportError('upstream response timed out', {
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
})))
response.writeHead(incoming.statusCode ?? 502, incoming.statusMessage, responseHeaders(incoming.headers))
incoming.pipe(response, { end: false })
incoming.once('end', () => {
if (Object.keys(incoming.trailers).length > 0) response.addTrailers(incoming.trailers)
response.end()
})
incoming.once('error', error => response.destroy(error))
})
upstream.once('error', error => {
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
proxyError(response, fields.statusCode, fields.code, fields.message)
})
request.pipe(upstream, { end: false })
request.once('end', () => {
if (Object.keys(request.trailers).length > 0) upstream.addTrailers(request.trailers)
upstream.end()
})
request.resume()
} catch (error) {
upstream?.destroy()
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
proxyError(response, fields.statusCode, fields.code, fields.message)
}
}
function rawRequest(request, target, { absolute, authorization }) {
const headers = forwardHeaders(request.headers, {
upgrade: true,
host: target.authority,
authorization,
})
const start = `${request.method ?? 'GET'} ${absolute ? target.url.href : requestPath(target)} HTTP/${request.httpVersion}`
return Buffer.from(`${[start, ...Object.entries(headers).map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}`), '', ''].join('\r\n')}`, 'latin1')
}
async function handleUpgrade(request, socket, head, context) {
socket.on('error', () => {})
if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
const target = parseAbsoluteTarget(request.url ?? '')
if (target === null || target.unsupported === true || target.url.protocol !== 'ws:' || String(request.headers.upgrade ?? '').toLowerCase() !== 'websocket') {
const invalid = target === null
return socketError(socket, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form WS target required' : 'target scheme is not supported')
}
const cancellation = new AbortController()
let route
let snapshot
socket.once('close', () => cancellation.abort())
try {
snapshot = context.getSnapshot()
const providerId = authorizeProvider(context, request.headers, snapshot)
route = await selectProxyRoute({
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
dnsCache: context.dnsCache, signal: cancellation.signal,
})
if (!['direct', 'http', 'socks5'].includes(route.mode)) throw new ProxyTransportError('configured proxy protocol is not available', { code: 'PROXY_PROTOCOL_UNAVAILABLE' })
const connected = await httpConnection(route, target, cancellation.signal, context.timeouts)
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
const authorization = route.mode === 'http' ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null
const preface = rawRequest(request, target, { absolute: route.mode === 'http', authorization })
bridge(socket, connected.socket, {
leftHead: Buffer.concat([preface, head]),
rightHead: connected.remainder,
timeoutMs: context.timeouts.streamIdleMs,
})
} catch (error) {
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
socketError(socket, fields.statusCode, fields.code, fields.message)
}
}
async function handleConnect(request, socket, head, context) {
socket.on('error', () => {})
if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
const target = parseAuthority(request.url ?? '')
if (target === null) return socketError(socket, 400, 'INVALID_CONNECT_AUTHORITY', 'CONNECT authority is invalid')
const cancellation = new AbortController()
let route
let snapshot
socket.once('close', () => cancellation.abort())
try {
snapshot = context.getSnapshot()
const providerId = authorizeProvider(context, request.headers, snapshot)
route = await selectProxyRoute({
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
dnsCache: context.dnsCache, signal: cancellation.signal,
})
const connected = await routeSocket(route, target, cancellation.signal, context.timeouts)
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
bridge(socket, connected.socket, {
leftHead: head,
rightHead: connected.remainder,
timeoutMs: context.timeouts.tunnelIdleMs,
})
} catch (error) {
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
socketError(socket, fields.statusCode, fields.code, fields.message)
}
}
export function createScopedProxyServer({
scope,
getSnapshot,
resolver,
dnsCache = new ProxyDnsCache({ resolver }),
agentPool = new ProxyAgentPool(),
routeHealth = new ProxyRouteHealth(),
providerHandles,
timeouts = PROXY_TIMEOUTS,
}) {
const context = Object.freeze({
scope,
getSnapshot,
dnsCache,
agentPool,
routeHealth,
providerHandles,
timeouts: Object.freeze({ ...PROXY_TIMEOUTS, ...timeouts }),
})
const server = createServer({ maxHeaderSize: 256 * 1024, allowHalfOpen: true }, (request, response) => {
void handleHttp(request, response, context)
})
server.maxHeadersCount = 256
server.on('connect', (request, socket, head) => { void handleConnect(request, socket, head, context) })
server.on('upgrade', (request, socket, head) => { void handleUpgrade(request, socket, head, context) })
server.on('clientError', (error, socket) => {
const overflow = error?.code === 'HPE_HEADER_OVERFLOW'
socketError(socket, overflow ? 431 : 400, overflow ? 'REQUEST_HEADERS_TOO_LARGE' : 'INVALID_PROXY_REQUEST', overflow ? 'request headers are too large' : 'proxy request is invalid')
})
return server
}
@@ -0,0 +1,80 @@
import { lookup } from 'node:dns/promises'
import { isIP } from 'node:net'
import { domainToASCII } from 'node:url'
import { ProxyTransportError } from './transport.mjs'
const POSITIVE_TTL_MS = 60_000
const NEGATIVE_TTL_MS = 10_000
function normalizeHost(value) {
if (isIP(value) !== 0) return value.toLowerCase()
const ascii = domainToASCII(String(value).toLowerCase().replace(/\.+$/, ''))
if (ascii === '') throw new ProxyTransportError('target DNS name is invalid', { code: 'TARGET_DNS_FAILED' })
return ascii
}
function normalizeRecords(value) {
if (!Array.isArray(value)) value = [value]
const records = new Map()
for (const record of value) {
const address = typeof record === 'string' ? record : record?.address
const family = typeof record === 'object' ? Number(record?.family) : isIP(address)
if (typeof address !== 'string' || ![4, 6].includes(family) || isIP(address) !== family) continue
const ttl = typeof record?.ttl === 'number' && Number.isFinite(record.ttl)
? Math.max(0, Math.min(60, record.ttl))
: 60
records.set(`${family}:${address}`, Object.freeze({ address, family, ttl }))
}
return Object.freeze([...records.values()])
}
export class ProxyDnsCache {
constructor({ resolver = lookup, now = Date.now, positiveTtlMs = POSITIVE_TTL_MS, negativeTtlMs = NEGATIVE_TTL_MS } = {}) {
this.resolver = resolver
this.now = now
this.positiveTtlMs = positiveTtlMs
this.negativeTtlMs = negativeTtlMs
this.entries = new Map()
}
async resolve(host, revision, { signal } = {}) {
const normalized = normalizeHost(host)
const type = isIP(normalized)
if (type !== 0) return Object.freeze([{ address: normalized, family: type, ttl: 60 }])
const key = `${revision}\u0000${normalized}`
const cached = this.entries.get(key)
if (cached !== undefined && cached.expiresAt > this.now()) {
if (cached.error !== null) throw cached.error
return cached.records
}
if (signal?.aborted) throw new ProxyTransportError('target DNS resolution was cancelled', { code: 'REQUEST_CANCELLED', statusCode: 499 })
let records
try {
if (signal === undefined) records = await this.resolver(normalized, { all: true, verbatim: true })
else records = await new Promise((resolve, reject) => {
const cancelled = () => reject(new ProxyTransportError('target DNS resolution was cancelled', {
code: 'REQUEST_CANCELLED', statusCode: 499,
}))
signal.addEventListener('abort', cancelled, { once: true })
this.resolver(normalized, { all: true, verbatim: true }).then(resolve, reject).finally(() => {
signal.removeEventListener('abort', cancelled)
})
})
records = normalizeRecords(records)
if (records.length === 0) throw new Error('resolver returned no usable addresses')
} catch (cause) {
if (cause instanceof ProxyTransportError) throw cause
const error = new ProxyTransportError('target DNS resolution failed', { code: 'TARGET_DNS_FAILED', cause })
this.entries.set(key, Object.freeze({ records: null, error, expiresAt: this.now() + this.negativeTtlMs }))
throw error
}
const recordTtlMs = Math.min(...records.map(record => record.ttl * 1000), this.positiveTtlMs)
this.entries.set(key, Object.freeze({ records, error: null, expiresAt: this.now() + recordTtlMs }))
if (this.entries.size > 512) {
const now = this.now()
for (const [entryKey, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(entryKey)
while (this.entries.size > 512) this.entries.delete(this.entries.keys().next().value)
}
return records
}
}
@@ -0,0 +1,26 @@
export class ProxyConfigurationError extends Error {
constructor(message, {
code = 'INVALID_CONFIG',
statusCode = 400,
stage = 'validate',
retryable = false,
} = {}) {
super(message)
this.name = 'ProxyConfigurationError'
this.code = code
this.statusCode = statusCode
this.stage = stage
this.retryable = retryable
}
}
export function proxyErrorBody(error) {
return Object.freeze({
error: Object.freeze({
code: error?.code ?? 'PROXY_MANAGER_UNAVAILABLE',
message: error?.message ?? 'proxy manager is unavailable',
stage: error?.stage ?? 'unknown',
retryable: error?.retryable === true,
}),
})
}
@@ -0,0 +1,47 @@
import { isIP } from 'node:net'
import { matchesProxyRules, normalizeProxyRules } from './rules.mjs'
import { outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs'
const PLATFORM_RULES = normalizeProxyRules(['localhost', '127.0.0.1', '::1'], { label: 'platform NO_PROXY' })
function route(mode, reason, snapshot, fields = {}) {
return Object.freeze({ mode, reason, revision: snapshot.revision, ...fields })
}
export async function selectProxyRoute({ snapshot, scope, providerId, host, port, dnsCache, signal }) {
const configuration = snapshot.configuration
const userNoProxy = normalizeProxyRules(configuration.noProxy.user, {
allowWildcard: true,
label: 'NO_PROXY',
})
const bypass = normalizeProxyRules(configuration.bypass.additional, {
allowCidr: true,
label: 'proxy bypass',
})
const bypassHosts = bypass.filter(rule => rule.type !== 'cidr')
const bypassCidrs = bypass.filter(rule => rule.type === 'cidr')
if (matchesProxyRules(PLATFORM_RULES, host, port)) return route('direct', 'platform-forced-direct', snapshot)
if (matchesProxyRules(userNoProxy, host, port)) return route('direct', 'no-proxy', snapshot)
if (matchesProxyRules(bypassHosts, host, port)) return route('direct', 'bypass', snapshot)
if (isIP(host) !== 0 && matchesProxyRules(bypassCidrs, host, port)) return route('direct', 'bypass', snapshot)
if (!outboundProxyScopeEnabled(configuration, scope, providerId)) {
return route('direct', scope === 'modelApi' ? 'provider-direct' : 'scope-direct', snapshot)
}
const endpoint = Object.freeze({
host: configuration.proxy.host,
port: configuration.proxy.port,
remoteDns: configuration.proxy.remoteDns,
username: snapshot.credentials.username,
password: snapshot.credentials.password,
})
if (configuration.proxy.protocol !== 'socks5' || configuration.proxy.remoteDns || isIP(host) !== 0) {
const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy'
return route(configuration.proxy.protocol, reason, snapshot, { endpoint })
}
if (dnsCache === undefined) throw new TypeError('SOCKS5 local DNS requires a ProxyDnsCache')
const targets = await dnsCache.resolve(host, snapshot.revision, { signal })
const directTargets = targets.filter(target => matchesProxyRules(bypassCidrs, target.address, port))
if (directTargets.length > 0) return route('direct', 'bypass-cidr', snapshot, { targets: Object.freeze(directTargets) })
const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy'
return route('socks5', reason, snapshot, { endpoint, targets })
}
Loaded 100 of 302 files, more files were not shown because too many files have changed in this diff. Show more