diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 70b5718cf7..2e16e565c2 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -12,7 +12,11 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::process::Command; -use crate::{BUN_CACHE_DIR, BUN_PATH, HOME_ENV, PATH_ENV, PROXY_ENVS, UV_CACHE_DIR}; +use crate::worker::non_empty_env; +use crate::{ + BUN_CACHE_DIR, BUN_PATH, HOME_ENV, INDEX_CERT, NATIVE_CERT, PATH_ENV, PROXY_ENVS, TRUSTED_HOST, + UV_CACHE_DIR, UV_HTTP_TIMEOUT, +}; use windmill_common::worker::write_file; const LOADER_BUILDER_CONTENT: &str = include_str!("../loader_builder.bun.js"); @@ -87,6 +91,15 @@ lazy_static::lazy_static! { /// UV binary path static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string()); + + /// This process has no database, so the `pip_index_url` / `pip_extra_index_url` instance + /// settings the job path resolves are unreachable here: their env-var equivalents are the + /// only registry configuration the debugger can see. + static ref PY_INDEX_URL: Option = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL")); + static ref PY_EXTRA_INDEX_URL: Option = non_empty_env("PY_EXTRA_INDEX_URL").or_else(|| non_empty_env("PIP_EXTRA_INDEX_URL")); + /// uv defaults to `first-index`; the job path overrides it so a package missing from the + /// first index is still resolved from the others. Same default here. + static ref PY_INDEX_STRATEGY: String = non_empty_env("UV_INDEX_STRATEGY").unwrap_or_else(|| "unsafe-best-match".to_string()); } /// Simple loader that doesn't require Windmill API for relative imports @@ -114,6 +127,11 @@ pub struct PrepareResponse { pub job_dir: String, pub success: bool, pub error: Option, + /// Raw stderr of the dependency installer when it exited non-zero, so a caller can show the + /// registry/TLS failure verbatim instead of the bare ModuleNotFoundError that follows. + /// Omitted from the JSON when absent, so callers that only know `success`/`error` are unaffected. + #[serde(skip_serializing_if = "Option::is_none")] + pub install_stderr: Option, } /// Parse Python imports and return a list of package names that need to be installed. @@ -160,6 +178,28 @@ fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap { envs } +/// uv registry arguments, mirroring what the job path passes in `python_executor`. +fn uv_registry_args() -> Vec { + let mut args: Vec = vec![]; + if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() { + for url in urls.split(',') { + args.extend(["--extra-index-url".to_string(), url.to_string()]); + } + } + if let Some(url) = PY_INDEX_URL.as_ref() { + args.extend(["--index-url".to_string(), url.to_string()]); + } + if let Some(hosts) = TRUSTED_HOST.as_ref() { + for host in hosts.split_whitespace() { + args.extend(["--trusted-host".to_string(), host.to_string()]); + } + } + if *NATIVE_CERT { + args.push("--native-tls".to_string()); + } + args +} + /// Prepare Python dependencies using uv async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { // Parse imports from the code @@ -172,6 +212,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: String::new(), success: true, error: None, + install_stderr: None, }; } @@ -189,17 +230,37 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to create job directory: {}", e)), + install_stderr: None, }; } - let common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR))); + let mut common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR))); + common_uv_envs.insert( + "UV_INDEX_STRATEGY".to_string(), + PY_INDEX_STRATEGY.to_string(), + ); + if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() { + common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string()); + } + if let Some(cert_path) = INDEX_CERT.as_ref() { + // uv has no `--cert` on `venv`/`pip install` (astral-sh/uv#6715), so a custom CA bundle + // reaches it through SSL_CERT_FILE, as in the job path. + common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path.to_string()); + } + + let registry_args = uv_registry_args(); // Step 1: Create virtual environment using uv + // `--seed` resolves pip/setuptools from the index, so the venv also needs the registry + // arguments: on a network that only reaches a private mirror it fails without them. + let mut venv_args = vec!["venv".to_string(), venv_dir.clone(), "--seed".to_string()]; + venv_args.extend(registry_args.iter().cloned()); + let output = Command::new(UV_PATH.as_str()) .current_dir(&job_dir) .env_clear() .envs(common_uv_envs.clone()) - .args(["venv", &venv_dir, "--seed"]) + .args(&venv_args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() @@ -212,26 +273,33 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to create venv: {}", e)), + install_stderr: None, }; } let out = output.unwrap(); if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); return PrepareResponse { node_modules_path: None, venv_path: None, job_dir: job_dir.clone(), success: false, error: Some(format!("uv venv failed: {}", stderr)), + install_stderr: Some(stderr), }; } // Step 2: Install packages using uv pip install let python_path = format!("{}/bin/python", venv_dir); - let mut args = vec!["pip", "install", "--python", &python_path]; - let package_refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect(); - args.extend(package_refs.iter()); + let mut args = vec![ + "pip".to_string(), + "install".to_string(), + "--python".to_string(), + python_path, + ]; + args.extend(packages.iter().cloned()); + args.extend(registry_args); let output = Command::new(UV_PATH.as_str()) .current_dir(&job_dir) @@ -246,11 +314,19 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { match output { Ok(out) => { if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - // Installation might fail for some packages (e.g., wrong package name) - // Log the error but continue - the script might still work if the - // package is actually installed elsewhere or the import is optional - tracing::warn!("uv pip install warning: {}", stderr); + // uv installs the whole set atomically, so a failure here means an empty venv: + // returning success would leave the caller with a bare ModuleNotFoundError and + // no way to see the registry/TLS/package-name error that caused it. + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + tracing::warn!("uv pip install failed: {}", stderr); + return PrepareResponse { + node_modules_path: None, + venv_path: None, + job_dir: job_dir.clone(), + success: false, + error: Some(format!("uv pip install failed: {}", stderr)), + install_stderr: Some(stderr), + }; } } Err(e) => { @@ -260,6 +336,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to run uv pip install: {}", e)), + install_stderr: None, }; } } @@ -292,6 +369,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { job_dir, success: true, error: None, + install_stderr: None, } } @@ -321,6 +399,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo "Unsupported language for dependency preparation: {}", language )), + install_stderr: None, }; } } @@ -336,6 +415,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to create job directory: {}", e)), + install_stderr: None, }; } @@ -347,6 +427,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to write main.ts: {}", e)), + install_stderr: None, }; } @@ -366,6 +447,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to write build.js: {}", e)), + install_stderr: None, }; } @@ -398,6 +480,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to write empty package.json: {}", e)), + install_stderr: None, }; } } @@ -411,6 +494,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to run build.js: {}", e)), + install_stderr: None, }; } } @@ -427,6 +511,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: true, error: None, + install_stderr: None, }; } }; @@ -441,6 +526,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to parse package.json: {}", e)), + install_stderr: None, }; } }; @@ -454,6 +540,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: true, error: None, + install_stderr: None, }; } @@ -471,13 +558,14 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo match output { Ok(out) => { if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); return PrepareResponse { node_modules_path: None, venv_path: None, job_dir: job_dir.clone(), success: false, error: Some(format!("bun install failed: {}", stderr)), + install_stderr: Some(stderr), }; } } @@ -488,6 +576,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir: job_dir.clone(), success: false, error: Some(format!("Failed to run bun install: {}", e)), + install_stderr: None, }; } } @@ -500,6 +589,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir, success: true, error: None, + install_stderr: None, } } else { PrepareResponse { @@ -508,6 +598,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo job_dir, success: true, error: None, + install_stderr: None, } } } @@ -531,6 +622,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { job_dir: String::new(), success: false, error: Some(format!("Failed to read stdin: {}", e)), + install_stderr: None, }; println!("{}", serde_json::to_string(&response)?); return Ok(()); @@ -550,6 +642,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { "Failed to parse JSON input: {}. Expected {{\"code\": \"...\", \"language\": \"bun\" or \"python3\"}}", e )), + install_stderr: None, }; println!("{}", serde_json::to_string(&response)?); return Ok(()); @@ -561,3 +654,43 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::PrepareResponse; + + /// The debugger (`debugger/dap_websocket_server.py`) parses this JSON out of the CLI's + /// stdout, so `install_stderr` has to stay additive: a response without an install failure + /// must serialize to the shape callers already know. + #[test] + fn test_install_stderr_is_additive() { + let ok = PrepareResponse { + node_modules_path: None, + venv_path: Some("/tmp/windmill-deps/x/venv".to_string()), + job_dir: "/tmp/windmill-deps/x".to_string(), + success: true, + error: None, + install_stderr: None, + }; + assert_eq!( + serde_json::to_value(&ok).unwrap(), + serde_json::json!({ + "node_modules_path": null, + "venv_path": "/tmp/windmill-deps/x/venv", + "job_dir": "/tmp/windmill-deps/x", + "success": true, + "error": null, + }) + ); + + let failed = PrepareResponse { + install_stderr: Some("error: no such package".to_string()), + success: false, + error: Some("uv pip install failed: error: no such package".to_string()), + ..ok + }; + let failed = serde_json::to_value(&failed).unwrap(); + assert_eq!(failed["install_stderr"], "error: no such package"); + assert_eq!(failed["success"], false); + } +} diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 397367742a..883b035a32 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -62,20 +62,8 @@ lazy_static::lazy_static! { static ref PY_CONCURRENT_DOWNLOADS: usize = var("PY_CONCURRENT_DOWNLOADS").ok().map(|flag| flag.parse().unwrap_or(20)).unwrap_or(20); - // uv's HTTP request timeout (seconds). spawn_uv_install uses env_clear(), so a - // UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly. - // Only forwarded when set; otherwise uv keeps its own default. Lets operators - // raise it for slow/contended private registries ("operation timed out"). - static ref UV_HTTP_TIMEOUT: Option = - var("UV_HTTP_TIMEOUT").ok().filter(|v| !v.is_empty()); - - static ref NON_ALPHANUM_CHAR: Regex = regex::Regex::new(r"[^0-9A-Za-z=.-]").unwrap(); - static ref TRUSTED_HOST: Option = var("PY_TRUSTED_HOST").ok().or(var("PIP_TRUSTED_HOST").ok()); - pub static ref INDEX_CERT: Option = var("PY_INDEX_CERT").ok().or(var("PIP_INDEX_CERT").ok()); - pub static ref NATIVE_CERT: bool = var("PY_NATIVE_CERT").ok().or(var("UV_NATIVE_TLS").ok()).map(|flag| flag == "true").unwrap_or(false); - static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(((u|f)\.)|\.)"#).unwrap(); static ref EPHEMERAL_TOKEN_CMD: Option = var("EPHEMERAL_TOKEN_CMD").ok(); @@ -163,9 +151,10 @@ use crate::{ handle_child::handle_child, is_sandboxing_enabled, read_ee_registry_with_workspace_override, worker_utils::ping_job_status, - PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, - PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, - TZ_ENV, UV_CACHE_DIR, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, + PyV, DISABLE_NUSER, HOME_ENV, INDEX_CERT, NATIVE_CERT, NSJAIL_AVAILABLE, NSJAIL_PATH, + NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, + PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TRUSTED_HOST, TZ_ENV, UV_CACHE_DIR, + UV_EXCLUDE_NEWER, UV_HTTP_TIMEOUT, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, }; use windmill_common::client::AuthedClient; diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs index 3a1f35a302..11f673e8f4 100644 --- a/backend/windmill-worker/src/python_versions.rs +++ b/backend/windmill-worker/src/python_versions.rs @@ -23,9 +23,9 @@ use crate::python_executor::UV_PATH; use crate::{ common::{start_child_process, OccupancyMetrics}, handle_child::handle_child, - python_executor::{INDEX_CERT, NATIVE_CERT, PYTHON_PATH}, - HOME_ENV, INSTANCE_PYTHON_VERSION, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, UV_CACHE_DIR, - UV_PYTHON_INSTALL_MIRROR, WIN_ENVS, + python_executor::PYTHON_PATH, + HOME_ENV, INDEX_CERT, INSTANCE_PYTHON_VERSION, NATIVE_CERT, PATH_ENV, PROXY_ENVS, + PY_INSTALL_DIR, UV_CACHE_DIR, UV_PYTHON_INSTALL_MIRROR, WIN_ENVS, }; impl From for PyVAlias { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 08bb085059..10e436d326 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -704,6 +704,26 @@ lazy_static::lazy_static! { pub static ref FLOW_RUNNER_RUNNING: Mutex = Mutex::new(false); } +lazy_static::lazy_static! { + /// Registry TLS/timeout settings for uv. Env-only (they have no instance setting), and read + /// both by the job path and by the DB-less `prepare-deps` CLI, which has no other source of + /// registry configuration. + pub static ref TRUSTED_HOST: Option = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST")); + pub static ref INDEX_CERT: Option = non_empty_env("PY_INDEX_CERT").or_else(|| non_empty_env("PIP_INDEX_CERT")); + pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false); + /// uv's HTTP request timeout (seconds). The uv invocations use env_clear(), so a + /// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly. + /// Only forwarded when set; otherwise uv keeps its own default. Lets operators + /// raise it for slow/contended private registries ("operation timed out"). + pub static ref UV_HTTP_TIMEOUT: Option = non_empty_env("UV_HTTP_TIMEOUT"); +} + +/// A variable declared but left empty (a common shape in compose/k8s manifests) must not +/// shadow the fallback name it is checked against. +pub(crate) fn non_empty_env(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + lazy_static::lazy_static! { /// Optional override for the size of the `/tmp` tmpfs mount in nsjail sandboxes (in megabytes). /// When `None` (or non-positive), executors fall back to the unified diff --git a/debugger/README.md b/debugger/README.md index dbeb213351..51c2838f92 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -75,6 +75,48 @@ Options: | `DAP_NSJAIL_PATH` | nsjail binary path | nsjail | | `DAP_NSJAIL_CONFIG` | nsjail config file path | - | +### Python dependency preparation + +Before debugging a Python script, its imports are installed through `windmill prepare-deps`, which +runs `uv` without a database connection. It cannot read the instance settings, so it takes its +registry configuration from the environment of the debug service instead, and the Python server is +handed the resulting venv with `--venv-path`. The install runs in the service rather than in the +session because a private index URL usually embeds credentials and the Python server executes the +debugged script inside its own interpreter, where anything it holds is readable by that script. + +Set these on the debug service. Where two names are listed the first wins; a worker reads the +`PIP_*` / `PY_*` names in the same way, except for the index URLs, whose worker env fallbacks are +only `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` (the `PY_*` spellings are accepted here for symmetry +with the other settings): + +| Variable | Description | Default | +|----------|-------------|---------| +| `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI | +| `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - | +| `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | +| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE` | - | +| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | +| `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match | +| `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | +| `DAP_PREPARE_DEPS_TIMEOUT_MS` | How long to wait for the install before starting the session without it | 120000 | + +When the install fails, the CLI answers `success: false` and carries the installer's stderr in both +`error` and `install_stderr`; the service reports it to the client as an `output` event, so the +reason (unreachable mirror, untrusted certificate, unknown package) reaches the user instead of a +bare `ModuleNotFoundError` at the first import. + +Proxy variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, in either case) are forwarded from the +service into each session, since the debugged script needs them for its own outbound calls, exactly +as a job's script does on a worker. When a proxy is set without a bypass list, `NO_PROXY` defaults +to `localhost,127.0.0.1` so calls to `BASE_INTERNAL_URL` are not proxied. + +Keeping the settings out of the session's environment only bounds what the debugged script can read +from itself. An unsandboxed session runs under the same user as the service and can still read the +service's environment through `/proc`, the same way a job can read a worker's when the worker runs +unsandboxed. Isolating sessions from the service takes `--nsjail --nsjail-config +nsjail.debug.config.proto`: it is that config's PID namespace and `mount_proc` that put the service +out of reach, not the flag on its own. + ### Frontend Integration ```svelte diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 2b9ce46272..52490cdc9a 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -352,6 +352,45 @@ interface SpawnOptions { stderr?: 'pipe' | 'inherit' } +/** + * Proxy settings forwarded to a debug session, matching what a worker gives a job's script. + * spawnProcess intentionally does not inherit this process's environment, so an outbound proxy + * is unreachable from a session unless these are passed explicitly. Registry settings are + * deliberately absent: they carry credentials and are consumed by the service itself (see + * PythonDebugSession.prepareDependencies). + */ +const SESSION_PROXY_ENV_VARS = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + // The lowercase spellings take precedence in the worker, so forward both. + 'http_proxy', + 'https_proxy', + 'no_proxy' +] + +/** + * 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 + +function sessionProxyEnv(): Record { + const env: Record = {} + for (const key of SESSION_PROXY_ENV_VARS) { + const value = process.env[key] + if (value) { + env[key] = value + } + } + // A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it; + // the worker defaults the same way (PROXY_ENVS in windmill-worker). + if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) { + env.NO_PROXY = 'localhost,127.0.0.1' + } + return env +} + /** * Spawn a process, optionally wrapped with nsjail. * This is the key function for sandboxed execution. @@ -502,6 +541,7 @@ class PythonDebugSession extends BaseDebugSession { private scriptResult: unknown = undefined private envVars: Record = {} private windmillPath?: string + private venvPath?: string private debugMode: boolean constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) { @@ -627,6 +667,89 @@ class PythonDebugSession extends BaseDebugSession { } } + /** + * 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 reads + * (`PY_INDEX_URL` and friends) 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. + * + * The trade-off is that the install itself is not jailed, so a source distribution's build + * backend runs outside nsjail, as it already does for Bun sessions. + */ + private async prepareDependencies(code: string): Promise { + if (!this.windmillPath) { + logger.info('No windmill binary path configured, skipping dependency preparation') + return null + } + + const warn = (reason: string): null => { + logger.error(`prepare-deps failed: ${reason}`) + this.sendEvent('output', { + category: 'stderr', + output: `Failed to prepare dependencies: ${reason}\n` + }) + return null + } + + try { + const proc = spawn({ + cmd: [this.windmillPath, 'prepare-deps'], + stdin: new Blob([JSON.stringify({ code, language: 'python3' }) + '\n']), + stdout: 'pipe', + stderr: 'pipe' + }) + + // 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 + const read = (async () => ({ + output: await new Response(proc.stdout).text(), + stderr: await new Response(proc.stderr).text() + }))() + const result = await Promise.race([ + read, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), PREPARE_DEPS_TIMEOUT_MS) + }) + ]) + clearTimeout(timer) + + if (!result) { + proc.kill() + 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') @@ -648,10 +771,11 @@ class PythonDebugSession extends BaseDebugSession { '--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}`) + // 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 @@ -662,7 +786,7 @@ class PythonDebugSession extends BaseDebugSession { this.process = spawnProcess({ cmd, cwd, - env: { PYTHONUNBUFFERED: '1', ...this.envVars } + env: { PYTHONUNBUFFERED: '1', ...sessionProxyEnv(), ...this.envVars } }) // Read stderr to capture startup messages @@ -987,6 +1111,10 @@ sys.stdout.flush() this.sendResponse(request) try { + if (code) { + this.venvPath = (await this.prepareDependencies(code)) ?? undefined + } + await this.startPythonProcess(cwd) // Re-apply breakpoints to the Python server using the actual script path diff --git a/debugger/dap_websocket_server.py b/debugger/dap_websocket_server.py index 624b339894..9407849695 100644 --- a/debugger/dap_websocket_server.py +++ b/debugger/dap_websocket_server.py @@ -280,9 +280,10 @@ class WindmillDebugger(bdb.Bdb): class DebugSession: """Manages a single debug session.""" - def __init__(self, websocket, windmill_path: str | None = None): + def __init__(self, websocket, windmill_path: str | None = None, prepared_venv_path: str | None = None): self.websocket = websocket self.windmill_path = windmill_path + self._prepared_venv_path = prepared_venv_path self.seq = 1 self.initialized = False self.configured = False @@ -309,6 +310,12 @@ class DebugSession: Prepare Python dependencies by calling the windmill CLI. Returns the path to the venv's site-packages directory, or None if no dependencies needed. """ + if self._prepared_venv_path: + # The debug service installs dependencies itself so that the registry credentials + # the CLI needs never enter this interpreter, which executes the debugged script. + logger.info(f"Using dependencies prepared by the debug service: {self._prepared_venv_path}") + return self._prepared_venv_path + if not self.windmill_path: logger.info("No windmill binary path configured, skipping dependency preparation") return None @@ -894,13 +901,17 @@ class DebugSession: ) -# Module-level variable to store windmill binary path +# Module-level variables to store the windmill binary path and, when the debug service +# already installed the script's dependencies, the venv to use instead of installing here. _windmill_path: str | None = None +_prepared_venv_path: str | None = None async def handle_connection(websocket) -> None: """Handle a WebSocket connection.""" - session = DebugSession(websocket, windmill_path=_windmill_path) + session = DebugSession( + websocket, windmill_path=_windmill_path, prepared_venv_path=_prepared_venv_path + ) logger.info(f"New connection from {websocket.remote_address}") try: @@ -924,13 +935,21 @@ async def handle_connection(websocket) -> None: session._cleanup_temp_file() -async def main(host: str = "localhost", port: int = 5679, windmill_path: str | None = None) -> None: +async def main( + host: str = "localhost", + port: int = 5679, + windmill_path: str | None = None, + prepared_venv_path: str | None = None, +) -> None: """Start the DAP WebSocket server.""" - global _windmill_path + global _windmill_path, _prepared_venv_path _windmill_path = windmill_path + _prepared_venv_path = prepared_venv_path if windmill_path: logger.info(f"Windmill binary path: {windmill_path}") + if prepared_venv_path: + logger.info(f"Dependencies prepared by the debug service: {prepared_venv_path}") logger.info(f"Starting DAP WebSocket server on ws://{host}:{port}") async with serve(handle_connection, host, port): @@ -944,6 +963,7 @@ if __name__ == "__main__": parser.add_argument("--host", default="localhost", help="Host to bind to") parser.add_argument("--port", type=int, default=5679, help="Port to listen on") parser.add_argument("--windmill", help="Path to windmill binary for dependency preparation (or set WINDMILL_PATH env var)") + parser.add_argument("--venv-path", help="Site-packages directory of a venv the caller already prepared; skips dependency installation") parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() @@ -957,6 +977,6 @@ if __name__ == "__main__": windmill_path = args.windmill or os.environ.get("WINDMILL_PATH") try: - asyncio.run(main(args.host, args.port, windmill_path)) + asyncio.run(main(args.host, args.port, windmill_path, args.venv_path)) except KeyboardInterrupt: logger.info("Server stopped")