#!/usr/bin/env bun /** * Unified DAP Debug Service * * A containerizable WebSocket service that routes debug requests to either * Python (debugpy) or TypeScript/Bun debuggers based on the endpoint hit. * * Endpoints: * /python - Python debugging via dap_websocket_server.py (bdb-based) * /typescript - TypeScript/Bun debugging via WebKit Inspector * /bun - Alias for /typescript * * Designed to be compatible with nsjail wrapping for sandboxed execution. * * Usage: * bun run dap_debug_service.ts [options] * * Options: * --port PORT Server port (default: 3003) * --host HOST Server host (default: 0.0.0.0) * --nsjail Enable nsjail wrapping * --nsjail-config PATH Path to nsjail config file * --windmill PATH Path to windmill binary for automatic dependency installation * --debug Enable debug logging * * Environment Variables: * DAP_PORT Server port * DAP_HOST Server host * DAP_NSJAIL_ENABLED Enable nsjail (true/false) * DAP_NSJAIL_CONFIG Path to nsjail config file * DAP_NSJAIL_PATH Path to nsjail binary (default: nsjail) * DAP_WINDMILL_PATH Path to windmill binary for dependency auto-installation * DAP_DEBUG Enable debug logging (true/false) */ import { spawn, type Subprocess } from 'bun' import { mkdtemp, writeFile, unlink, rmdir } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' // Import the working Bun debug session from the standalone server import { DebugSession as BunDebugSessionWorking, killProcessTree, nsjailWrap, type NsjailConfig } from './dap_websocket_server_bun' import { sessionEnv } from './env_passthrough' import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // ============================================================================ // Configuration // ============================================================================ interface ServiceConfig { port: number host: string nsjail: { enabled: boolean configPath?: string binaryPath: string // Additional nsjail options that can be passed extraArgs: string[] } // Paths to debugger binaries (can be overridden for containerized deployments) pythonPath: string bunPath: string // Windmill binary path for prepare-deps CLI (optional, for dependency auto-installation) windmillPath?: string // Enable debug logging debug: boolean } function parseConfig(): ServiceConfig { const args = process.argv.slice(2) const config: ServiceConfig = { port: parseInt(process.env.DAP_PORT || '3003', 10), host: process.env.DAP_HOST || '0.0.0.0', nsjail: { enabled: process.env.DAP_NSJAIL_ENABLED === 'true', configPath: process.env.DAP_NSJAIL_CONFIG, binaryPath: process.env.DAP_NSJAIL_PATH || 'nsjail', extraArgs: [] }, pythonPath: process.env.DAP_PYTHON_PATH || '/usr/bin/python3', bunPath: process.env.DAP_BUN_PATH || '/usr/bin/bun', windmillPath: process.env.DAP_WINDMILL_PATH, debug: process.env.DAP_DEBUG === 'true' } for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--port': config.port = parseInt(args[++i], 10) break case '--host': config.host = args[++i] break case '--nsjail': config.nsjail.enabled = true break case '--nsjail-config': config.nsjail.configPath = args[++i] break case '--nsjail-path': config.nsjail.binaryPath = args[++i] break case '--python-path': config.pythonPath = args[++i] break case '--bun-path': config.bunPath = args[++i] break case '--windmill': config.windmillPath = args[++i] break case '--debug': config.debug = true break } } return config } const config = parseConfig() // Validate windmill path exists if specified if (config.windmillPath && !existsSync(config.windmillPath)) { console.error(`ERROR: Windmill binary not found at: ${config.windmillPath}`) console.error('Please provide a valid path with --windmill /path/to/windmill') process.exit(1) } // ============================================================================ // Logging // ============================================================================ const LOG_LEVEL = process.env.LOG_LEVEL || 'INFO' const logger = { debug: (...args: unknown[]) => { if (LOG_LEVEL === 'DEBUG') console.log('[DEBUG]', new Date().toISOString(), ...args) }, info: (...args: unknown[]) => console.log('[INFO]', new Date().toISOString(), ...args), warn: (...args: unknown[]) => console.warn('[WARN]', new Date().toISOString(), ...args), error: (...args: unknown[]) => console.error('[ERROR]', new Date().toISOString(), ...args) } // ============================================================================ // JWT Token Verification // ============================================================================ // JWT verification for debug requests // The debugger fetches the public key from the Windmill backend's JWKS endpoint const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000 const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false' // Opt-in cross-origin protection (CSWSH defense-in-depth). When // DEBUG_ALLOWED_ORIGINS is set (comma-separated list of origins), browser // requests carrying a non-matching Origin header are rejected at the // handshake. Non-browser clients send no Origin and are unaffected; code // execution is independently gated by signed-token verification on launch. const ALLOWED_ORIGINS = (process.env.DEBUG_ALLOWED_ORIGINS || '') .split(',') .map(o => o.trim()) .filter(Boolean) function isOriginRejected(req: Request): boolean { const origin = req.headers.get('origin') if (!origin || ALLOWED_ORIGINS.length === 0) return false return !ALLOWED_ORIGINS.includes(origin) } interface JWK { kty: string crv: string x: string kid: string use: string alg: string } interface JWKS { keys: JWK[] } interface DebugTokenClaims { code_hash: string language: string workspace_id: string email: string iat: number exp: number job_id: string } // Cached public key let cachedPublicKey: CryptoKey | null = null let publicKeyFetchPromise: Promise | null = null /** * Fetch and cache the Ed25519 public key from the JWKS endpoint. */ async function getPublicKey(): Promise { if (cachedPublicKey) { return cachedPublicKey } if (publicKeyFetchPromise) { return publicKeyFetchPromise } if (!WINDMILL_BASE_URL) { logger.warn('WINDMILL_BASE_URL not set - cannot fetch public key') return null } publicKeyFetchPromise = (async () => { try { const jwksUrl = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/jwks` logger.info(`Fetching JWKS from ${jwksUrl}`) const response = await fetch(jwksUrl) if (!response.ok) { throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) } const jwks: JWKS = await response.json() if (!jwks.keys || jwks.keys.length === 0) { throw new Error('No keys in JWKS') } const jwk = jwks.keys[0] if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { throw new Error(`Unsupported key type: ${jwk.kty}/${jwk.crv}`) } // Decode the public key from base64url const publicKeyBytes = base64urlDecode(jwk.x) // Import as Ed25519 public key const key = await crypto.subtle.importKey( 'raw', publicKeyBytes, { name: 'Ed25519' }, true, ['verify'] ) cachedPublicKey = key logger.info('Successfully loaded Ed25519 public key from JWKS') return key } catch (error) { logger.error(`Failed to fetch/parse JWKS: ${error}`) return null } finally { publicKeyFetchPromise = null } })() return publicKeyFetchPromise } /** * Compute SHA-256 hash of code and return first 16 bytes as hex. */ async function computeCodeHash(code: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(code) const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashArray = new Uint8Array(hashBuffer) return Array.from(hashArray.slice(0, 16)) .map(b => b.toString(16).padStart(2, '0')) .join('') } /** * Base64url decode */ function base64urlDecode(str: string): Uint8Array { // Add padding if needed const padding = '='.repeat((4 - str.length % 4) % 4) const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding const binary = atob(base64) return Uint8Array.from(binary, c => c.charCodeAt(0)) } /** * Verify a JWT debug token. * Returns null if valid, or an error message if invalid. */ async function verifyDebugToken(token: string, code: string): Promise { const publicKey = await getPublicKey() if (!publicKey) { if (REQUIRE_SIGNED_REQUESTS) { return 'Public key not available but signed requests are required. Set WINDMILL_BASE_URL.' } logger.warn('Public key not available - signature verification disabled') return null } // Parse JWT const parts = token.split('.') if (parts.length !== 3) { return 'Invalid JWT format' } const [headerB64, claimsB64, signatureB64] = parts try { // Verify signature const message = new TextEncoder().encode(`${headerB64}.${claimsB64}`) const signature = base64urlDecode(signatureB64) const isValid = await crypto.subtle.verify( { name: 'Ed25519' }, publicKey, signature, message ) if (!isValid) { return 'Invalid JWT signature' } // Parse and validate claims const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)) const claims: DebugTokenClaims = JSON.parse(claimsJson) // Check expiration const now = Math.floor(Date.now() / 1000) if (now > claims.exp) { return `Token expired: ${now - claims.exp} seconds ago` } // Verify code hash const expectedHash = await computeCodeHash(code) if (claims.code_hash !== expectedHash) { return 'Code hash mismatch - code was modified after signing' } logger.info(`Verified debug token from ${claims.email} in workspace ${claims.workspace_id} (job: ${claims.job_id})`) return null } catch (error) { return `JWT verification error: ${error}` } } // ============================================================================ // Process Spawning with nsjail Support // ============================================================================ interface SpawnOptions { cmd: string[] cwd?: string env?: Record stdin?: Blob /** * Hand the child this process's whole environment rather than the minimal set below. Only * the dependency installer wants it: the registry credentials and CA settings it reads are * precisely what the minimal set exists to keep away from a debugged script. */ inheritEnv?: boolean /** * Start the child in its own process group, so killProcessTree can signal what it spawns. */ detached?: boolean stdout?: 'pipe' | 'inherit' stderr?: 'pipe' | 'inherit' } /** * How long `windmill prepare-deps` may take before the session gives up on it and starts without * the dependencies. Raise it for slow private mirrors, where a large install can outlast the default. */ const PREPARE_DEPS_TIMEOUT_MS = Number(process.env.DAP_PREPARE_DEPS_TIMEOUT_MS) || 120_000 /** * Spawn a process, optionally wrapped with nsjail. * This is the key function for sandboxed execution. */ function spawnProcess(options: SpawnOptions): Subprocess { const cmd = nsjailWrap(options.cmd, config.nsjail, options.cwd) if (config.nsjail.enabled) { logger.info(`Spawning with nsjail: ${cmd.join(' ')}`) } else { logger.info(`Spawning: ${cmd.join(' ')}`) } // Only include essential env vars + caller-provided ones // Don't inherit all of process.env to keep debugger environment clean return spawn({ cmd, cwd: options.cwd || process.cwd(), ...(options.stdin ? { stdin: options.stdin } : {}), ...(options.detached ? { detached: true } : {}), stdout: options.stdout || 'pipe', stderr: options.stderr || 'pipe', env: { ...(options.inheritEnv ? process.env : {}), // Essential system vars PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME, // Caller-provided env vars // Note: WM_BASE_URL is already overridden by BASE_INTERNAL_URL if set ...options.env } }) } // ============================================================================ // DAP Types // ============================================================================ interface DAPMessage { seq: number type: 'request' | 'response' | 'event' command?: string event?: string request_seq?: number success?: boolean message?: string body?: Record arguments?: Record } // ============================================================================ // Base Debug Session // ============================================================================ abstract class BaseDebugSession { protected ws: { send: (data: string) => void; close: () => void } protected seq = 1 protected initialized = false protected configured = false protected running = false protected terminatedSent = false protected process: Subprocess | null = null protected scriptPath: string | null = null protected tempDir: string | null = null protected tempFile: string | null = null protected breakpoints = new Map() protected callMain = false protected mainArgs: Record = {} protected scriptResult: unknown = undefined constructor(ws: { send: (data: string) => void; close: () => void }) { this.ws = ws } protected nextSeq(): number { return this.seq++ } protected sendMessage(msg: DAPMessage): void { const data = JSON.stringify(msg) logger.debug('Sending DAP:', data) this.ws.send(data) } protected sendResponse( request: DAPMessage, success = true, body: Record = {}, message = '' ): void { this.sendMessage({ seq: this.nextSeq(), type: 'response', command: request.command || '', request_seq: request.seq, success, message, body }) } protected sendEvent(event: string, body: Record = {}): void { this.sendMessage({ seq: this.nextSeq(), type: 'event', event, body }) } abstract handleRequest(request: DAPMessage): Promise abstract cleanup(): Promise } // ============================================================================ // Python Debug Session // ============================================================================ const DEFAULT_DEBUGPY_TIMEOUT_MS = 10_000 // `launch` waits on dependency preparation in the Python server, which allows `windmill // prepare-deps` up to 120s; anything shorter here reports a timeout while the install is // still legitimately running. const DEBUGPY_TIMEOUT_MS_BY_COMMAND: Record = { launch: 180_000 } class PythonDebugSession extends BaseDebugSession { private debugpyWs: WebSocket | null = null private debugpySeq = 1 private pendingDebugpyRequests = new Map< number, { resolve: (value: DAPMessage) => void; reject: (error: Error) => void } >() private callFrames: Array<{ id: number; name: string; line: number; column: number; source?: { path: string; name?: string } }> = [] private variablesRefCounter = 1 private scopesMap = new Map() private scriptResult: unknown = undefined private envVars: Record = {} private windmillPath?: string private venvPath?: string private prepareDepsProcess: Subprocess | null = null private disposed = false private debugMode: boolean constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) { super(ws) this.windmillPath = windmillPath this.debugMode = debugMode } private nextDebugpySeq(): number { return this.debugpySeq++ } private nextVarRef(): number { return this.variablesRefCounter++ } private async sendDebugpyRequest(command: string, args?: Record): Promise { if (!this.debugpyWs || this.debugpyWs.readyState !== WebSocket.OPEN) { throw new Error('Debugpy not connected') } const seq = this.nextDebugpySeq() const message: DAPMessage = { seq, type: 'request', command, arguments: args } const timeoutMs = DEBUGPY_TIMEOUT_MS_BY_COMMAND[command] ?? DEFAULT_DEBUGPY_TIMEOUT_MS return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingDebugpyRequests.delete(seq) reject(new Error(`Debugpy command timeout: ${command} (after ${timeoutMs}ms)`)) }, timeoutMs) this.pendingDebugpyRequests.set(seq, { resolve: (value) => { clearTimeout(timeout) resolve(value) }, reject: (error) => { clearTimeout(timeout) reject(error) } }) logger.debug('Sending to debugpy:', JSON.stringify(message)) this.debugpyWs!.send(JSON.stringify(message)) }) } private handleDebugpyMessage(data: string): void { try { const message: DAPMessage = JSON.parse(data) logger.debug('Debugpy message:', data.substring(0, 200)) if (message.type === 'response') { const pending = this.pendingDebugpyRequests.get(message.request_seq!) if (pending) { this.pendingDebugpyRequests.delete(message.request_seq!) if (message.success) { pending.resolve(message) } else { pending.reject(new Error(message.message || 'Request failed')) } } } else if (message.type === 'event') { this.handleDebugpyEvent(message) } } catch (error) { logger.error('Failed to parse debugpy message:', error) } } private handleDebugpyEvent(event: DAPMessage): void { const body = event.body || {} switch (event.event) { case 'initialized': this.sendEvent('initialized') break case 'stopped': this.sendEvent('stopped', { reason: body.reason || 'breakpoint', threadId: body.threadId || 1, allThreadsStopped: body.allThreadsStopped ?? true, line: body.line }) break case 'continued': this.sendEvent('continued', { threadId: body.threadId || 1 }) break case 'terminated': if (!this.terminatedSent) { this.terminatedSent = true // Include captured result in terminated event this.sendEvent('terminated', { ...body, result: this.scriptResult }) } break case 'output': { // Capture the result from __WINDMILL_RESULT__ output const output = body.output as string | undefined if (output && output.startsWith('__WINDMILL_RESULT__:')) { try { const resultJson = output.substring('__WINDMILL_RESULT__:'.length).trim() this.scriptResult = JSON.parse(resultJson) logger.info(`Python: Captured script result: ${resultJson}`) } catch (error) { logger.error('Failed to parse Python result:', error) } // Don't forward __WINDMILL_RESULT__ output to client break } this.sendEvent('output', body) break } case 'exited': if (!this.terminatedSent) { this.terminatedSent = true this.sendEvent('terminated', { result: this.scriptResult }) } break } } /** * Install the script's imports through `windmill prepare-deps` and return the venv to add to * the debugged script's sys.path. * * This runs here rather than in the Python server because the registry settings the CLI is * given routinely embed private-registry credentials, and the Python server executes the * submitted script inside its own interpreter: anything in that process is recoverable by the * script. The service never executes user code, so the credentials stop here. * * It still goes through spawnProcess so nsjail confines it on the same terms as the debuggee: * `uv pip install` builds source distributions, which executes their build backend's arbitrary * Python. Those same credentials are what `inheritEnv` is for — the jail config keeps the * environment across the boundary, so nothing else has to carry them in. */ private async prepareDependencies(code: string, registry: RegistryConfig): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null } const warn = (reason: string): null => { // cleanup() kills the installer, which ends the read with nothing to parse. Reporting // that as an install failure blames the user for their own disconnect, on a websocket // that is being torn down anyway. if (this.disposed) { return null } logger.error(`prepare-deps failed: ${reason}`) this.sendEvent('output', { category: 'stderr', output: `Failed to prepare dependencies: ${reason}\n` }) return null } try { const proc = spawnProcess({ cmd: [this.windmillPath, 'prepare-deps'], // The venv has to be built against the interpreter that will run the script: its // site-packages goes on that interpreter's sys.path, and uv otherwise picks its // own, which silently leaves compiled extensions unimportable. stdin: new Blob([ JSON.stringify({ code, language: 'python3', python_path: config.pythonPath, registry }) + '\n' ]), inheritEnv: true, detached: true }) this.prepareDepsProcess = proc // The launch response is already sent, so an install that never returns would leave the // client waiting on a session that never starts, with nothing on screen. The deadline // races the read rather than only killing the child: a grandchild holding the pipe open // keeps the read pending long after the child itself is gone. let timer: ReturnType | undefined // spawnProcess's return type does not carry the piped stdio through const read = (async () => ({ output: await new Response(proc.stdout as ReadableStream).text(), stderr: await new Response(proc.stderr as ReadableStream).text() }))() const result = await Promise.race([ read, new Promise((resolve) => { timer = setTimeout(() => resolve(null), PREPARE_DEPS_TIMEOUT_MS) }) ]) clearTimeout(timer) this.prepareDepsProcess = null if (!result) { killProcessTree(proc) return warn( `dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` ) } const { output, stderr } = result const lastLine = output.trim().split('\n').pop() || '' if (!lastLine.startsWith('{')) { return warn(stderr.trim() || 'windmill binary produced no response') } const response = JSON.parse(lastLine) if (!response.success) { // install_stderr is the installer's raw output; `error` already contains it, so // prefer whichever the CLI version at hand provides. return warn(response.install_stderr || response.error || 'unknown error') } if (response.venv_path) { logger.info(`Dependencies installed at: ${response.venv_path}`) } else { logger.info('No external dependencies to install') } return response.venv_path || null } catch (error) { return warn(String(error)) } } private async startPythonProcess(cwd: string): Promise { if (!this.scriptPath) { throw new Error('No script path') } // Use a wider port range (10000-60000) to avoid collisions with recently used ports const debugpyPort = 10000 + Math.floor(Math.random() * 50000) // Get the directory where this script is located to find dap_websocket_server.py const scriptDir = import.meta.dir // Spawn the Python WebSocket DAP server // Pass env vars to the server - it will forward them to the debugged script const cmd = [ config.pythonPath, '-u', join(scriptDir, 'dap_websocket_server.py'), '--port', String(debugpyPort), '--host', '127.0.0.1' ] // Dependencies are installed by the service (see prepareDependencies), so the server is // handed the resulting venv instead of the windmill binary it would install with. if (this.venvPath) { cmd.push('--venv-path', this.venvPath) logger.info(`Python session: using dependencies at ${this.venvPath}`) } // Pass debug flag to Python subprocess if (this.debugMode) { cmd.push('--debug') } this.process = spawnProcess({ cmd, cwd, env: { PYTHONUNBUFFERED: '1', ...sessionEnv(), ...this.envVars } }) // Read stderr to capture startup messages this.readPythonStderr(this.process.stderr) // Wait for the Python server to be ready (check via health endpoint or just wait) await this.waitForPythonServer(debugpyPort) // Connect to the Python WebSocket server await this.connectToDebugpy(`ws://127.0.0.1:${debugpyPort}`) this.process.exited.then(async (exitCode) => { logger.info(`Python process exited with code: ${exitCode}`) this.running = false if (!this.terminatedSent) { this.terminatedSent = true this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) } await this.cleanup() }) } private async readPythonStderr(stream: ReadableStream | null): Promise { if (!stream) return const reader = stream.getReader() const decoder = new TextDecoder() try { while (true) { const { done, value } = await reader.read() if (done) break const text = decoder.decode(value) // Forward stderr output to the client (Python logs go to stderr) if (text.trim()) { // Log Python output at info level so we can see it for (const line of text.split('\n')) { if (line.trim()) { logger.info(`[Python] ${line}`) } } } } } catch (error) { logger.debug('Python stderr stream ended:', error) } } private async waitForPythonServer(port: number, maxAttempts = 30): Promise { // Wait a bit for Python to start - it takes at least 50-100ms to initialize await new Promise(resolve => setTimeout(resolve, 100)) for (let i = 0; i < maxAttempts; i++) { try { // Try to connect to see if server is up const testWs = new WebSocket(`ws://127.0.0.1:${port}`) await new Promise((resolve, reject) => { const timeout = setTimeout(() => { try { testWs.close() } catch {} reject(new Error('Connection timeout')) }, 500) testWs.onopen = () => { clearTimeout(timeout) // Wait a moment before closing to ensure the connection is stable setTimeout(() => { try { testWs.close() } catch {} resolve() }, 50) } testWs.onerror = () => { clearTimeout(timeout) try { testWs.close() } catch {} reject(new Error('Connection failed')) } }) // Small delay to let the test connection fully close await new Promise(resolve => setTimeout(resolve, 50)) logger.info(`Python server ready on port ${port}`) return } catch { await new Promise(resolve => setTimeout(resolve, 100)) } } throw new Error('Python server did not start in time') } private async connectToDebugpy(wsUrl: string): Promise { logger.info(`Connecting to debugpy at ${wsUrl}`) return new Promise((resolve, reject) => { this.debugpyWs = new WebSocket(wsUrl) const timeout = setTimeout(() => { reject(new Error('Debugpy connection timeout')) }, 5000) this.debugpyWs.onopen = async () => { clearTimeout(timeout) logger.info('Connected to debugpy') try { await this.sendDebugpyRequest('initialize', { clientID: 'windmill', clientName: 'Windmill Debug Service', adapterID: 'python', pathFormat: 'path', linesStartAt1: true, columnsStartAt1: true }) this.running = true resolve() } catch (error) { reject(error) } } this.debugpyWs.onmessage = (event) => { this.handleDebugpyMessage(event.data as string) } this.debugpyWs.onerror = (error) => { logger.error('Debugpy WebSocket error:', error) } this.debugpyWs.onclose = () => { logger.info('Debugpy WebSocket closed') this.debugpyWs = null // A Python server that dies mid-request must fail it now; otherwise the caller // waits out the command timeout, which for `launch` is minutes. const aborted = Array.from(this.pendingDebugpyRequests.values()) this.pendingDebugpyRequests.clear() for (const pending of aborted) { pending.reject(new Error('Debugpy connection closed')) } } }) } async handleRequest(request: DAPMessage): Promise { const command = request.command || '' switch (command) { case 'initialize': await this.handleInitialize(request) break case 'setBreakpoints': await this.handleSetBreakpoints(request) break case 'configurationDone': this.configured = true if (this.debugpyWs) { await this.sendDebugpyRequest('configurationDone') } this.sendResponse(request) break case 'launch': await this.handleLaunch(request) break case 'threads': await this.forwardToDebugpy(request) break case 'stackTrace': await this.forwardToDebugpy(request) break case 'scopes': await this.forwardToDebugpy(request) break case 'variables': await this.forwardToDebugpy(request) break case 'evaluate': await this.forwardToDebugpy(request) break case 'continue': await this.forwardToDebugpy(request) break case 'next': await this.forwardToDebugpy(request) break case 'stepIn': await this.forwardToDebugpy(request) break case 'stepOut': await this.forwardToDebugpy(request) break case 'pause': await this.forwardToDebugpy(request) break case 'disconnect': case 'terminate': await this.handleTerminate(request) break default: this.sendResponse(request, false, {}, `Unsupported command: ${command}`) } } private async forwardToDebugpy(request: DAPMessage): Promise { try { const response = await this.sendDebugpyRequest(request.command!, request.arguments) this.sendResponse(request, response.success, response.body, response.message) } catch (error) { this.sendResponse(request, false, {}, String(error)) } } private async handleInitialize(request: DAPMessage): Promise { this.sendResponse(request, true, { supportsConfigurationDoneRequest: true, supportsEvaluateForHovers: true, supportTerminateDebuggee: true, supportsTerminateRequest: true }) this.initialized = true } private async handleSetBreakpoints(request: DAPMessage): Promise { const args = request.arguments || {} const source = args.source as { path?: string } | undefined const sourcePath = source?.path || '' const breakpointsData = (args.breakpoints as Array<{ line: number }>) || [] const lineNumbers = breakpointsData.map(bp => bp.line) this.breakpoints.set(sourcePath, lineNumbers) if (this.debugpyWs) { try { const response = await this.sendDebugpyRequest('setBreakpoints', args) this.sendResponse(request, response.success, response.body) } catch (error) { this.sendResponse(request, false, {}, String(error)) } } else { // Debugpy not connected yet, respond with unverified breakpoints const breakpoints = lineNumbers.map((line, i) => ({ id: i + 1, verified: false, line, source: { path: sourcePath } })) this.sendResponse(request, true, { breakpoints }) } } private async handleLaunch(request: DAPMessage): Promise { // Per launch, not per session: cleanup() also runs when a program finishes normally, and // the flag must only mean "torn down while this launch was still preparing". this.disposed = false const args = request.arguments || {} let code = args.code as string | undefined this.scriptPath = args.program as string | undefined let cwd = (args.cwd as string) || process.cwd() this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} // Also what authorizes the registry configuration fetch below. const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). if (REQUIRE_SIGNED_REQUESTS) { // The backend only signs inline `code`; a `program`-mode launch names an // arbitrary server-side file path that gets read and executed and is never // signed. Refuse it so it cannot bypass token verification entirely. if (this.scriptPath) { logger.error('Rejected program-mode launch: only signed inline code is permitted') this.sendResponse(request, false, {}, 'program-mode launch is not permitted; submit signed code instead') return } if (!token) { logger.error('No debug token provided but signed requests are required') this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.') return } const verificationError = await verifyDebugToken(token, code ?? '') if (verificationError) { logger.error(`Token verification failed: ${verificationError}`) this.sendResponse(request, false, {}, `Token verification failed: ${verificationError}`) return } } // If BASE_INTERNAL_URL is set on the server, use it to override WM_BASE_URL if (process.env.BASE_INTERNAL_URL) { this.envVars.WM_BASE_URL = process.env.BASE_INTERNAL_URL } if (Object.keys(this.envVars).length > 0) { logger.info(`Python launch with env vars: ${Object.keys(this.envVars).join(', ')}`) } if (!this.scriptPath && !code) { this.sendResponse(request, false, {}, 'No program or code specified') return } // If callMain is true, wrap the code to call main() with args if (this.callMain && code) { const argsJson = JSON.stringify(this.mainArgs) code += ` # Auto-generated call to main entrypoint import json import sys _args = json.loads('${argsJson.replace(/'/g, "\\'")}') _result = main(**_args) print("__WINDMILL_RESULT__:" + json.dumps(_result)) sys.stdout.flush() ` } if (code && !this.scriptPath) { try { this.tempDir = await mkdtemp(join(tmpdir(), 'windmill_debug_')) this.tempFile = join(this.tempDir, 'script.py') await writeFile(this.tempFile, code) this.scriptPath = this.tempFile // Use temp directory as cwd so debugger can find the script cwd = this.tempDir logger.info(`Wrote Python code to ${this.tempFile}, cwd=${cwd}`) } catch (error) { this.sendResponse(request, false, {}, `Failed to create temp file: ${error}`) return } } this.sendResponse(request) try { if (code) { const registry = await fetchRegistryConfig(token, logger) // A round trip of its own, during which the client can give up: the installer runs // a source distribution's build backend, so starting one for a session that is // already gone executes package code nobody is waiting for. if (this.disposed) { logger.info('Session torn down during the registry configuration fetch, not installing') await this.cleanup() return } if (registry.message) { this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) } this.venvPath = (await this.prepareDependencies(code, registry)) ?? undefined } // Installing takes long enough for the client to give up meanwhile, and cleanup() has // then already run: starting the debuggee now would leave a process nothing owns // executing the script for a session that is gone. Clean up again on the way out, // since a teardown that landed before the script was written left it behind. if (this.disposed) { logger.info('Session torn down during dependency preparation, not starting Python') await this.cleanup() return } await this.startPythonProcess(cwd) // Re-apply breakpoints to the Python server using the actual script path for (const [, lines] of this.breakpoints) { await this.sendDebugpyRequest('setBreakpoints', { source: { path: this.scriptPath }, breakpoints: lines.map(line => ({ line })) }) } // Signal configuration done await this.sendDebugpyRequest('configurationDone') // Now forward the launch command to the Python server // The Python server needs to know what code/program to debug await this.sendDebugpyRequest('launch', { program: this.scriptPath, code: code, args: this.mainArgs, cwd: cwd, callMain: this.callMain, env: this.envVars }) } catch (error) { this.sendEvent('output', { category: 'stderr', output: `Failed to start Python: ${error}\n` }) // Claim the terminated event before cleanup kills the process, otherwise the // `exited` handler sends a second one whose empty body erases this error. this.terminatedSent = true this.sendEvent('terminated', { error: String(error) }) // A Python server that refused the launch stays in its connection loop, so // nothing else ever reaps it, its websocket or the temp dir. await this.cleanup() } } private async handleTerminate(request: DAPMessage): Promise { this.running = false const shouldSendTerminated = !this.terminatedSent this.terminatedSent = true if (this.debugpyWs) { try { await this.sendDebugpyRequest('terminate') } catch {} } await this.cleanup() this.sendResponse(request) if (shouldSendTerminated) { this.sendEvent('terminated', this.scriptResult !== undefined ? { result: this.scriptResult } : {}) } } async cleanup(): Promise { this.disposed = true // A client that gives up mid-install must not leave the package manager running if (this.prepareDepsProcess) { killProcessTree(this.prepareDepsProcess) this.prepareDepsProcess = null } if (this.debugpyWs) { this.debugpyWs.close() this.debugpyWs = null } if (this.process) { this.process.kill() this.process = null } if (this.tempFile) { try { await unlink(this.tempFile) } catch {} this.tempFile = null } if (this.tempDir) { try { await rmdir(this.tempDir) } catch {} this.tempDir = null } } } // ============================================================================ // Main Server // ============================================================================ const sessions = new Map() logger.info(`Starting DAP Debug Service on ${config.host}:${config.port}`) logger.info(`Endpoints: /python, /typescript, /bun`) if (config.nsjail.enabled) { logger.info(`nsjail enabled: ${config.nsjail.binaryPath}`) if (config.nsjail.configPath) { logger.info(`nsjail config: ${config.nsjail.configPath}`) } } if (config.windmillPath) { logger.info(`Windmill binary: ${config.windmillPath} (autoinstall enabled)`) } else { logger.info('Windmill binary: not configured (autoinstall disabled)') } if (config.debug) { logger.info('Debug logging: enabled') } const server = Bun.serve({ hostname: config.host, port: config.port, fetch(req, server) { const url = new URL(req.url) const path = url.pathname if (isOriginRejected(req)) { logger.warn(`Rejected request from disallowed origin: ${req.headers.get('origin')}`) return new Response('Forbidden origin', { status: 403 }) } // Handle WebSocket upgrade with path-based routing if (server.upgrade(req, { data: { path } })) { logger.info(`WS upgrade: ${path}`) return undefined as unknown as Response } logger.info(`HTTP ${req.method} ${path}`) // Health check endpoint if (path === '/health' || path === '/ws_debug/health') { return new Response(JSON.stringify({ status: 'ok', service: 'debugger', endpoints: ['/python', '/typescript', '/bun'], nsjail: config.nsjail.enabled }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }) } return new Response('DAP Debug Service\n\nEndpoints:\n /python - Python debugging\n /typescript - TypeScript/Bun debugging\n /bun - TypeScript/Bun debugging\n /health - Health check', { status: 200 }) }, websocket: { open(ws) { let path = (ws.data as { path: string }).path // Trim /ws_debug prefix if present (for direct access without reverse proxy stripping) if (path.startsWith('/ws_debug/')) { path = path.slice('/ws_debug'.length) } // Handle ping test — respond and close immediately if (path === '/ping') { logger.info(`WS ping test`) ws.send(JSON.stringify({ type: 'pong', service: 'debugger' })) ws.close() return } logger.info(`New client connected: ${path}`) // Create appropriate session based on path const wsWrapper = { send: (data: string) => ws.send(data), close: () => ws.close() } let session: BaseDebugSession // Build nsjail config to pass to debuggers const nsjailConfig: NsjailConfig | undefined = config.nsjail.enabled ? { enabled: true, binaryPath: config.nsjail.binaryPath, configPath: config.nsjail.configPath, extraArgs: config.nsjail.extraArgs } : undefined if (path === '/python') { session = new PythonDebugSession(wsWrapper, config.windmillPath, config.debug) } else if (path === '/typescript' || path === '/bun' || path === '/') { // Use the working Bun debug session from dap_websocket_server_bun.ts session = new BunDebugSessionWorking(wsWrapper as unknown as WebSocket, { nsjailConfig, bunPath: config.bunPath, windmillPath: config.windmillPath }) as unknown as BaseDebugSession } else { logger.warn(`Unknown path: ${path}, defaulting to TypeScript`) session = new BunDebugSessionWorking(wsWrapper as unknown as WebSocket, { nsjailConfig, bunPath: config.bunPath, windmillPath: config.windmillPath }) as unknown as BaseDebugSession } sessions.set(ws, session) }, async message(ws, message) { const session = sessions.get(ws) if (!session) return try { const data = JSON.parse(message as string) as DAPMessage logger.debug('Received:', JSON.stringify(data).substring(0, 200)) if (data.type === 'request') { await session.handleRequest(data) } } catch (error) { logger.error('Error handling message:', error) } }, async close(ws) { logger.info('Client disconnected') const session = sessions.get(ws) if (session) { await session.cleanup() sessions.delete(ws) } } } }) logger.info(`Server started on ${server.url}`) // Handle graceful shutdown process.on('SIGTERM', async () => { logger.info('Received SIGTERM, shutting down...') for (const session of sessions.values()) { await session.cleanup() } process.exit(0) }) process.on('SIGINT', async () => { logger.info('Received SIGINT, shutting down...') for (const session of sessions.values()) { await session.cleanup() } process.exit(0) })