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:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -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 })
|
||||
Reference in new issue
Block a user