import * as plugins from './plugins.js'; import type { ControllerContainerEnvironment, IContainerEnvironmentRunLease } from './classes.containerenvironment.js'; const wrapperScript = [ '#!/bin/bash', 'set -euo pipefail', 'pidfile=${1:?pid file required}; shift', 'stat=$( "$temporary"', 'mv -- "$temporary" "$pidfile"', 'exec "$@"', '', ].join('\n'); const signalScript = [ '#!/bin/bash', 'set -euo pipefail', 'mode=${1:?signal required}; pidfile=${2:?pid file required}', '[[ "$mode" == TERM || "$mode" == KILL ]] || exit 40', '[[ -f "$pidfile" ]] || exit 41', 'read -r pid pgid started < "$pidfile" || exit 42', '[[ "$pid" =~ ^[1-9][0-9]*$ && "$pgid" =~ ^[1-9][0-9]*$ && "$started" =~ ^[1-9][0-9]*$ ]] || exit 43', '[[ -r "/proc/$pid/stat" ]] || exit 44', 'stat=$(<"/proc/$pid/stat"); rest=${stat##*) }', 'read -ra fields <<< "$rest"', '[[ "${fields[2]}" == "$pgid" && "${fields[19]}" == "$started" ]] || exit 45', 'initstat=$( => { const destination = plugins.path.join(directoryArg, nameArg); const temporary = `${destination}.${process.pid}.${plugins.crypto.randomUUID()}.tmp`; try { await plugins.fs.promises.writeFile(temporary, contentsArg, { flag: 'wx', mode: 0o755 }); await plugins.fs.promises.rename(temporary, destination); } finally { await plugins.fs.promises.rm(temporary, { force: true }); } }; /** Generated executables are mounted read-only; PID files are disposable process IPC. */ export const prepareContainerTerminalAssets = async (assetsDirectoryArg: string): Promise => { await writeExecutableAsset(assetsDirectoryArg, 'chat-wrapper', wrapperScript); await writeExecutableAsset(assetsDirectoryArg, 'signal-chat', signalScript); }; export interface IControllerTerminalExecutionExit { exitCode: number; /** The TTY transport ended while Docker still reported the process running. */ streamLost: boolean; } /** Future host and container implementations share this manager-facing terminal contract. */ export interface IControllerTerminalExecution { readonly finalPromise: Promise; sendInput(dataArg: Uint8Array): Promise; resize(colsArg: number, rowsArg: number): Promise; pause(): void; resume(): void; terminate(): Promise; kill(): Promise; close(): Promise; } export interface IControllerDockerTerminalStartOptions { command: string; args: readonly string[]; workingDirectory: string; environment: Record; cols: number; rows: number; onData: (chunkArg: Buffer) => void; } export interface IControllerDockerTerminalExecutorOptions { environment: Pick< ControllerContainerEnvironment, 'execInteractiveWithRunLease' | 'execIfCurrentRun' | 'resizeExecIfCurrentRun' | 'stopIfCurrentRun' >; stateDirectory: string; } type TInteractiveExec = plugins.docker.IContainerInteractiveExec; export class ControllerDockerTerminalExecutor { constructor(private readonly options: IControllerDockerTerminalExecutorOptions) { if (!plugins.path.isAbsolute(options.stateDirectory)) { throw new Error('Container terminal state directory must be absolute.'); } } public async start(optionsArg: IControllerDockerTerminalStartOptions): Promise { if (!optionsArg.command || !plugins.path.isAbsolute(optionsArg.command)) { throw new Error('Container terminal command must be an absolute executable path.'); } if (!plugins.path.isAbsolute(optionsArg.workingDirectory)) { throw new Error('Container terminal working directory must be absolute.'); } const runDirectory = plugins.path.join(this.options.stateDirectory, 'run'); await plugins.fs.promises.mkdir(runDirectory, { recursive: true, mode: 0o700 }); const executionName = plugins.crypto.randomBytes(12).toString('hex'); const hostPidFile = plugins.path.join(runDirectory, `${executionName}.pid`); const containerPidFile = `/opt/agl-state/run/${executionName}.pid`; const { session, runLease } = await this.options.environment.execInteractiveWithRunLease( ['/opt/agl/chat-wrapper', containerPidFile, optionsArg.command, ...optionsArg.args], { env: optionsArg.environment, workingDirectory: optionsArg.workingDirectory, tty: true, detachKeys: 'ctrl-@,ctrl-_', consoleSize: [optionsArg.rows, optionsArg.cols], }, ); const execution = new ControllerDockerTerminalExecution( this.options.environment, runLease, session, hostPidFile, containerPidFile, optionsArg.onData, ); // The owner may attach its result handler after start returns; early process exit is possible. void execution.finalPromise.catch(() => undefined); try { const deadline = Date.now() + 5_000; while (true) { try { await plugins.fs.promises.access(hostPidFile); break; } catch { /* The exec handshake can finish before the wrapper starts. */ } if (!(await session.inspect()).Running) break; if (Date.now() >= deadline) { const stopped = await this.options.environment.stopIfCurrentRun(runLease); throw new Error(stopped ? 'Container terminal wrapper did not establish its PID proof; environment stopped.' : 'Container terminal wrapper did not establish its PID proof; its run already ended.'); } await plugins.timersPromises.setTimeout(10); } } catch (errorArg) { await session.close(); throw errorArg; } return execution; } } class ControllerDockerTerminalExecution implements IControllerTerminalExecution { public readonly finalPromise: Promise; private resolveFinal!: (exitArg: IControllerTerminalExecutionExit) => void; private rejectFinal!: (errorArg: unknown) => void; private settling = false; private ownerSignalled = false; private finished = false; constructor( private readonly environment: IControllerDockerTerminalExecutorOptions['environment'], private readonly runLease: IContainerEnvironmentRunLease, private readonly session: TInteractiveExec, private readonly hostPidFile: string, private readonly containerPidFile: string, private readonly onData: (chunkArg: Buffer) => void, ) { this.finalPromise = new Promise((resolve, reject) => { this.resolveFinal = resolve; this.rejectFinal = reject; }); session.stream.on('data', this.handleData); session.stream.once('end', this.handleEnd); session.stream.once('close', this.handleEnd); session.stream.once('error', this.handleError); if (session.stream.destroyed) this.beginSettlement(); } private readonly handleData = (chunkArg: Buffer | Uint8Array | string): void => { try { this.onData(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)); } catch (errorArg) { this.session.stream.destroy(errorArg instanceof Error ? errorArg : new Error(String(errorArg))); } }; private readonly handleEnd = (): void => this.beginSettlement(); private readonly handleError = (): void => this.beginSettlement(); private beginSettlement(): void { if (this.settling) return; this.settling = true; void this.finish().then(this.resolveFinal, this.rejectFinal); } private async finish(): Promise { let streamLost = false; try { let inspection = await this.session.inspect(); if (inspection.Running) { streamLost = !this.ownerSignalled; await this.signalProcess('KILL'); inspection = await this.waitForExit(); } this.finished = true; return { exitCode: inspection.ExitCode, streamLost }; } catch (errorArg) { return await this.failUnproven(errorArg, 'Container terminal state was unproven'); } finally { this.session.stream.off('data', this.handleData); this.session.stream.off('end', this.handleEnd); this.session.stream.off('close', this.handleEnd); this.session.stream.off('error', this.handleError); try { await this.session.close(); } finally { await plugins.fs.promises.rm(this.hostPidFile, { force: true }); } } } private async waitForExit(): Promise { const deadline = Date.now() + 5_000; while (true) { const inspection = await this.session.inspect(); if (!inspection.Running) return inspection; if (Date.now() >= deadline) { throw new Error('Container terminal process did not stop after its signal.'); } await plugins.timersPromises.setTimeout(50); } } private async signalProcess(signalArg: 'TERM' | 'KILL'): Promise { this.ownerSignalled = true; try { const result = await this.environment.execIfCurrentRun( this.runLease, ['/opt/agl/signal-chat', signalArg, this.containerPidFile], { timeoutMs: 5_000, maxOutputBytes: 4096 }, ); if (result?.exitCode === 0) return; const inspection = await this.session.inspect(); if (!inspection.Running) return; throw new Error(result ? `Container terminal PID proof failed (${result.exitCode}).` : 'Container terminal owning run is no longer current.'); } catch (errorArg) { try { const inspection = await this.session.inspect(); if (!inspection.Running) return; } catch { /* The exact process cannot be proven stopped. */ } return this.failUnproven(errorArg, 'Container terminal could not prove its process stopped'); } } private async failUnproven(errorArg: unknown, messageArg: string): Promise { let stopped: boolean; try { stopped = await this.environment.stopIfCurrentRun(this.runLease); } catch (stopErrorArg) { throw new AggregateError( [errorArg, stopErrorArg], `${messageArg} and stopping its environment failed.`, ); } throw new Error( stopped ? `${messageArg}; environment stopped.` : `${messageArg}; owning run already ended.`, { cause: errorArg }, ); } public async sendInput(dataArg: Uint8Array): Promise { if (this.settling || this.session.stream.destroyed) throw new Error('Container terminal stream is closed.'); await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.session.stream.destroy(new Error('Container terminal input stalled.')); reject(new Error('Container terminal input stalled.')); }, 5_000); timer.unref(); this.session.stream.write(dataArg, (errorArg) => { clearTimeout(timer); if (errorArg) reject(errorArg); else resolve(); }); }); } public async resize(colsArg: number, rowsArg: number): Promise { if (this.settling) throw new Error('Container terminal stream is closed.'); if (!(await this.environment.resizeExecIfCurrentRun(this.runLease, this.session.execId, rowsArg, colsArg))) { throw new Error('Container terminal owning run is no longer current.'); } } public pause(): void { this.session.stream.pause(); } public resume(): void { this.session.stream.resume(); } public async terminate(): Promise { if (this.finished) return; await this.signalProcess('TERM'); } public async kill(): Promise { if (this.finished) return; await this.signalProcess('KILL'); } public async close(): Promise { if (!this.settling) { try { const inspection = await this.session.inspect(); if (inspection.Running) await this.kill(); } finally { try { await this.session.close(); } finally { this.beginSettlement(); } } } return this.finalPromise; } }