fix(debugger): pass python index settings to prepare-deps and report failures (#10533)

* fix: honor python index settings in prepare-deps and report install failures

* fix: forward python registry env to the debugger's prepare-deps

* fix: scope registry credentials to the prepare-deps subprocess

* fix: install python debug dependencies from the service, not the session

* fix: bound the debugger dependency install and keep the proxy bypass default

* docs: name the nsjail config that isolates debug sessions
This commit is contained in:
Ruben Fiszel
2026-08-05 11:43:12 +00:00
committed by GitHub
parent 09c8f3b1f3
commit 7d153d5750
7 changed files with 374 additions and 42 deletions
+146 -13
View File
@@ -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<String> = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL"));
static ref PY_EXTRA_INDEX_URL: Option<String> = 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<String>,
/// 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<String>,
}
/// 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<String, String> {
envs
}
/// uv registry arguments, mirroring what the job path passes in `python_executor`.
fn uv_registry_args() -> Vec<String> {
let mut args: Vec<String> = 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);
}
}
+4 -15
View File
@@ -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<String> =
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<String> = var("PY_TRUSTED_HOST").ok().or(var("PIP_TRUSTED_HOST").ok());
pub static ref INDEX_CERT: Option<String> = 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<String> = 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;
@@ -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<PyV> for PyVAlias {
+20
View File
@@ -704,6 +704,26 @@ lazy_static::lazy_static! {
pub static ref FLOW_RUNNER_RUNNING: Mutex<bool> = 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<String> = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST"));
pub static ref INDEX_CERT: Option<String> = 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<String> = 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<String> {
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
+42
View File
@@ -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
+133 -5
View File
@@ -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<string, string> {
const env: Record<string, string> = {}
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<string, string> = {}
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<string | null> {
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<typeof setTimeout> | 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<null>((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<void> {
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
+26 -6
View File
@@ -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")