import * as plugins from '../../plugins.ts'; import { logger } from '../../logging.ts'; import type { OpsServer } from '../classes.opsserver.ts'; import * as interfaces from '../../../ts_interfaces/index.ts'; import { requireAdminIdentity } from '../helpers/guards.ts'; import { getErrorMessage } from '../../utils/error.ts'; interface IWorkspaceProcessSession { processId: string; serviceName: string; userId: string; stream: plugins.nodeStream.Duplex; close: () => Promise; inspect: () => Promise<{ ExitCode?: number | null; Running?: boolean }>; finalized: boolean; } const getWorkspaceProcessTag = (processIdArg: string) => `workspaceProcess:${processIdArg}`; export class WorkspaceHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); private workspaceProcesses = new Map(); constructor(private opsServerRef: OpsServer) { this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } /** * Resolve a service name to a container ID (handling Swarm service IDs) */ private async resolveContainerId(serviceName: string): Promise { const service = this.opsServerRef.oneboxRef.services.getService(serviceName); if (!service || !service.containerID) { throw new plugins.typedrequest.TypedResponseError(`Service not found or has no container: ${serviceName}`); } return service.containerID; } private validateProcessId(processIdArg: string): void { if (!/^[a-zA-Z0-9_-]{8,80}$/.test(processIdArg)) { throw new plugins.typedrequest.TypedResponseError('Invalid workspace process id'); } } private async getShellCommandForContainer( containerIdArg: string, ): Promise { const candidates: interfaces.requests.IWorkspaceShellCommand[] = [ { command: '/bin/bash', args: ['-il'], label: 'bash', prompt: '# ' }, { command: 'bash', args: ['-il'], label: 'bash', prompt: '# ' }, { command: '/bin/sh', args: ['-i'], label: 'sh', prompt: '# ' }, { command: 'sh', args: ['-i'], label: 'sh', prompt: '# ' }, { command: '/bin/ash', args: ['-i'], label: 'ash', prompt: '# ' }, { command: 'ash', args: ['-i'], label: 'ash', prompt: '# ' }, { command: '/usr/bin/zsh', args: ['-il'], label: 'zsh', prompt: '# ' }, { command: 'zsh', args: ['-il'], label: 'zsh', prompt: '# ' }, ]; for (const candidate of candidates) { const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerIdArg, [candidate.command, '-c', 'printf onebox-shell'], ); if (result.exitCode === 0 && result.stdout.includes('onebox-shell')) { return candidate; } } throw new plugins.typedrequest.TypedResponseError( 'No supported interactive shell found in the target container', ); } private async getProcessSession( dataArg: { identity: interfaces.data.IIdentity; processId: string }, ): Promise { const identity = await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); this.validateProcessId(dataArg.processId); const session = this.workspaceProcesses.get(dataArg.processId); if (!session) { throw new plugins.typedrequest.TypedResponseError(`Workspace process not found: ${dataArg.processId}`); } if (session.userId !== identity.userId) { throw new plugins.typedrequest.TypedResponseError('Workspace process belongs to another session'); } return session; } private async pushWorkspaceProcessOutput(processIdArg: string, outputArg: string): Promise { const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket; if (!typedsocket) return; const connections = await typedsocket.findAllTargetConnectionsByTag(getWorkspaceProcessTag(processIdArg)); await Promise.allSettled( connections.map((connection: any) => typedsocket .createTypedRequest( 'pushWorkspaceProcessOutput', connection, ) .fire({ processId: processIdArg, output: outputArg })), ); } private async pushWorkspaceProcessExit(processIdArg: string, exitCodeArg: number): Promise { const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket; if (!typedsocket) return; const connections = await typedsocket.findAllTargetConnectionsByTag(getWorkspaceProcessTag(processIdArg)); await Promise.allSettled( connections.map((connection: any) => typedsocket .createTypedRequest( 'pushWorkspaceProcessExit', connection, ) .fire({ processId: processIdArg, exitCode: exitCodeArg })), ); } private async finalizeWorkspaceProcess(processIdArg: string, fallbackExitCodeArg = -1): Promise { const session = this.workspaceProcesses.get(processIdArg); if (!session || session.finalized) return; session.finalized = true; let exitCode = fallbackExitCodeArg; try { await new Promise((resolve) => setTimeout(resolve, 50)); const inspectResult = await session.inspect(); if (typeof inspectResult.ExitCode === 'number') { exitCode = inspectResult.ExitCode; } } catch (error) { logger.debug(`Failed to inspect workspace process ${processIdArg}: ${getErrorMessage(error)}`); } this.workspaceProcesses.delete(processIdArg); await this.pushWorkspaceProcessExit(processIdArg, exitCode); try { await session.close(); } catch { // The hijacked connection may already be closed by Docker. } } private registerHandlers(): void { // Read file from container this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceReadFile', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerId, ['cat', dataArg.path], ); if (result.exitCode !== 0) { throw new plugins.typedrequest.TypedResponseError(`Failed to read file: ${result.stderr || 'File not found'}`); } return { content: result.stdout }; }, ), ); // Write file to container this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceWriteFile', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); // Use sh -c with printf to write content (handles special characters) const escaped = dataArg.content.replace(/'/g, "'\\''"); const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerId, ['sh', '-c', `printf '%s' '${escaped}' > ${dataArg.path}`], ); if (result.exitCode !== 0) { throw new plugins.typedrequest.TypedResponseError(`Failed to write file: ${result.stderr}`); } return {}; }, ), ); // Read directory from container this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceReadDir', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); // Use ls with -1 -F to get entries with type indicators (/ for dirs) const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerId, ['ls', '-1', '-F', dataArg.path], ); if (result.exitCode !== 0) { throw new plugins.typedrequest.TypedResponseError(`Failed to read directory: ${result.stderr}`); } const entries = result.stdout .split('\n') .filter((line) => line.trim()) .map((line) => { const isDir = line.endsWith('/'); const name = isDir ? line.slice(0, -1) : line.replace(/[*@=|]$/, ''); const basePath = dataArg.path.endsWith('/') ? dataArg.path : dataArg.path + '/'; return { type: (isDir ? 'directory' : 'file') as 'file' | 'directory', name, path: basePath + name, }; }); return { entries }; }, ), ); // Create directory in container this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceMkdir', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerId, ['mkdir', '-p', dataArg.path], ); if (result.exitCode !== 0) { throw new plugins.typedrequest.TypedResponseError(`Failed to create directory: ${result.stderr}`); } return {}; }, ), ); // Remove file/directory from container this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceRm', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); const args = dataArg.recursive ? ['rm', '-rf', dataArg.path] : ['rm', '-f', dataArg.path]; const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerId, args, ); if (result.exitCode !== 0) { throw new plugins.typedrequest.TypedResponseError(`Failed to remove: ${result.stderr}`); } return {}; }, ), ); // Check if path exists in container this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceExists', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerId, ['test', '-e', dataArg.path], ); return { exists: result.exitCode === 0 }; }, ), ); // Execute a command in the container (non-interactive) this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceExec', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); const cmd = dataArg.args ? [dataArg.command, ...dataArg.args] : [dataArg.command]; const result = await this.opsServerRef.oneboxRef.docker.execInContainer( containerId, cmd, ); return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceGetShellCommand', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const containerId = await this.resolveContainerId(dataArg.serviceName); const shellCommand = await this.getShellCommandForContainer(containerId); return { shellCommand }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceStartProcess', async (dataArg) => { const identity = await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); this.validateProcessId(dataArg.processId); if (this.workspaceProcesses.has(dataArg.processId)) { throw new plugins.typedrequest.TypedResponseError(`Workspace process already exists: ${dataArg.processId}`); } const containerId = await this.resolveContainerId(dataArg.serviceName); const command = dataArg.args ? [dataArg.command, ...dataArg.args] : [dataArg.command]; const interactiveExec = await this.opsServerRef.oneboxRef.docker.startInteractiveExecInContainer( containerId, command, ); const session: IWorkspaceProcessSession = { processId: dataArg.processId, serviceName: dataArg.serviceName, userId: identity.userId, stream: interactiveExec.stream, close: interactiveExec.close, inspect: interactiveExec.inspect, finalized: false, }; this.workspaceProcesses.set(dataArg.processId, session); interactiveExec.stream.on('data', (chunk: Uint8Array | string) => { const output = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk); void this.pushWorkspaceProcessOutput(dataArg.processId, output); }); interactiveExec.stream.on('error', (error: Error) => { void this.pushWorkspaceProcessOutput( dataArg.processId, `\r\n[workspace process error: ${getErrorMessage(error)}]\r\n`, ); void this.finalizeWorkspaceProcess(dataArg.processId, -1); }); interactiveExec.stream.on('end', () => { void this.finalizeWorkspaceProcess(dataArg.processId, -1); }); interactiveExec.stream.on('close', () => { void this.finalizeWorkspaceProcess(dataArg.processId, -1); }); return { processId: dataArg.processId }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceProcessInput', async (dataArg) => { const session = await this.getProcessSession(dataArg); if (session.finalized || session.stream.writableEnded) { return {}; } await new Promise((resolve, reject) => { session.stream.write(dataArg.input, (error?: Error | null) => { if (error) { reject(error); } else { resolve(); } }); }); return {}; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'workspaceKillProcess', async (dataArg) => { const session = await this.getProcessSession(dataArg); session.stream.destroy(); try { await session.close(); } catch { // The stream may already be closed. } await this.finalizeWorkspaceProcess(dataArg.processId, -1); return {}; }, ), ); logger.info('Workspace handler registered'); } }