From 154f8f461ef01d60da73f9633d76bed45381a035 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 20:16:38 +0000 Subject: [PATCH] feat(debugger): install debug session deps from the instance registry settings (#10550) * feat(debugger): install debug session deps from the instance registry settings Co-Authored-By: Claude Opus 5 (1M context) * fix(debugger): keep install-time registry credentials out of the session-visible tree Co-Authored-By: Claude Opus 5 (1M context) * docs: drop em dashes from the debugger registry docs and comments Co-Authored-By: Claude Opus 5 (1M context) * fix(debugger): stop installing for a session that went away during the settings fetch Also serves nativets sessions the npm settings their installer reads. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ...83ac80b669a910eada527c2a75f5cbf7d830d.json | 28 ++ backend/windmill-api-debug/Cargo.toml | 4 + backend/windmill-api-debug/src/lib.rs | 355 +++++++++++++++++- backend/windmill-api/Cargo.toml | 2 +- backend/windmill-worker/src/bun_executor.rs | 39 +- backend/windmill-worker/src/prepare_deps.rs | 224 ++++++++++- debugger/Dockerfile | 1 + debugger/README.md | 83 +++- debugger/dap_debug_service.ts | 35 +- debugger/dap_websocket_server_bun.ts | 34 +- debugger/nsjail.debug.config.proto | 9 + debugger/registry_config.ts | 84 +++++ docker/DockerfileExtra | 1 + 13 files changed, 841 insertions(+), 58 deletions(-) create mode 100644 backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json create mode 100644 debugger/registry_config.ts diff --git a/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json b/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json new file mode 100644 index 0000000000..28aa09d18d --- /dev/null +++ b/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, value FROM global_settings WHERE name = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d" +} diff --git a/backend/windmill-api-debug/Cargo.toml b/backend/windmill-api-debug/Cargo.toml index 8531407416..c262ac3d32 100644 --- a/backend/windmill-api-debug/Cargo.toml +++ b/backend/windmill-api-debug/Cargo.toml @@ -8,6 +8,10 @@ edition.workspace = true name = "windmill_api_debug" path = "src/lib.rs" +[features] +default = [] +enterprise = [] + [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-api-debug/src/lib.rs b/backend/windmill-api-debug/src/lib.rs index 3b43a04ad1..7b2f569821 100644 --- a/backend/windmill-api-debug/src/lib.rs +++ b/backend/windmill-api-debug/src/lib.rs @@ -20,15 +20,21 @@ //! - A job entry in v2_job (kind=preview) for traceability //! - A completed job entry in v2_job_completed //! - An audit log entry identical to script preview runs +//! +//! The same signature is what authorizes the debugger's requests back to the API: +//! /api/debug/registry_config serves the instance's dependency-registry settings, which the +//! debugger cannot read for itself, to sessions whose token carries the `registry_config` +//! claim. use axum::{ extract::Path, + http::HeaderMap, routing::{get, post}, Extension, Json, Router, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chrono::Utc; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::types::Json as SqlxJson; @@ -37,8 +43,18 @@ use tokio::sync::RwLock; use uuid::Uuid; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::{ - db::UserDB, error::JsonResult, jobs::JobKind, jwt::JWT_SECRET, scripts::ScriptLang, + db::UserDB, + error::{Error, JsonResult}, + global_settings::{ + BUNFIG_INSTALL_SCOPES_SETTING, EXTRA_PIP_INDEX_URL_SETTING, NPMRC_SETTING, + NPM_CONFIG_REGISTRY_SETTING, PIP_INDEX_URL_SETTING, UV_INDEX_STRATEGY_SETTING, + WORKSPACE_REGISTRIES_SETTING, + }, + jobs::JobKind, + jwt::JWT_SECRET, + scripts::ScriptLang, users::username_to_permissioned_as, + DB, }; use windmill_api_auth::ApiAuthed; @@ -112,7 +128,9 @@ pub async fn reload_debug_signing_key() { } pub fn global_service() -> Router { - Router::new().route("/jwks", get(get_jwks)) + Router::new() + .route("/jwks", get(get_jwks)) + .route("/registry_config", get(get_registry_config)) } pub fn workspaced_service() -> Router { @@ -168,6 +186,220 @@ async fn get_jwks() -> JsonResult { })) } +/// The instance's dependency-registry configuration, as `windmill prepare-deps` consumes it. +/// Field names are the `global_settings` keys, so the debug service forwards this object to +/// the CLI as-is. +#[derive(Serialize, Default)] +pub struct DebugRegistryConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub npm_config_registry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub npmrc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bunfig_install_scopes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_index_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_extra_index_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uv_index_strategy: Option, + /// Why configured settings were withheld, for the debug service to show the user. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Placeholder an EE instance puts in an index URL for a token minted per install by +/// `EPHEMERAL_TOKEN_CMD` (`windmill-worker`'s `handle_ephemeral_token`). +const EPHEMERAL_TOKEN_MARKER: &str = "EPHEMERAL_TOKEN"; + +/// Every `global_settings` key [`get_registry_config`] reads, in one query. A setting +/// resolved there but missing here reads as unset, whatever the instance has stored. +const REGISTRY_SETTINGS: [&str; 7] = [ + NPM_CONFIG_REGISTRY_SETTING, + NPMRC_SETTING, + BUNFIG_INSTALL_SCOPES_SETTING, + PIP_INDEX_URL_SETTING, + EXTRA_PIP_INDEX_URL_SETTING, + UV_INDEX_STRATEGY_SETTING, + WORKSPACE_REGISTRIES_SETTING, +]; + +/// Which half of the settings a session installs with, from the language its token was signed +/// for: a session is served only what its own installer runs on, so a token minted for one +/// language cannot be replayed to read the other's credentials. Every language the debugger +/// accepts (`isDebuggableLanguage` in the frontend) has to appear here, or its sessions +/// silently install from the public registries. +fn registry_settings_for_language(language: &str) -> (bool, bool) { + match language { + "bun" | "typescript" | "deno" | "nativets" => (true, false), + "python3" | "python" => (false, true), + _ => (false, false), + } +} + +/// Resolve one setting the way a worker resolves it: a workspace override wins over the +/// instance value, which is `FORCE_` > `global_settings` > `` (the server's +/// `load_option_setting_value`). A blank value means unset from either source, so a +/// workspace can blank one out. +fn resolve_registry_setting( + stored: &std::collections::HashMap, + workspace_overrides: Option<&serde_json::Value>, + key: &str, + env_var: &str, +) -> Option { + let as_str = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).map(|s| s.to_string()); + let instance_value = std::env::var(format!("FORCE_{env_var}")) + .ok() + .or_else(|| as_str(stored.get(key))) + .or_else(|| std::env::var(env_var).ok()); + as_str(workspace_overrides.and_then(|w| w.get(key))) + .or(instance_value) + .filter(|v| !v.trim().is_empty()) +} + +/// Serve the dependency-registry settings to the debug service. +/// +/// `windmill prepare-deps` installs a debug session's imports without a database +/// connection, so the service fetches the settings here and passes them down over the +/// CLI's stdin request. They stop there: a private index URL embeds credentials and the +/// debugged script can read its own process, so nothing served here reaches the session's +/// environment (see `debugger/README.md`). +/// +/// Authorized by the launch token the service verified for that session, and only when the +/// token carries the `registry_config` claim (see [`sign_debug_request`] for what it means). +async fn get_registry_config( + Extension(db): Extension, + headers: HeaderMap, +) -> JsonResult { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| Error::NotAuthorized("Missing debug token".to_string()))?; + + let claims = verify_debug_token(token).await?; + if !claims.registry_config { + return Err(Error::NotAuthorized( + "This debug session is not allowed to read the registry configuration".to_string(), + )); + } + + let names = REGISTRY_SETTINGS.map(String::from); + let stored = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &names[..] + ) + .fetch_all(&db) + .await? + .into_iter() + .map(|r| (r.name, r.value)) + .collect::>(); + + let workspace_overrides = stored + .get(WORKSPACE_REGISTRIES_SETTING) + .and_then(|v| v.get(&claims.workspace_id)); + let (npm, python) = registry_settings_for_language(&claims.language); + let resolve = |serve: bool, key: &str, env_var: &str| { + serve + .then(|| resolve_registry_setting(&stored, workspace_overrides, key, env_var)) + .flatten() + }; + + let mut config = DebugRegistryConfig { + npm_config_registry: resolve(npm, NPM_CONFIG_REGISTRY_SETTING, "NPM_CONFIG_REGISTRY"), + npmrc: resolve(npm, NPMRC_SETTING, "NPMRC"), + bunfig_install_scopes: resolve( + npm, + BUNFIG_INSTALL_SCOPES_SETTING, + "BUNFIG_INSTALL_SCOPES", + ), + pip_index_url: resolve(python, PIP_INDEX_URL_SETTING, "PIP_INDEX_URL"), + pip_extra_index_url: resolve(python, EXTRA_PIP_INDEX_URL_SETTING, "PIP_EXTRA_INDEX_URL"), + // Not a private-registry setting: a worker reads it on any edition, so it is + // served below the Enterprise gate too. + uv_index_strategy: resolve(python, UV_INDEX_STRATEGY_SETTING, "UV_INDEX_STRATEGY"), + message: None, + }; + + if cfg!(feature = "enterprise") { + // A worker substitutes this marker with the output of `EPHEMERAL_TOKEN_CMD` + // (`handle_ephemeral_token`), a command the debug service has no way to run. Serving + // the placeholder would install with a literal token, so the value is withheld and + // the service falls back to the index URL in its own environment. + let ephemeral = |url: &Option| { + url.as_ref() + .is_some_and(|u| u.contains(EPHEMERAL_TOKEN_MARKER)) + }; + if ephemeral(&config.pip_index_url) || ephemeral(&config.pip_extra_index_url) { + config.pip_index_url = None; + config.pip_extra_index_url = None; + config.message = Some(format!( + "Python index configuration ignored: an {EPHEMERAL_TOKEN_MARKER} index URL can only be resolved on a worker" + )); + } + } else { + // A private registry is an Enterprise feature, and `read_ee_registry` drops these + // same settings on a CE worker, so a CE debug session installs from the public registries + // and says why, instead of gaining a capability jobs on that instance don't have. + let configured = config.npm_config_registry.is_some() + || config.npmrc.is_some() + || config.bunfig_install_scopes.is_some() + || config.pip_index_url.is_some() + || config.pip_extra_index_url.is_some(); + config.npm_config_registry = None; + config.npmrc = None; + config.bunfig_install_scopes = None; + config.pip_index_url = None; + config.pip_extra_index_url = None; + if configured { + config.message = Some( + "Private registry configuration ignored: this feature requires Windmill Enterprise Edition" + .to_string(), + ); + } + } + + Ok(Json(config)) +} + +/// Verify a token minted by [`sign_debug_request`] and return its claims. +/// +/// The debug service verifies the same token itself against the JWKS public key; this is +/// the server-side half, for the requests the service makes back on a session's behalf. +async fn verify_debug_token(token: &str) -> Result { + let key_guard = DEBUG_SIGNING_KEY.read().await; + let signing_key = key_guard + .as_ref() + .ok_or_else(|| Error::InternalErr("Debug signing key not initialized".to_string()))?; + + let invalid = || Error::NotAuthorized("Invalid debug token".to_string()); + let mut parts = token.split('.'); + let (header_b64, claims_b64, signature_b64) = + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(header), Some(claims), Some(signature), None) => (header, claims, signature), + _ => return Err(invalid()), + }; + + let signature = Signature::from_slice( + &URL_SAFE_NO_PAD + .decode(signature_b64) + .map_err(|_| invalid())?, + ) + .map_err(|_| invalid())?; + signing_key + .verifying_key() + .verify_strict(format!("{header_b64}.{claims_b64}").as_bytes(), &signature) + .map_err(|_| invalid())?; + + let claims: DebugTokenClaims = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims_b64).map_err(|_| invalid())?) + .map_err(|_| invalid())?; + if Utc::now().timestamp() > claims.exp { + return Err(Error::NotAuthorized("Debug token expired".to_string())); + } + Ok(claims) +} + #[derive(Deserialize)] pub struct SignDebugRequest { /// The code to be debugged @@ -193,6 +425,11 @@ pub struct DebugTokenClaims { pub exp: i64, /// Job ID for traceability pub job_id: String, + /// Whether this session may be served the instance's dependency-registry settings + /// (see [`get_registry_config`]). Defaults to `false` so a token that predates the + /// claim is refused rather than silently trusted. + #[serde(default)] + pub registry_config: bool, } #[derive(Serialize)] @@ -248,6 +485,14 @@ async fn sign_debug_request( iat: now_ts, exp, job_id: job_id.to_string(), + // The registry settings embed credentials, and the token reaches the browser, so they + // are only served for a session whose author can already install with them: someone + // who can run a preview job. For npm that discloses nothing new, since a worker leaves + // the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in; the + // Python index URL only ever appears as uv's argv, so serving it here does widen what + // a member of the workspace can read. Operators cannot run previews at all, so their + // sessions install from the public registries. + registry_config: !authed.is_operator, }; // Create JWT manually with Ed25519 signature @@ -504,3 +749,107 @@ async fn sign_multiplayer( Ok(Json(SignedMultiplayerPayload { token })) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Sign `claims` the way [`sign_debug_request`] does, with a key only this test knows. + async fn signed(claims: &DebugTokenClaims) -> String { + let key = derive_signing_key_from_jwt_secret("test-secret"); + *DEBUG_SIGNING_KEY.write().await = Some(key.clone()); + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"EdDSA","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap()); + let message = format!("{header}.{payload}"); + let signature = URL_SAFE_NO_PAD.encode(key.sign(message.as_bytes()).to_bytes()); + format!("{message}.{signature}") + } + + fn claims(exp_in: i64) -> DebugTokenClaims { + DebugTokenClaims { + code_hash: "0".repeat(32), + language: "bun".to_string(), + workspace_id: "test".to_string(), + email: "user@windmill.dev".to_string(), + iat: Utc::now().timestamp(), + exp: Utc::now().timestamp() + exp_in, + job_id: Uuid::nil().to_string(), + registry_config: true, + } + } + + /// The registry settings are credentials, and this signature is the only thing standing + /// between them and any caller of `/api/debug/registry_config`. Each rejection here is a + /// way in if it stops being checked: an edited claim, a session whose author may not read + /// them, or a token replayed long after its session. + #[tokio::test] + async fn only_an_unexpired_token_with_the_claim_verifies() { + let token = signed(&claims(60)).await; + assert!(verify_debug_token(&token).await.is_ok()); + + let no_claim = signed(&DebugTokenClaims { registry_config: false, ..claims(60) }).await; + assert!(!verify_debug_token(&no_claim).await.unwrap().registry_config); + + let expired = signed(&claims(-1)).await; + assert!(verify_debug_token(&expired).await.is_err()); + + // Re-signing is the only way to change a claim: swapping the payload of a valid token + // for one that grants itself the claim must not verify. + let (header, rest) = token.split_once('.').unwrap(); + let (_, signature) = rest.split_once('.').unwrap(); + let forged = URL_SAFE_NO_PAD.encode( + serde_json::to_string(&DebugTokenClaims { exp: i64::MAX, ..claims(60) }).unwrap(), + ); + assert!( + verify_debug_token(&format!("{header}.{forged}.{signature}")) + .await + .is_err() + ); + } + + /// Every language the debugger can start a session for installs dependencies, so each one + /// has to name the settings its installer reads. A language missing here is not a refusal: + /// its sessions quietly install from the public registries instead. + #[test] + fn every_debuggable_language_is_served_its_own_settings() { + // Mirrors `isDebuggableLanguage` in frontend/src/lib/components/debug/debugUtils.ts. + for language in ["bun", "typescript", "deno", "nativets"] { + assert_eq!(registry_settings_for_language(language), (true, false)); + } + assert_eq!(registry_settings_for_language("python3"), (false, true)); + assert_eq!(registry_settings_for_language("go"), (false, false)); + } + + /// A debug session must resolve a registry setting to what a job in the same workspace + /// resolves it to (`read_ee_registry_with_workspace_override`): the workspace override + /// replaces the instance value, and a blank value from either source means unset, which + /// is how a workspace opts out of an instance-wide registry. + #[test] + fn workspace_override_replaces_the_instance_value() { + let stored = [( + NPM_CONFIG_REGISTRY_SETTING.to_string(), + serde_json::json!("https://instance.example/"), + )] + .into_iter() + .collect::>(); + // Never set, so the environment fallback stays out of the comparison. + let env_var = "WM_TEST_DEBUG_REGISTRY_UNSET"; + let resolve = |overrides: Option<&serde_json::Value>| { + resolve_registry_setting(&stored, overrides, NPM_CONFIG_REGISTRY_SETTING, env_var) + }; + + assert_eq!(resolve(None).as_deref(), Some("https://instance.example/")); + let workspace = serde_json::json!({ "npm_config_registry": "https://workspace.example/" }); + assert_eq!( + resolve(Some(&workspace)).as_deref(), + Some("https://workspace.example/") + ); + let blanked = serde_json::json!({ "npm_config_registry": " " }); + assert_eq!(resolve(Some(&blanked)), None); + let unrelated = serde_json::json!({ "npmrc": "//other/:_authToken=x" }); + assert_eq!( + resolve(Some(&unrelated)).as_deref(), + Some("https://instance.example/") + ); + } +} diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 4c410b7785..e39c2a35c4 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-assets/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-amqp?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private", "windmill-object-store/private"] -enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"] +enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-debug/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"] stripe = [] run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"] agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 01e1203b6b..d64499878b 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -571,12 +571,8 @@ async fn gen_bunfig( NPMRC.read().await.clone() }; - if let Some(ref npmrc_content) = npmrc { - if !npmrc_content.trim().is_empty() { - tracing::debug!("Writing .npmrc for bun from npmrc setting"); - write_file(job_dir, ".npmrc", npmrc_content)?; - return Ok(()); - } + if npmrc.as_ref().is_some_and(|c| !c.trim().is_empty()) { + return write_bun_registry_config(job_dir, npmrc, None, None); } let (registry, bunfig_install_scopes) = if let Some(conn) = db { @@ -606,6 +602,35 @@ async fn gen_bunfig( BUNFIG_INSTALL_SCOPES.read().await.clone(), ) }; + write_bun_registry_config(job_dir, None, registry, bunfig_install_scopes) +} + +/// The files [`write_bun_registry_config`] may create in the directory bun installs from. +/// Both can hold a registry auth token, so `prepare-deps` deletes them by these names once +/// the install is over. +pub(crate) const BUN_NPMRC_FILE: &str = ".npmrc"; +pub(crate) const BUN_CONFIG_FILE: &str = "bunfig.toml"; + +/// Write the registry configuration `bun install` picks up from its working directory: the +/// `npmrc` setting verbatim as `.npmrc` when set, otherwise a `bunfig.toml` holding the +/// registry URL, its auth token and the install scopes. +/// +/// Shared with the debugger's `prepare-deps`, which resolves the same settings without a +/// database (see `prepare_deps.rs`), so the two install paths configure bun identically. +pub(crate) fn write_bun_registry_config( + job_dir: &str, + npmrc: Option, + registry: Option, + bunfig_install_scopes: Option, +) -> Result<()> { + if let Some(ref npmrc_content) = npmrc { + if !npmrc_content.trim().is_empty() { + tracing::debug!("Writing .npmrc for bun from npmrc setting"); + write_file(job_dir, BUN_NPMRC_FILE, npmrc_content)?; + return Ok(()); + } + } + if registry.is_some() || bunfig_install_scopes.is_some() { let (url, token_opt) = if let Some(ref s) = registry { let url = s.trim(); @@ -635,7 +660,7 @@ registry = {} .unwrap_or("".to_string()) ); tracing::debug!("Writing following bunfig.toml: {bunfig_toml}"); - let _ = write_file(&job_dir, "bunfig.toml", &bunfig_toml)?; + let _ = write_file(&job_dir, BUN_CONFIG_FILE, &bunfig_toml)?; } Ok(()) } diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 161744af40..9c8a5179e3 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -12,6 +12,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::process::Command; +use crate::bun_executor::{write_bun_registry_config, BUN_CONFIG_FILE, BUN_NPMRC_FILE}; 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, @@ -92,9 +93,10 @@ 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. + /// Fallbacks for the registry settings, used when the caller sends none: this process has + /// no database, so the instance settings only reach it through the request (see + /// [`RegistryConfig`]), and a debug service that cannot fetch them still configures the + /// installer from its own environment. 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 @@ -112,6 +114,25 @@ const p = { }; "#; +/// The registry settings resolved from the instance settings by the caller. +/// +/// This process runs without a database, so `GET /api/debug/registry_config` is where the +/// debug service reads them and this request field is how they get here. They are consumed +/// to configure the installer and never handed to the debug session itself: an index URL +/// embeds credentials and the session executes user-supplied code. +/// +/// Field names are the `global_settings` keys, so the service forwards the endpoint's +/// response verbatim. +#[derive(Deserialize, Default)] +pub struct RegistryConfig { + pub npm_config_registry: Option, + pub npmrc: Option, + pub bunfig_install_scopes: Option, + pub pip_index_url: Option, + pub pip_extra_index_url: Option, + pub uv_index_strategy: Option, +} + #[derive(Deserialize)] pub struct PrepareRequest { pub code: String, @@ -121,6 +142,125 @@ pub struct PrepareRequest { /// extension built for another version is simply invisible there. #[serde(default)] pub python_path: Option, + #[serde(default)] + pub registry: RegistryConfig, +} + +/// A blank value means unset, as it does for the same setting on a worker. +fn configured(value: &Option) -> Option { + value.clone().filter(|v| !v.trim().is_empty()) +} + +/// Where the registry configuration for one `bun install` is written. +/// +/// Deliberately not the install directory: the debug session resolves its `node_modules` +/// symlink into that directory, and the sandbox bind-mounts all of `/tmp` into every session, +/// so a concurrent session could read the credentials of an install in flight. `/var/tmp` is a +/// tmpfs private to each jail (`debugger/nsjail.debug.config.proto`), which also takes the +/// credentials with it when a jailed install is killed. `bun install` reads them from here +/// through `--config` and `HOME`. +const REGISTRY_CONFIG_ROOT: &str = "/var/tmp/windmill-debug-registry"; + +/// How long a configuration directory may survive before the next install treats it as debris. +/// The caller kills an install with SIGKILL, leaving nothing able to clean up after it, and an +/// unjailed install is the only one that writes somewhere outliving the process at all. Well +/// past any install: the caller's own timeout is two minutes by default. +const REGISTRY_CONFIG_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60); + +/// Removes the registry configuration when the install ends, whichever way it ends. +struct RegistryConfigDir(Option); + +impl Drop for RegistryConfigDir { + fn drop(&mut self) { + if let Some(dir) = self.0.as_deref() { + remove_registry_config_dir(dir); + } + } +} + +/// Write the registry configuration for one install and return the directory holding it, empty +/// when there is nothing to write. +/// +/// Falls back to the install directory if the private root is not writable: an install that +/// reaches its registry matters more than the isolation above, which only holds for sessions +/// that run under nsjail in the first place. +fn write_registry_config_dir( + job_id: &uuid::Uuid, + job_dir: &str, + registry: &RegistryConfig, +) -> anyhow::Result { + let npmrc = configured(®istry.npmrc); + let npm_config_registry = configured(®istry.npm_config_registry); + let bunfig_install_scopes = configured(®istry.bunfig_install_scopes); + if npmrc.is_none() && npm_config_registry.is_none() && bunfig_install_scopes.is_none() { + return Ok(RegistryConfigDir(None)); + } + + let dir = format!("{}/{}", REGISTRY_CONFIG_ROOT, job_id); + let dir = match create_private_dir(&dir) { + Ok(()) => dir, + Err(e) => { + tracing::warn!("Could not create {dir} ({e}), keeping the registry configuration in the install directory"); + job_dir.to_string() + } + }; + // Claimed before anything is written to it, so a failure below still takes it down. + let held = RegistryConfigDir(Some(dir.clone())); + write_bun_registry_config(&dir, npmrc, npm_config_registry, bunfig_install_scopes)?; + Ok(held) +} + +fn create_private_dir(dir: &str) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::create_dir_all(REGISTRY_CONFIG_ROOT)?; + sweep_stale_registry_config(); + std::fs::DirBuilder::new().mode(0o700).create(dir) + } + #[cfg(not(unix))] + { + sweep_stale_registry_config(); + std::fs::create_dir_all(dir) + } +} + +/// Drop what an install that was killed could not clean up itself. +fn sweep_stale_registry_config() { + let Ok(entries) = std::fs::read_dir(REGISTRY_CONFIG_ROOT) else { + return; + }; + for entry in entries.flatten() { + let stale = entry + .metadata() + .and_then(|m| m.modified()) + .is_ok_and(|m| m.elapsed().is_ok_and(|age| age > REGISTRY_CONFIG_MAX_AGE)); + if stale { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +/// Delete the registry configuration once the install that needed it is over. A failure to +/// remove credentials has to be visible. +fn remove_registry_config_dir(dir: &str) { + if dir.starts_with(REGISTRY_CONFIG_ROOT) { + if let Err(e) = std::fs::remove_dir_all(dir) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::error!("Failed to remove registry configuration {dir}: {e}"); + } + } + return; + } + // The fallback path above put them in the install directory, which has to survive. + for file in [BUN_NPMRC_FILE, BUN_CONFIG_FILE] { + let path = format!("{}/{}", dir, file); + if let Err(e) = std::fs::remove_file(&path) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::error!("Failed to remove registry configuration {path}: {e}"); + } + } + } } #[derive(Serialize)] @@ -195,14 +335,18 @@ fn index_ca_bundle() -> Option { } /// uv registry arguments, mirroring what the job path passes in `python_executor`. -fn uv_registry_args() -> Vec { +fn uv_registry_args(registry: &RegistryConfig) -> Vec { + let index_url = configured(®istry.pip_index_url).or_else(|| PY_INDEX_URL.clone()); + let extra_index_url = + configured(®istry.pip_extra_index_url).or_else(|| PY_EXTRA_INDEX_URL.clone()); + let mut args: Vec = vec![]; - if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() { + if let Some(urls) = 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() { + if let Some(url) = index_url.as_ref() { args.extend(["--index-url".to_string(), url.to_string()]); } if let Some(hosts) = TRUSTED_HOST.as_ref() { @@ -217,7 +361,11 @@ fn uv_registry_args() -> Vec { } /// Prepare Python dependencies using uv -async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) -> PrepareResponse { +async fn prepare_python_deps_standalone( + code: &str, + python_path: Option<&str>, + registry: &RegistryConfig, +) -> PrepareResponse { // Parse imports from the code let packages = parse_python_imports(code); @@ -253,7 +401,7 @@ async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) - 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(), + configured(®istry.uv_index_strategy).unwrap_or_else(|| 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()); @@ -270,7 +418,7 @@ async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) - common_uv_envs.insert("SSL_CERT_DIR".to_string(), cert_dir); } - let registry_args = uv_registry_args(); + let registry_args = uv_registry_args(registry); // Step 1: Create virtual environment using uv // `--seed` resolves pip/setuptools from the index, so the venv also needs the registry @@ -418,11 +566,12 @@ pub async fn prepare_deps_standalone( code: &str, language: &str, python_path: Option<&str>, + registry: &RegistryConfig, ) -> PrepareResponse { // Route to the appropriate handler based on language match language { "python3" | "python" => { - return prepare_python_deps_standalone(code, python_path).await; + return prepare_python_deps_standalone(code, python_path, registry).await; } "bun" | "typescript" | "deno" => { // Continue with JS/TS handling below @@ -489,7 +638,7 @@ pub async fn prepare_deps_standalone( }; } - let common_bun_proc_envs = get_simple_bun_proc_envs(); + let mut common_bun_proc_envs = get_simple_bun_proc_envs(); // Step 1: Run build.js to generate package.json let output = Command::new(&*BUN_PATH) @@ -582,17 +731,43 @@ pub async fn prepare_deps_standalone( }; } - // Step 2: Run bun install + // Step 2: Run bun install, from the same registry configuration a job installs with. + let mut args = vec!["install".to_string()]; + let registry_config_dir = match write_registry_config_dir(&job_id, &job_dir, registry) { + Ok(dir) => dir, + Err(e) => { + return PrepareResponse { + node_modules_path: None, + venv_path: None, + job_dir: job_dir.clone(), + success: false, + error: Some(format!("Failed to write registry configuration: {}", e)), + install_stderr: None, + }; + } + }; + if let Some(dir) = registry_config_dir.0.as_ref() { + // Only one of the two files exists: `.npmrc` is read from the installer's home, + // `bunfig.toml` only from the path named here. + common_bun_proc_envs.insert("HOME".to_string(), dir.to_string()); + let bunfig = format!("{}/{}", dir, BUN_CONFIG_FILE); + if std::path::Path::new(&bunfig).exists() { + args.push(format!("--config={}", bunfig)); + } + } + let output = Command::new(&*BUN_PATH) .current_dir(&job_dir) .env_clear() .envs(common_bun_proc_envs) - .args(vec!["install"]) + .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await; + drop(registry_config_dir); + match output { Ok(out) => { if !out.status.success() { @@ -691,6 +866,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { &request.code, &request.language, request.python_path.as_deref(), + &request.registry, ) .await; println!("{}", serde_json::to_string(&response)?); @@ -700,7 +876,27 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use super::PrepareResponse; + use super::{PrepareRequest, PrepareResponse}; + + /// The debug service and this CLI are deployed as separate images, and the service + /// forwards `GET /api/debug/registry_config` verbatim, `message` field included. So a + /// request from an older service carries no `registry` at all, and one from a newer + /// service carries fields this binary does not know. + #[test] + fn test_registry_is_optional_and_tolerates_unknown_fields() { + let without: PrepareRequest = + serde_json::from_str(r#"{"code": "import lodash", "language": "bun"}"#).unwrap(); + assert!(without.registry.npm_config_registry.is_none()); + + let with: PrepareRequest = serde_json::from_str( + r#"{"code": "", "language": "bun", "registry": {"npm_config_registry": "https://npm.example", "message": "for the user"}}"#, + ) + .unwrap(); + assert_eq!( + with.registry.npm_config_registry.as_deref(), + Some("https://npm.example") + ); + } /// 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 diff --git a/debugger/Dockerfile b/debugger/Dockerfile index 4c1eb85c98..a80643e823 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -48,6 +48,7 @@ COPY dap_debug_service.ts . COPY dap_websocket_server_bun.ts . COPY env_passthrough.ts . COPY dap_websocket_server.py . +COPY registry_config.ts . # Expose the default port EXPOSE 5679 diff --git a/debugger/README.md b/debugger/README.md index 31599f4db8..1dba49d977 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -75,36 +75,81 @@ Options: | `DAP_NSJAIL_PATH` | nsjail binary path | nsjail | | `DAP_NSJAIL_CONFIG` | nsjail config file path | - | -### Python dependency preparation +### 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. +Before debugging a script, its imports are installed through `windmill prepare-deps`, which runs +`uv` (Python) or `bun install` (TypeScript) without a database connection. The install runs in the +service rather than in the session because the registry configuration usually embeds credentials +and a debug server executes the submitted script inside a process the script can read; the Python +server is handed only the resulting venv, with `--venv-path`, and a Bun session only the resulting +`node_modules`. -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): +`DAP_PREPARE_DEPS_TIMEOUT_MS` bounds the install (default 120000); past it the session starts +without its dependencies. 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. + +### Registry configuration + +Because `prepare-deps` has no database, the service reads the instance settings for it from +`GET /api/debug/registry_config` on `WINDMILL_BASE_URL` and passes them down over the CLI's stdin +request. It is authorized by the launch token of the session being started, and serves only the +settings that session's own installer runs on, so a TypeScript session's token cannot be used to +read the Python index credentials. + +The token also reaches the browser, so what it can fetch is what a workspace member can fetch. +Sessions started by an operator are refused outright, since an operator cannot run a preview job +either; for a member who can, the npm settings are already exposed by a preview (a worker leaves +the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in), while the Python +index URL, which otherwise only appears as uv's argv, becomes readable where it was not before. + +These settings are Enterprise-only, exactly as they are for jobs, and a CE instance reports that in +the session's output rather than applying them: + +| Setting | Applies to | +|---------|------------| +| `npm_config_registry` | `bun install` registry and its `:_authToken=` | +| `npmrc` | written verbatim as `.npmrc`, taking precedence over `npm_config_registry` | +| `bunfig_install_scopes` | `[install.scopes]` in the generated `bunfig.toml` | +| `pip_index_url` | `uv --index-url` | +| `pip_extra_index_url` | `uv --extra-index-url`, comma-separated | + +`uv_index_strategy` is served on any edition, like it is to a worker. An index URL holding the +`EPHEMERAL_TOKEN` placeholder is not served at all: only a worker can run the command that +substitutes it. + +The credential-bearing files (`.npmrc`, `bunfig.toml`) are written under +`/var/tmp/windmill-debug-registry`, not into the directory the install runs in, and are deleted +when the install ends. That directory is not private to the install: a session resolves its +`node_modules` symlink back into it, and `nsjail.debug.config.proto` bind-mounts the whole of +`/tmp` into every session, so credentials left there would be readable by a concurrent session. +`/var/tmp` is a tmpfs in that same config, one instance per jail, so a session sees an empty one +and a jailed install's credentials go away with the jail even when it is killed (the service kills +an install with SIGKILL, which no cleanup in the installer can survive). An install running +unjailed writes to the host's `/var/tmp` instead, where a directory a kill left behind is removed +by the next install; a session running unjailed is unconfined anyway and sees the whole filesystem, +as it already does the rest of the service's state. + +The rest of the registry configuration has no instance setting and is read from the environment of +the debug service. Where two names are listed the first wins; a worker reads the same names: | 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`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - | | `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - | | `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 | +| `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 | -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. +`PY_INDEX_URL` / `PIP_INDEX_URL` and `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL`, along with +`UV_INDEX_STRATEGY`, are still read from the same environment whenever the fetch yields no index: +because the instance has none set, because this is a CE instance, or because the session was not +allowed the settings. A Python debug service configured that way therefore keeps working, but setting +them is an instance-wide decision to install Python dependencies from that index, independent of who +opened the session; leave them unset to let the instance settings alone decide. The npm settings have +no such fallback: the instance settings are the only source. 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 diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 6e16bae75a..11616f7b11 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -47,6 +47,7 @@ import { type NsjailConfig } from './dap_websocket_server_bun' import { sessionEnv } from './env_passthrough' +import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // ============================================================================ // Configuration @@ -650,17 +651,17 @@ 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. + * This runs here rather than in the Python server because the registry settings the CLI is + * given routinely embed private-registry credentials, and the Python server executes the + * submitted script inside its own interpreter: anything in that process is recoverable by the + * script. The service never executes user code, so the credentials stop here. * * It still goes through spawnProcess so nsjail confines it on the same terms as the debuggee: * `uv pip install` builds source distributions, which executes their build backend's arbitrary * Python. Those same credentials are what `inheritEnv` is for — the jail config keeps the * environment across the boundary, so nothing else has to carry them in. */ - private async prepareDependencies(code: string): Promise { + private async prepareDependencies(code: string, registry: RegistryConfig): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null @@ -688,7 +689,12 @@ class PythonDebugSession extends BaseDebugSession { // site-packages goes on that interpreter's sys.path, and uv otherwise picks its // own, which silently leaves compiled extensions unimportable. stdin: new Blob([ - JSON.stringify({ code, language: 'python3', python_path: config.pythonPath }) + '\n' + JSON.stringify({ + code, + language: 'python3', + python_path: config.pythonPath, + registry + }) + '\n' ]), inheritEnv: true, detached: true @@ -1042,6 +1048,8 @@ class PythonDebugSession extends BaseDebugSession { this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} + // Also what authorizes the registry configuration fetch below. + const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). @@ -1055,7 +1063,6 @@ class PythonDebugSession extends BaseDebugSession { return } - 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.') @@ -1118,7 +1125,19 @@ sys.stdout.flush() try { if (code) { - this.venvPath = (await this.prepareDependencies(code)) ?? undefined + const registry = await fetchRegistryConfig(token, logger) + // A round trip of its own, during which the client can give up: the installer runs + // a source distribution's build backend, so starting one for a session that is + // already gone executes package code nobody is waiting for. + if (this.disposed) { + logger.info('Session torn down during the registry configuration fetch, not installing') + await this.cleanup() + return + } + if (registry.message) { + this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) + } + this.venvPath = (await this.prepareDependencies(code, registry)) ?? undefined } // Installing takes long enough for the client to give up meanwhile, and cleanup() has diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts index 8a0f2fb4e4..3b5072b1e6 100644 --- a/debugger/dap_websocket_server_bun.ts +++ b/debugger/dap_websocket_server_bun.ts @@ -27,6 +27,7 @@ import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { sessionEnv } from './env_passthrough' +import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // Types for V8 Inspector Protocol interface V8Message { @@ -1512,6 +1513,8 @@ export class DebugSession { this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} + // Also what authorizes the registry configuration fetch below. + const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). @@ -1525,7 +1528,6 @@ export class DebugSession { return } - 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.') @@ -1557,7 +1559,19 @@ export class DebugSession { // Prepare dependencies using the original code (before any modifications) // This analyzes imports and installs required npm packages if (code) { - this.nodeModulesPath = await this.prepareDependencies(code) || undefined + const registry = await fetchRegistryConfig(token, logger) + // A round trip of its own, during which the client can give up: the installer runs the + // packages' postinstall scripts, so starting one for a session that is already gone + // executes package code nobody is waiting for. + if (this.disposed) { + logger.info('Session was torn down during the registry configuration fetch, not installing') + this.sendResponse(request, false, {}, 'Session terminated during dependency preparation') + return + } + if (registry.message) { + this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) + } + this.nodeModulesPath = await this.prepareDependencies(code, registry) || undefined // Installing takes long enough for the client to give up meanwhile, and cleanup() has // then already run: starting the debuggee now would leak a process nothing owns. @@ -1653,10 +1667,18 @@ export class DebugSession { * * Jailed on the same terms as the debuggee: `bun install` runs the packages' postinstall * scripts, which is user-supplied code executing next to the other services in the container. - * Its environment is inherited rather than filtered, which is what carries the registry and - * CA settings into the installer (the jail keeps the environment across the boundary). + * Its environment is inherited rather than filtered, which is what carries the CA settings + * into the installer (the jail keeps the environment across the boundary). + * + * The CLI has no database, so `registry` carries the instance's registry settings down to it + * instead. They configure `bun install` and nothing else: the debugged script never gets + * them, since it could read them back out of the process it runs in. */ - private async prepareDependencies(code: string, language: string = 'bun'): Promise { + private async prepareDependencies( + code: string, + registry: RegistryConfig, + language: string = 'bun' + ): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null @@ -1679,7 +1701,7 @@ export class DebugSession { let timedOut = false try { - const input = JSON.stringify({ code, language }) + '\n' + const input = JSON.stringify({ code, language, registry }) + '\n' logger.info(`prepare-deps input length: ${input.length}`) // Spawn the windmill binary with prepare-deps command. Its environment is inherited diff --git a/debugger/nsjail.debug.config.proto b/debugger/nsjail.debug.config.proto index 5e538e6002..65ea3456cf 100644 --- a/debugger/nsjail.debug.config.proto +++ b/debugger/nsjail.debug.config.proto @@ -63,6 +63,15 @@ mount { rw: true } +# Private scratch, one instance per jail. `windmill prepare-deps` writes the registry +# credentials here rather than into its install directory under the shared /tmp above, so no +# other session can read them, and they go away with the jail even when it is killed. +mount { + dst: "/var/tmp" + fstype: "tmpfs" + rw: true +} + # Debugger scripts directory (for Python debugger server) mount { src: "/debugger" diff --git a/debugger/registry_config.ts b/debugger/registry_config.ts new file mode 100644 index 0000000000..b7cb0451e5 --- /dev/null +++ b/debugger/registry_config.ts @@ -0,0 +1,84 @@ +/** + * Dependency-registry settings for a debug session's install. + * + * `windmill prepare-deps` installs a session's imports with no database connection, so the + * instance settings that point at a private npm or pip registry cannot be read there. They + * are fetched here instead, from the backend that signed the session's launch token, and + * passed down to the CLI over its stdin request. + * + * They stop at the installer. A registry URL usually embeds credentials and a debugged + * script can read whatever the process running it holds, so none of these values are ever + * put in a session's environment (see README.md, "Registry configuration"). + */ + +export interface RegistryConfig { + npm_config_registry?: string + npmrc?: string + bunfig_install_scopes?: string + pip_index_url?: string + pip_extra_index_url?: string + uv_index_strategy?: string + /** Why the instance's settings are not in this response, for the user to see. */ + message?: string +} + +const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL + +/** + * Bounds how long a launch waits on the backend. The session can still start without the + * settings, it just installs from the public registries, so an unreachable backend must + * not hold it up for longer than the install itself would take. + */ +const FETCH_TIMEOUT_MS = Number(process.env.DAP_REGISTRY_CONFIG_TIMEOUT_MS) || 10_000 + +/** + * Fetch the registry settings for a session, authorized by its launch token. + * + * Never throws and never blocks a launch: on any failure it returns a config carrying only + * a `message`, so the session starts against the public registries and the user is told why + * instead of being left with an unexplained "package not found". + */ +export async function fetchRegistryConfig( + token: string | undefined, + logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void } +): Promise { + if (!token || !WINDMILL_BASE_URL) { + return {} + } + + const url = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/registry_config` + try { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (response.status === 401 || response.status === 403 || response.status === 404) { + // Expected answers, not something the user can act on: a session that may not read + // the settings (an operator's) is refused, and a backend older than this image has + // no such route at all. Both install from the public registries. + logger.info(`Registry configuration not served for this session (${response.status})`) + return {} + } + if (!response.ok) { + const detail = (await response.text().catch(() => '')).trim() + return { + message: `Could not read the registry configuration (${response.status}): ${detail || response.statusText}` + } + } + + const config: RegistryConfig = await response.json() + // The values carry registry credentials, so only their names are logged. + const configured = Object.entries(config) + .filter(([key, value]) => key !== 'message' && value) + .map(([key]) => key) + logger.info( + configured.length > 0 + ? `Registry configuration from instance settings: ${configured.join(', ')}` + : 'No registry configuration set on the instance' + ) + return config + } catch (error) { + logger.warn(`Failed to fetch registry configuration: ${error}`) + return { message: `Could not read the registry configuration: ${error}` } + } +} diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index a0b4986102..9eb2a987f2 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -96,6 +96,7 @@ COPY debugger/dap_debug_service.ts . COPY debugger/dap_websocket_server_bun.ts . COPY debugger/env_passthrough.ts . COPY debugger/dap_websocket_server.py . +COPY debugger/registry_config.ts . COPY debugger/nsjail.debug.config.proto . # Install Python debugger dependencies using uv