/** * Local persistent PTY backend using public `node-pty` APIs, shared sandbox * policy, bounded output, platform readiness probes, and process-session cleanup. * @module @deepseek-ai/dsh-pty-local */ import { Context } from 'cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { type Config, type ResolvedConfig, validateConfig } from './config.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalPtySession } from './session.ts' export { Config } from './config.ts' export type { Config as PtyLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'pty-local' /** Required services: registry plus the one shared confinement policy. */ export const inject = ['pty', 'sandbox', 'sandboxPolicy'] const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(process.env)) { if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value } return { ...env, TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"', BASH_SILENCE_DEPRECATION_WARNING: '1', DSH_SHELL: '1', DSH_SESSION_ID: spec.owner.id, DSH_PTY_SESSION_ID: spec.sessionId, } } function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] { const argv = [config.shellPath, ...config.shellArgs] const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode if (mode === 'danger-full-access') return argv return ctx.sandbox.confine(argv, { mode: mode, workspaceRoot: ctx.sandboxPolicy.workspaceRoot, }).argv } /** Local shell backend registered under the configured type. */ export class LocalPtyBackend implements PtyBackend { readonly type: string constructor( private readonly ctx: Context, private readonly config: ResolvedConfig, private readonly inspector: ProcessInspector, private readonly spawnTerminal: typeof nodePty.spawn = nodePty.spawn, private readonly createSession: ( terminal: ReturnType, inspector: ProcessInspector, config: ResolvedConfig, ) => LocalPtySession = (terminal, inspector, config) => new LocalPtySession(terminal, inspector, config), ) { this.type = config.backendType } async spawn(spec: PtyBackendSpawnSpec): Promise { if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted') const argv = spawnArgv(this.ctx, this.config, spec) const file = argv[0] if (file === undefined) throw new Error('pty-local: sandbox returned empty argv') const options: IPtyForkOptions = { name: 'dumb', cols: this.config.cols, rows: this.config.rows, cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot, env: childEnvironment(spec), } const terminal = this.spawnTerminal(file, argv.slice(1), options) const session = this.createSession(terminal, this.inspector, this.config) try { await session.initialize(spec.signal) return session } catch (error) { try { await session.close('PTY startup failed') } catch (closeError: unknown) { throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed') } throw error } } } /** Register the local PTY backend. */ export function apply(ctx: Context, config: Config): void { validateConfig(config) const inspector = createProcessInspector() ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector)) }