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) <noreply@anthropic.com>

* fix(debugger): keep install-time registry credentials out of the session-visible tree

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: drop em dashes from the debugger registry docs and comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-05 20:16:38 +00:00
committed by GitHub
parent 1aee22296e
commit 154f8f461e
13 changed files with 841 additions and 58 deletions
@@ -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"
}
+4
View File
@@ -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 }
+352 -3
View File
@@ -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<DebugJwks> {
}))
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub npmrc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bunfig_install_scopes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_index_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_extra_index_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uv_index_strategy: Option<String>,
/// Why configured settings were withheld, for the debug service to show the user.
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// 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_<env>` > `global_settings` > `<env>` (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<String, serde_json::Value>,
workspace_overrides: Option<&serde_json::Value>,
key: &str,
env_var: &str,
) -> Option<String> {
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<DB>,
headers: HeaderMap,
) -> JsonResult<DebugRegistryConfig> {
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::<std::collections::HashMap<_, _>>();
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<String>| {
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<DebugTokenClaims, Error> {
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::<std::collections::HashMap<_, _>>();
// 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/")
);
}
}
+1 -1
View File
@@ -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"]
+32 -7
View File
@@ -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<String>,
registry: Option<String>,
bunfig_install_scopes: Option<String>,
) -> 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(())
}
+210 -14
View File
@@ -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<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
@@ -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<String>,
pub npmrc: Option<String>,
pub bunfig_install_scopes: Option<String>,
pub pip_index_url: Option<String>,
pub pip_extra_index_url: Option<String>,
pub uv_index_strategy: Option<String>,
}
#[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<String>,
#[serde(default)]
pub registry: RegistryConfig,
}
/// A blank value means unset, as it does for the same setting on a worker.
fn configured(value: &Option<String>) -> Option<String> {
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<String>);
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<RegistryConfigDir> {
let npmrc = configured(&registry.npmrc);
let npm_config_registry = configured(&registry.npm_config_registry);
let bunfig_install_scopes = configured(&registry.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<String> {
}
/// uv registry arguments, mirroring what the job path passes in `python_executor`.
fn uv_registry_args() -> Vec<String> {
fn uv_registry_args(registry: &RegistryConfig) -> Vec<String> {
let index_url = configured(&registry.pip_index_url).or_else(|| PY_INDEX_URL.clone());
let extra_index_url =
configured(&registry.pip_extra_index_url).or_else(|| PY_EXTRA_INDEX_URL.clone());
let mut args: Vec<String> = 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<String> {
}
/// 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(&registry.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
+1
View File
@@ -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
+64 -19
View File
@@ -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
+27 -8
View File
@@ -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<string | null> {
private async prepareDependencies(code: string, registry: RegistryConfig): Promise<string | null> {
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<string, unknown>) || {}
this.envVars = (args.env as Record<string, string>) || {}
// 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
+28 -6
View File
@@ -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<string, unknown>) || {}
this.envVars = (args.env as Record<string, string>) || {}
// 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<string | null> {
private async prepareDependencies(
code: string,
registry: RegistryConfig,
language: string = 'bun'
): Promise<string | null> {
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
+9
View File
@@ -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"
+84
View File
@@ -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<RegistryConfig> {
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}` }
}
}
+1
View File
@@ -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