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

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

No files matched your search

@@ -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 })