- Fix debugger HTTP health endpoint to also match /ws_debug/health (ingress forwards the full path, not just /health) - Add request logging to all three extra services (LSP, multiplayer, debugger) for HTTP and WebSocket ping/upgrade events Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1198 lines
33 KiB
TypeScript
1198 lines
33 KiB
TypeScript
#!/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, type NsjailConfig } from './dap_websocket_server_bun'
|
|
|
|
// ============================================================================
|
|
// 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'
|
|
|
|
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<CryptoKey | null> | null = null
|
|
|
|
/**
|
|
* Fetch and cache the Ed25519 public key from the JWKS endpoint.
|
|
*/
|
|
async function getPublicKey(): Promise<CryptoKey | null> {
|
|
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<string> {
|
|
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<string | null> {
|
|
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<string, string>
|
|
stdout?: 'pipe' | 'inherit'
|
|
stderr?: 'pipe' | 'inherit'
|
|
}
|
|
|
|
/**
|
|
* Spawn a process, optionally wrapped with nsjail.
|
|
* This is the key function for sandboxed execution.
|
|
*/
|
|
function spawnProcess(options: SpawnOptions): Subprocess {
|
|
let cmd = options.cmd
|
|
|
|
if (config.nsjail.enabled) {
|
|
// Build nsjail command
|
|
const nsjailCmd = [config.nsjail.binaryPath]
|
|
|
|
// Add config file if specified
|
|
if (config.nsjail.configPath) {
|
|
nsjailCmd.push('--config', config.nsjail.configPath)
|
|
}
|
|
|
|
// Add any extra nsjail arguments
|
|
nsjailCmd.push(...config.nsjail.extraArgs)
|
|
|
|
// Add working directory if specified
|
|
if (options.cwd) {
|
|
nsjailCmd.push('--cwd', options.cwd)
|
|
}
|
|
|
|
// Separator and actual command
|
|
nsjailCmd.push('--')
|
|
nsjailCmd.push(...cmd)
|
|
|
|
cmd = nsjailCmd
|
|
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(),
|
|
stdout: options.stdout || 'pipe',
|
|
stderr: options.stderr || 'pipe',
|
|
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<string, unknown>
|
|
arguments?: Record<string, unknown>
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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<string, number[]>()
|
|
protected callMain = false
|
|
protected mainArgs: Record<string, unknown> = {}
|
|
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<string, unknown> = {},
|
|
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<string, unknown> = {}): void {
|
|
this.sendMessage({
|
|
seq: this.nextSeq(),
|
|
type: 'event',
|
|
event,
|
|
body
|
|
})
|
|
}
|
|
|
|
abstract handleRequest(request: DAPMessage): Promise<void>
|
|
abstract cleanup(): Promise<void>
|
|
}
|
|
|
|
// ============================================================================
|
|
// Python Debug Session
|
|
// ============================================================================
|
|
|
|
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<number, { variablesReference: number }>()
|
|
private scriptResult: unknown = undefined
|
|
private envVars: Record<string, string> = {}
|
|
private windmillPath?: string
|
|
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<string, unknown>): Promise<DAPMessage> {
|
|
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
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
this.pendingDebugpyRequests.delete(seq)
|
|
reject(new Error(`Debugpy command timeout: ${command}`))
|
|
}, 10000)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
private async startPythonProcess(cwd: string): Promise<void> {
|
|
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'
|
|
]
|
|
|
|
// Pass windmill path for dependency auto-installation if configured
|
|
if (this.windmillPath) {
|
|
cmd.push('--windmill', this.windmillPath)
|
|
logger.info(`Python session: autoinstall enabled with windmill at ${this.windmillPath}`)
|
|
}
|
|
|
|
// Pass debug flag to Python subprocess
|
|
if (this.debugMode) {
|
|
cmd.push('--debug')
|
|
}
|
|
|
|
this.process = spawnProcess({
|
|
cmd,
|
|
cwd,
|
|
env: { PYTHONUNBUFFERED: '1', ...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<Uint8Array> | null): Promise<void> {
|
|
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<void> {
|
|
// 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<void>((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<void> {
|
|
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
|
|
}
|
|
})
|
|
}
|
|
|
|
async handleRequest(request: DAPMessage): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
this.sendResponse(request, true, {
|
|
supportsConfigurationDoneRequest: true,
|
|
supportsEvaluateForHovers: true,
|
|
supportTerminateDebuggee: true,
|
|
supportsTerminateRequest: true
|
|
})
|
|
this.initialized = true
|
|
}
|
|
|
|
private async handleSetBreakpoints(request: DAPMessage): Promise<void> {
|
|
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<void> {
|
|
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<string, unknown>) || {}
|
|
this.envVars = (args.env as Record<string, string>) || {}
|
|
|
|
// Verify JWT token if code is provided (signed debug request)
|
|
// The token is passed in the launch arguments
|
|
if (code && REQUIRE_SIGNED_REQUESTS) {
|
|
const token = args.token as string | undefined
|
|
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 {
|
|
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` })
|
|
this.sendEvent('terminated', { error: String(error) })
|
|
}
|
|
}
|
|
|
|
private async handleTerminate(request: DAPMessage): Promise<void> {
|
|
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<void> {
|
|
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<unknown, BaseDebugSession>()
|
|
|
|
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
|
|
|
|
// 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)
|
|
})
|