From cf41486fbd852401116a2afe18ec00c9e22394df Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Wed, 8 Jul 2026 11:31:19 -0700 Subject: [PATCH 1/6] fix(playground): allow any loopback origin through the playground access guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard's port-derived origin allowlist blocked ssh -L remapped tunnels: through a tunnel the browser's origin is the local endpoint — loopback host, arbitrary port. Check host-is-loopback (localhost, 127.0.0.0/8, [::1], http or https, any port) instead of exact strings. The host, not the port, is the DNS-rebinding trust signal; a hostile page's fetch/WS still carries its real remote origin and stays denied. VS Code webview origins are unchanged. --- .../baml_lsp_server/src/playground_server.rs | 90 ++++++++++++------- 1 file changed, 59 insertions(+), 31 deletions(-) diff --git a/baml_language/crates/baml_lsp_server/src/playground_server.rs b/baml_language/crates/baml_lsp_server/src/playground_server.rs index b5c72167b9b..92ca2fa6474 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_server.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_server.rs @@ -805,20 +805,11 @@ fn capture_loss_message(capture_kind: &str, skipped: u64) -> String { } #[derive(Clone, Debug)] -struct PlaygroundAccessGuard { - allowed_origins: Arc>, -} +struct PlaygroundAccessGuard {} impl PlaygroundAccessGuard { - fn for_listener_addr(addr: SocketAddr) -> Self { - let port = addr.port(); - Self { - allowed_origins: Arc::new(vec![ - format!("http://localhost:{port}"), - format!("http://127.0.0.1:{port}"), - format!("http://[::1]:{port}"), - ]), - } + fn new() -> Self { + Self {} } fn is_allowed_origin(&self, origin: Option<&HeaderValue>) -> bool { @@ -828,10 +819,7 @@ impl PlaygroundAccessGuard { let Ok(origin) = origin.to_str() else { return false; }; - self.allowed_origins - .iter() - .any(|allowed| origin.eq_ignore_ascii_case(allowed)) - || is_vscode_webview_origin(origin) + is_loopback_origin(origin) || is_vscode_webview_origin(origin) } fn cors_origin(&self, origin: Option<&HeaderValue>) -> Option { @@ -844,6 +832,31 @@ impl PlaygroundAccessGuard { } } +/// True when `origin` is an http(s) origin whose host is loopback +/// (`localhost`, a 127.0.0.0/8 address, or `[::1]`), on any port. +/// +/// Through an `ssh -L` tunnel the page's origin is the local tunnel endpoint: +/// loopback host, arbitrary port. The host, not the port, is the trust signal; +/// a hostile web page's fetch/WS still carries its real remote origin -> denied. +fn is_loopback_origin(origin: &str) -> bool { + let Ok(uri) = origin.parse::() else { + return false; + }; + if !matches!(uri.scheme_str(), Some("http") | Some("https")) { + return false; + } + let Some(host) = uri.host() else { + return false; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + fn is_vscode_webview_origin(origin: &str) -> bool { if origin .get(..17) @@ -904,7 +917,7 @@ pub async fn run( workspace_roots: Vec, ) -> anyhow::Result<()> { let local_addr = listener.local_addr()?; - let access_guard = PlaygroundAccessGuard::for_listener_addr(local_addr); + let access_guard = PlaygroundAccessGuard::new(); let app = build_router( bex, broadcast_tx, @@ -2933,21 +2946,36 @@ mod tests { } #[test] - fn playground_access_guard_accepts_localhost_and_vscode_origins_only() { - let guard = - PlaygroundAccessGuard::for_listener_addr(SocketAddr::from(([127, 0, 0, 1], 3700))); + fn playground_access_guard_accepts_any_loopback_port_and_vscode_origins() { + let guard = PlaygroundAccessGuard::new(); assert!(guard.is_allowed_origin(None)); - assert!(guard.is_allowed_origin(Some(&HeaderValue::from_static("http://localhost:3700")))); - assert!( - guard.is_allowed_origin(Some(&HeaderValue::from_static("vscode-webview://abc123"))) - ); - assert!(guard.is_allowed_origin(Some(&HeaderValue::from_static( - "https://abc123.vscode-cdn.net" - )))); - assert!(!guard.is_allowed_origin(Some(&HeaderValue::from_static("https://example.com")))); - assert!(!guard.is_allowed_origin(Some(&HeaderValue::from_static( - "https://vscode-cdn.net.example.com" - )))); + for allowed in [ + "http://localhost:4265", + "http://localhost:8000", // ssh -L remapped tunnel port + "http://127.0.0.1:9999", + "http://[::1]:4000", + "https://localhost:8443", + "vscode-webview://abc123", + "https://abc123.vscode-cdn.net", + ] { + assert!( + guard.is_allowed_origin(Some(&HeaderValue::from_static(allowed))), + "{allowed}" + ); + } + for denied in [ + "https://example.com", + "http://localhost.evil.com", // suffix trick + "http://10.0.0.5:4265", // non-loopback IP + "http://127.0.0.1.evil.com:4265", + "null", + "https://vscode-cdn.net.example.com", + ] { + assert!( + !guard.is_allowed_origin(Some(&HeaderValue::from_static(denied))), + "{denied}" + ); + } } #[test] From dc09da3a60222b405355264cd2f8055cfce7f4a6 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Wed, 8 Jul 2026 11:32:42 -0700 Subject: [PATCH 2/6] feat(cli): add --port/--no-open to baml playground and print a startup banner - --port binds exactly that port via a new bind_exact_port (fails loudly with an actionable message); default stays the auto-scan. - --no-open suppresses the browser; headless sessions (SSH_CONNECTION/ SSH_TTY anywhere, no DISPLAY/WAYLAND_DISPLAY on non-macOS unix) are auto-detected in the CLI and suppress it too. The server crate just obeys a bool on the new PlaygroundServerOptions. - A plain-text stdout banner (URL, project root, ssh -L hint, Ctrl-C note) prints the moment the listener is bound, before project load and independent of RUST_LOG. Browser mode only; stdout is unused there. - webbrowser::open moves onto a detached thread so a text-mode browser (lynx/w3m) can never block server startup. - In browser mode a port-bind failure now propagates as an error instead of limping on to the generic bail; LSP mode keeps serving stdio. --- baml_language/crates/baml_cli/src/commands.rs | 2 + .../crates/baml_cli/src/playground_command.rs | 62 ++++++++++- .../crates/baml_lsp_server/src/lib.rs | 101 +++++++++++++++--- .../baml_lsp_server/src/playground_sender.rs | 10 +- .../baml_lsp_server/src/playground_server.rs | 25 +++++ 5 files changed, 183 insertions(+), 17 deletions(-) diff --git a/baml_language/crates/baml_cli/src/commands.rs b/baml_language/crates/baml_cli/src/commands.rs index 5a424575101..affdedce554 100644 --- a/baml_language/crates/baml_cli/src/commands.rs +++ b/baml_language/crates/baml_cli/src/commands.rs @@ -323,6 +323,8 @@ mod tests { assert!(help.contains("Usage: baml playground [OPTIONS]"), "{help}"); assert!(help.contains("--file "), "{help}"); assert!(help.contains("--from "), "{help}"); + assert!(help.contains("--port "), "{help}"); + assert!(help.contains("--no-open"), "{help}"); } /// `run -e` accepts hyphen-prefixed values without consuming run flags. diff --git a/baml_language/crates/baml_cli/src/playground_command.rs b/baml_language/crates/baml_cli/src/playground_command.rs index ecd1826d628..b7608fb1f4b 100644 --- a/baml_language/crates/baml_cli/src/playground_command.rs +++ b/baml_language/crates/baml_cli/src/playground_command.rs @@ -14,6 +14,16 @@ pub struct PlaygroundArgs { /// Project search starting point. Ignored when `--file` is set. #[arg(long, value_name = "PATH")] pub from: Option, + + /// Listen on exactly this port (errors if unavailable). + /// Default: the first free port from 3700. + #[arg(long, value_name = "PORT")] + pub port: Option, + + /// Do not open a browser. Opening is also skipped automatically in + /// headless sessions (SSH, or no display on Linux). + #[arg(long)] + pub no_open: bool, } impl PlaygroundArgs { @@ -21,11 +31,30 @@ impl PlaygroundArgs { let playground_dir = resolve_playground_assets()?; let roots = workspace_roots(self.from.as_deref(), self.file.as_deref())?; - baml_lsp_server::run_playground_server(roots, playground_dir)?; + let options = baml_lsp_server::PlaygroundServerOptions { + port: self.port, + open_browser: !self.no_open + && !is_headless_session(|key| std::env::var_os(key).is_some()), + }; + baml_lsp_server::run_playground_server(roots, playground_dir, options)?; Ok(crate::ExitCode::Success) } } +/// A browser can't usefully open here: an SSH session, or a Linux/BSD session +/// with no display server. `webbrowser` would fall back to text-mode browsers +/// (lynx/w3m) that hijack the terminal. macOS/Windows sessions are never +/// display-less in practice, so only the SSH signal applies there. +fn is_headless_session(has_env: impl Fn(&str) -> bool) -> bool { + if has_env("SSH_CONNECTION") || has_env("SSH_TTY") { + return true; + } + if cfg!(all(unix, not(target_os = "macos"))) { + return !has_env("DISPLAY") && !has_env("WAYLAND_DISPLAY"); + } + false +} + fn workspace_roots(from: Option<&Path>, file: Option<&Path>) -> Result> { let location = resolve_source_location(from, file, None)?; match location { @@ -102,6 +131,37 @@ mod tests { "[package]\nname = \"test-project\"\n" } + fn env_with(present: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |key| present.contains(&key) + } + + #[test] + fn ssh_connection_implies_headless() { + assert!(is_headless_session(env_with(&[ + "SSH_CONNECTION", + "DISPLAY" + ]))); + } + + #[test] + fn ssh_tty_implies_headless() { + assert!(is_headless_session(env_with(&["SSH_TTY", "DISPLAY"]))); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn display_present_is_not_headless() { + assert!(!is_headless_session(env_with(&["DISPLAY"]))); + assert!(!is_headless_session(env_with(&["WAYLAND_DISPLAY"]))); + assert!(is_headless_session(env_with(&[]))); + } + + #[cfg(target_os = "macos")] + #[test] + fn no_signals_is_not_headless_on_macos() { + assert!(!is_headless_session(env_with(&[]))); + } + #[test] fn project_mode_errors_without_project_marker() { let tmp = TempDir::new().unwrap(); diff --git a/baml_language/crates/baml_lsp_server/src/lib.rs b/baml_language/crates/baml_lsp_server/src/lib.rs index d44028846a3..a304029b858 100644 --- a/baml_language/crates/baml_lsp_server/src/lib.rs +++ b/baml_language/crates/baml_lsp_server/src/lib.rs @@ -97,17 +97,42 @@ fn build_playground_sys_ops( /// 4. Starts the playground HTTP/WS server /// 5. Runs the stdio LSP event loop pub fn run_server(workspace_roots: Vec) -> anyhow::Result<()> { - run_server_inner(PlaygroundOpenTarget::LspClient, workspace_roots, None) + run_server_inner( + PlaygroundOpenTarget::LspClient, + workspace_roots, + None, + PlaygroundServerOptions::default(), + ) +} + +/// Options for `run_playground_server` (browser mode only; LSP mode ignores them). +#[derive(Debug, Clone)] +pub struct PlaygroundServerOptions { + /// Bind exactly this port; error if unavailable. `None` = scan from 3700. + pub port: Option, + /// Open the local browser once the server is up. + pub open_browser: bool, +} + +impl Default for PlaygroundServerOptions { + fn default() -> Self { + Self { + port: None, + open_browser: true, + } + } } pub fn run_playground_server( workspace_roots: Vec, playground_dir_override: Option, + options: PlaygroundServerOptions, ) -> anyhow::Result<()> { run_server_inner( PlaygroundOpenTarget::Browser, workspace_roots, playground_dir_override, + options, ) } @@ -121,6 +146,7 @@ fn run_server_inner( playground_open_target: PlaygroundOpenTarget, workspace_roots: Vec, playground_dir_override: Option, + options: PlaygroundServerOptions, ) -> anyhow::Result<()> { let workspace_roots = absolutize_workspace_roots(workspace_roots)?; @@ -209,14 +235,28 @@ fn run_server_inner( Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); // Pick the playground port early so we can pass it to the sender. - let (playground_listener, playground_port): (Option, u16) = - match tokio_runtime.block_on(playground_server::pick_port(3700, 100)) { + let (playground_listener, playground_port): (Option, u16) = { + let bind_result = match options.port { + Some(port) => tokio_runtime + .block_on(playground_server::bind_exact_port(port)) + .map(|listener| (listener, port)), + None => tokio_runtime.block_on(playground_server::pick_port(3700, 100)), + }; + match bind_result { Ok((listener, port)) => (Some(listener), port), Err(e) => { + if matches!(playground_open_target, PlaygroundOpenTarget::Browser) { + return Err(e); // browser mode is useless without the server + } tracing::error!("Could not find playground port: {e}"); - (None, 0) + (None, 0) // LSP mode continues serving stdio } - }; + } + }; + + if matches!(playground_open_target, PlaygroundOpenTarget::Browser) { + print_playground_banner(playground_port, &workspace_roots); + } // Playground sender (needs port + lsp_sender for OpenPlayground) let playground_sender: Arc = @@ -255,14 +295,16 @@ fn run_server_inner( if matches!(playground_open_target, PlaygroundOpenTarget::Browser) && playground_port != 0 { if let Some(project) = explicit_projects.first() { - playground_sender.send_playground_notification( - bex_project::PlaygroundNotification::OpenPlayground { - project: project.clone(), - function_name: None, - test_name: None, - testset_name: None, - }, - ); + if options.open_browser { + playground_sender.send_playground_notification( + bex_project::PlaygroundNotification::OpenPlayground { + project: project.clone(), + function_name: None, + test_name: None, + testset_name: None, + }, + ); + } } else if has_explicit_workspace_roots { tracing::warn!("No BAML projects discovered for explicit workspace roots"); } @@ -399,6 +441,23 @@ fn run_server_inner( Ok(()) } +#[allow(clippy::print_stdout)] // user-facing banner; browser mode has no stdio LSP client +fn print_playground_banner(port: u16, roots: &[PathBuf]) { + println!("{}", format_playground_banner(port, roots)); +} + +fn format_playground_banner(port: u16, roots: &[PathBuf]) -> String { + let root = roots + .first() + .map(|r| r.display().to_string()) + .unwrap_or_else(|| "(no workspace roots)".to_string()); + format!( + "\n Playground: http://localhost:{port}/\n Project: {root}\n\n \ + Remote machine? Forward the port, then open the URL locally:\n \ + ssh -L {port}:localhost:{port} \n\n Press Ctrl-C to stop.\n" + ) +} + fn absolutize_workspace_roots(workspace_roots: Vec) -> anyhow::Result> { if workspace_roots.iter().all(|root| root.is_absolute()) { return Ok(workspace_roots); @@ -545,6 +604,22 @@ fn file_signature(metadata: &fs::Metadata) -> FileSignature { mod tests { use super::*; + #[test] + fn playground_banner_shows_url_project_root_and_ssh_hint() { + let banner = format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")]); + assert!(banner.contains("http://localhost:4265/"), "{banner}"); + assert!(banner.contains("/home/dev/my-app"), "{banner}"); + assert!( + banner.contains("ssh -L 4265:localhost:4265 "), + "{banner}" + ); + assert!(banner.contains("Press Ctrl-C to stop."), "{banner}"); + + let no_roots = format_playground_banner(4270, &[]); + assert!(no_roots.contains("(no workspace roots)"), "{no_roots}"); + assert!(no_roots.contains("http://localhost:4270/"), "{no_roots}"); + } + #[test] fn absolutize_workspace_roots_makes_relative_paths_absolute() { let cwd = std::env::current_dir().expect("cwd should be available"); diff --git a/baml_language/crates/baml_lsp_server/src/playground_sender.rs b/baml_language/crates/baml_lsp_server/src/playground_sender.rs index 8b8ac9f7122..05422351040 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_sender.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_sender.rs @@ -48,9 +48,13 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender { { if self.open_in_browser { let url = format!("http://localhost:{}", self.playground_port); - if let Err(e) = webbrowser::open(&url) { - tracing::error!("Failed to open browser at {}: {}", url, e); - } + // `webbrowser::open` can block until a text-mode browser (lynx/w3m) + // exits on headless hosts; never hold up server startup on it. + std::thread::spawn(move || { + if let Err(e) = webbrowser::open(&url) { + tracing::error!("Failed to open browser at {}: {}", url, e); + } + }); } else { let params = serde_json::json!({ "port": self.playground_port, diff --git a/baml_language/crates/baml_lsp_server/src/playground_server.rs b/baml_language/crates/baml_lsp_server/src/playground_server.rs index 92ca2fa6474..8257ca24498 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_server.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_server.rs @@ -440,6 +440,17 @@ pub async fn pick_port(base_port: u16, max_attempts: u16) -> anyhow::Result<(Tcp ) } +/// Bind exactly `port` on loopback, with an actionable error when taken. +pub async fn bind_exact_port(port: u16) -> anyhow::Result { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + TcpListener::bind(addr).await.map_err(|e| { + anyhow::anyhow!( + "Could not bind playground port {port}: {e}. Another process may be \ + using it; pass a different --port or omit it to auto-pick from 3700." + ) + }) +} + // --------------------------------------------------------------------------- // Shared state for Axum handlers // --------------------------------------------------------------------------- @@ -2978,6 +2989,20 @@ mod tests { } } + #[tokio::test] + async fn bind_exact_port_reports_conflict_with_actionable_error() { + let (occupied, port) = pick_port(3700, 100).await.expect("a free port to occupy"); + + let err = bind_exact_port(port) + .await + .expect_err("second bind of the same port should fail"); + let message = format!("{err}"); + assert!(message.contains(&port.to_string()), "{message}"); + assert!(message.contains("--port"), "{message}"); + + drop(occupied); + } + #[test] fn lsp_save_path_must_stay_under_workspace_roots() { let root = unique_temp_dir("baml-lsp-save-root"); From cb56541e5085869d8d8ea13b73515a84c7ea152c Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Wed, 8 Jul 2026 11:33:06 -0700 Subject: [PATCH 3/6] feat(playground): standardize the default playground port on 4265 4265 ("BML") is memorable, unregistered, and below the OS ephemeral ranges; auto-scan covers 4265..4364. Moving off 3700 also separates the new server's scan range from the legacy engine/language_server (which deliberately stays on 3700), removing a wrong-instance collision mode. Doc-comment and test-literal references follow. BAML_PLAYGROUND_DEV_PORT examples (4000) are the Vite upstream target, not a listen port, and are unchanged, as is everything under fern/ and engine/. --- .../crates/baml_cli/src/playground_command.rs | 2 +- baml_language/crates/baml_lsp_server/src/lib.rs | 4 ++-- .../crates/baml_lsp_server/src/playground_server.rs | 4 ++-- .../src/panels/__tests__/getWebviewHtml.test.ts | 12 ++++++------ typescript2/pkg-editor/src/remote-backend.ts | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/baml_language/crates/baml_cli/src/playground_command.rs b/baml_language/crates/baml_cli/src/playground_command.rs index b7608fb1f4b..3313baf5f5f 100644 --- a/baml_language/crates/baml_cli/src/playground_command.rs +++ b/baml_language/crates/baml_cli/src/playground_command.rs @@ -16,7 +16,7 @@ pub struct PlaygroundArgs { pub from: Option, /// Listen on exactly this port (errors if unavailable). - /// Default: the first free port from 3700. + /// Default: the first free port from 4265. #[arg(long, value_name = "PORT")] pub port: Option, diff --git a/baml_language/crates/baml_lsp_server/src/lib.rs b/baml_language/crates/baml_lsp_server/src/lib.rs index a304029b858..4ce7d154dec 100644 --- a/baml_language/crates/baml_lsp_server/src/lib.rs +++ b/baml_language/crates/baml_lsp_server/src/lib.rs @@ -108,7 +108,7 @@ pub fn run_server(workspace_roots: Vec) -> anyhow::Result<()> { /// Options for `run_playground_server` (browser mode only; LSP mode ignores them). #[derive(Debug, Clone)] pub struct PlaygroundServerOptions { - /// Bind exactly this port; error if unavailable. `None` = scan from 3700. + /// Bind exactly this port; error if unavailable. `None` = scan from 4265. pub port: Option, /// Open the local browser once the server is up. pub open_browser: bool, @@ -240,7 +240,7 @@ fn run_server_inner( Some(port) => tokio_runtime .block_on(playground_server::bind_exact_port(port)) .map(|listener| (listener, port)), - None => tokio_runtime.block_on(playground_server::pick_port(3700, 100)), + None => tokio_runtime.block_on(playground_server::pick_port(4265, 100)), }; match bind_result { Ok((listener, port)) => (Some(listener), port), diff --git a/baml_language/crates/baml_lsp_server/src/playground_server.rs b/baml_language/crates/baml_lsp_server/src/playground_server.rs index 8257ca24498..c887b6cd18e 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_server.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_server.rs @@ -446,7 +446,7 @@ pub async fn bind_exact_port(port: u16) -> anyhow::Result { TcpListener::bind(addr).await.map_err(|e| { anyhow::anyhow!( "Could not bind playground port {port}: {e}. Another process may be \ - using it; pass a different --port or omit it to auto-pick from 3700." + using it; pass a different --port or omit it to auto-pick from 4265." ) }) } @@ -2991,7 +2991,7 @@ mod tests { #[tokio::test] async fn bind_exact_port_reports_conflict_with_actionable_error() { - let (occupied, port) = pick_port(3700, 100).await.expect("a free port to occupy"); + let (occupied, port) = pick_port(4265, 100).await.expect("a free port to occupy"); let err = bind_exact_port(port) .await diff --git a/typescript2/app-vscode-ext/src/panels/__tests__/getWebviewHtml.test.ts b/typescript2/app-vscode-ext/src/panels/__tests__/getWebviewHtml.test.ts index c5cd64f7761..56a669b4883 100644 --- a/typescript2/app-vscode-ext/src/panels/__tests__/getWebviewHtml.test.ts +++ b/typescript2/app-vscode-ext/src/panels/__tests__/getWebviewHtml.test.ts @@ -55,20 +55,20 @@ beforeEach(() => { describe('getPlaygroundHtml', () => { it('reads the packaged playground HTML', async () => { - await getPlaygroundHtml(webview, extensionUri, 3700); + await getPlaygroundHtml(webview, extensionUri, 4265); expect(fs.readFile).toHaveBeenCalledWith('/extension/dist/playground/index.html', 'utf8'); }); it('injects the WS URL global', async () => { - const html = await getPlaygroundHtml(webview, extensionUri, 3700); + const html = await getPlaygroundHtml(webview, extensionUri, 4265); expect(html).toContain('__PLAYGROUND_WS_URL'); - expect(html).toContain('ws://localhost:3700/api/ws'); + expect(html).toContain('ws://localhost:4265/api/ws'); }); it('injects a CSP allowing packaged scripts and localhost websocket traffic', async () => { - const html = await getPlaygroundHtml(webview, extensionUri, 3700); + const html = await getPlaygroundHtml(webview, extensionUri, 4265); expect(html).toContain('script-src vscode-webview://test'); expect(html).toContain('connect-src ws://localhost:*'); @@ -76,13 +76,13 @@ describe('getPlaygroundHtml', () => { }); it('does not contain an iframe', async () => { - const html = await getPlaygroundHtml(webview, extensionUri, 3700); + const html = await getPlaygroundHtml(webview, extensionUri, 4265); expect(html).not.toContain(' { - const html = await getPlaygroundHtml(webview, extensionUri, 3700); + const html = await getPlaygroundHtml(webview, extensionUri, 4265); expect(html).toContain('
'); expect(html).toContain('src="vscode-webview://test/assets/index.js"'); diff --git a/typescript2/pkg-editor/src/remote-backend.ts b/typescript2/pkg-editor/src/remote-backend.ts index e8771df0f71..1f62602c495 100644 --- a/typescript2/pkg-editor/src/remote-backend.ts +++ b/typescript2/pkg-editor/src/remote-backend.ts @@ -17,9 +17,9 @@ import { WebSocketRuntimePort } from '@b/pkg-playground'; import type { EditorBackend, EditorConnection, WorkbenchHandle } from './backend'; export interface RemoteBackendOptions { - /** WebSocket URL for the LSP JSON-RPC endpoint, e.g. `ws://localhost:3700/api/lsp`. */ + /** WebSocket URL for the LSP JSON-RPC endpoint, e.g. `ws://localhost:4265/api/lsp`. */ lspUrl: string; - /** WebSocket URL for the playground runtime endpoint, e.g. `ws://localhost:3700/api/ws`. */ + /** WebSocket URL for the playground runtime endpoint, e.g. `ws://localhost:4265/api/ws`. */ runtimeUrl: string; /** Window-indicator label shown in the workbench title bar. */ windowLabel?: string; From d76e5659bfa85d52e6dc76c064193516f8bfbc2e Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Wed, 8 Jul 2026 11:33:34 -0700 Subject: [PATCH 4/6] docs(cli): add a describe entry for baml playground Data-only YAML topic following the `check` precedent (topic name == subcommand name); covers --from/--file/--port/--no-open, the 4265..4364 scan, headless auto-suppression, and the ssh -L recipe. Snapshot via cargo insta accept, plus the keyword-dispatch regression assertion. --- .../keyword_docs/baml_keywords.yaml | 21 ++++++++++++++++ .../baml_cli/src/describe_command_tests.rs | 18 ++++++++++++-- ...mand_tests__render_keyword_playground.snap | 24 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap diff --git a/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml b/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml index f4b135d3159..9e86ff85346 100644 --- a/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml +++ b/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml @@ -682,6 +682,27 @@ check: baml run --list --from . details: "`baml check` compiles the project and reports compiler errors without running anything — the most direct way to validate. Other commands (`baml run`, `baml run --list`, `baml fmt`, `baml test`) also load and check the project before their main work. Use `baml run --list` when you additionally want the compiled function signatures." +playground: + summary: "Serves the BAML playground web UI for a project and opens it in a browser." + syntax: | + baml playground # current project, first free port from 4265 + baml playground --from ./my-app # explicit project directory + baml playground --file ./one.baml # standalone single file + baml playground --port 4265 # pin the port (errors if taken) + baml playground --no-open # don't launch a browser (automatic in SSH/headless sessions) + details: | + `baml playground` starts a loopback-only web server hosting the playground UI + and an LSP backend for the loaded project. It binds the first free port from + 4265 (scanning up to 4364) unless `--port` pins one, prints the URL to stdout, + and opens a browser unless `--no-open` is set or the session is headless + (SSH, or no display on Linux). + + On a remote machine, forward the port and open the printed URL locally: + + ssh -L 4265:localhost:4265 user@host + + See also: `baml describe check`. + spawn: summary: "Launches a concurrent task that runs in the background." syntax: | diff --git a/baml_language/crates/baml_cli/src/describe_command_tests.rs b/baml_language/crates/baml_cli/src/describe_command_tests.rs index 7e70eae0484..f2589ae2e04 100644 --- a/baml_language/crates/baml_cli/src/describe_command_tests.rs +++ b/baml_language/crates/baml_cli/src/describe_command_tests.rs @@ -1093,6 +1093,12 @@ fn render_keyword_cleanup() { insta::assert_snapshot!(output); } +#[test] +fn render_keyword_playground() { + let output = capture_keyword("playground"); + insta::assert_snapshot!(output); +} + #[test] fn render_keyword_baml_sdk() { let output = capture_keyword("baml_sdk"); @@ -1132,9 +1138,17 @@ fn dispatch_defer_and_cleanup_resolve_to_keyword() { #[test] fn dispatch_language_topic_resolves_to_keyword() { - // Language/SDK + pattern topics route to keyword docs, not package resolution. + // Language/SDK + pattern topics and CLI-command topics route to keyword + // docs, not package resolution. let db = simple_project(); - for name in ["python", "typescript", "baml_sdk", "patterns", "pattern"] { + for name in [ + "python", + "typescript", + "baml_sdk", + "patterns", + "pattern", + "playground", + ] { assert!( matches!(dispatch(&db, name), Some(ResolvedTarget::Keyword(_))), "`{name}` should resolve to a keyword topic" diff --git a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap new file mode 100644 index 00000000000..3dd6a727900 --- /dev/null +++ b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap @@ -0,0 +1,24 @@ +--- +source: crates/baml_cli/src/describe_command_tests.rs +expression: output +--- +playground — Serves the BAML playground web UI for a project and opens it in a browser. + +Syntax: + baml playground # current project, first free port from 4265 + baml playground --from ./my-app # explicit project directory + baml playground --file ./one.baml # standalone single file + baml playground --port 4265 # pin the port (errors if taken) + baml playground --no-open # don't launch a browser (automatic in SSH/headless sessions) + +`baml playground` starts a loopback-only web server hosting the playground UI +and an LSP backend for the loaded project. It binds the first free port from +4265 (scanning up to 4364) unless `--port` pins one, prints the URL to stdout, +and opens a browser unless `--no-open` is set or the session is headless +(SSH, or no display on Linux). + +On a remote machine, forward the port and open the printed URL locally: + + ssh -L 4265:localhost:4265 user@host + +See also: `baml describe check`. From 29c9e902030cd2d37ce063af5159e6b640057d2d Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Wed, 8 Jul 2026 12:52:55 -0700 Subject: [PATCH 5/6] feat(playground): require a per-session token on /api in browser mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `baml playground` mints a uuid-v4 session token, prints it on the banner URL (?token=...), and the guard middleware rejects /api requests without it (401 with an actionable message). The check runs after the OPTIONS branch so CORS preflights stay unauthenticated; the origin check is unchanged and still wins. This closes the Origin-less exfiltration hole: any local process could previously open /api/ws and receive the env dump. LSP/editor mode mints no token — guard behavior there is byte-for-byte today's, and the VS Code extension is untouched. Frontend: a `withPlaygroundToken` helper in pkg-editor carries the page URL's token onto both WS URLs and the source-files fetch (browsers can't set WS headers, so it rides the query string). The VS Code-injected WS URL is left alone. `WebSocketRuntimePort` now exposes a connection status, and the standalone app shows a thin banner when the socket is stuck retrying — the new user-error path (opening the bare URL without the token) previously rendered a fully dead UI with no explanation. The app's vite config aliases @b/pkg-editor to its source dir, which bypasses the package `exports` map — the new `/token` subpath therefore gets an explicit alias entry so the helper can be imported without pulling the Monaco re-exports into the main bundle. --- baml_language/Cargo.lock | 1 + .../keyword_docs/baml_keywords.yaml | 4 + ...mand_tests__render_keyword_playground.snap | 4 + .../crates/baml_lsp_server/Cargo.toml | 1 + .../crates/baml_lsp_server/src/lib.rs | 45 ++++++++-- .../baml_lsp_server/src/playground_sender.rs | 12 ++- .../baml_lsp_server/src/playground_server.rs | 83 +++++++++++++++++-- typescript2/app-vscode-webview/src/App.tsx | 29 ++++++- .../src/RemoteEditorView.tsx | 11 ++- typescript2/app-vscode-webview/vite.config.ts | 4 + typescript2/pkg-editor/package.json | 4 + typescript2/pkg-editor/src/index.ts | 3 + .../pkg-editor/src/playground-token.ts | 21 +++++ typescript2/pkg-editor/src/remote-backend.ts | 5 +- typescript2/pkg-playground/src/index.ts | 1 + .../src/ports/WebSocketRuntimePort.ts | 32 +++++++ 16 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 typescript2/pkg-editor/src/playground-token.ts diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 7af41d61f4d..f6336e8e2d8 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -1048,6 +1048,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", "vfs", "webbrowser", ] diff --git a/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml b/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml index 9e86ff85346..daed2af6575 100644 --- a/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml +++ b/baml_language/crates/baml_builtins2/keyword_docs/baml_keywords.yaml @@ -701,6 +701,10 @@ playground: ssh -L 4265:localhost:4265 user@host + The printed URL carries a `?token=...` query parameter that authorizes the + session — open it verbatim and treat it as a per-session secret. API requests + without the token are rejected. + See also: `baml describe check`. spawn: diff --git a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap index 3dd6a727900..b135d379c46 100644 --- a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap +++ b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_keyword_playground.snap @@ -21,4 +21,8 @@ On a remote machine, forward the port and open the printed URL locally: ssh -L 4265:localhost:4265 user@host +The printed URL carries a `?token=...` query parameter that authorizes the +session — open it verbatim and treat it as a per-session secret. API requests +without the token are rejected. + See also: `baml describe check`. diff --git a/baml_language/crates/baml_lsp_server/Cargo.toml b/baml_language/crates/baml_lsp_server/Cargo.toml index 88948d8efd9..49356935b55 100644 --- a/baml_language/crates/baml_lsp_server/Cargo.toml +++ b/baml_language/crates/baml_lsp_server/Cargo.toml @@ -59,6 +59,7 @@ tokio-tungstenite = { workspace = true } tower-http = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +uuid = { workspace = true } vfs = { workspace = true } webbrowser = { workspace = true } diff --git a/baml_language/crates/baml_lsp_server/src/lib.rs b/baml_language/crates/baml_lsp_server/src/lib.rs index 4ce7d154dec..a318b93da65 100644 --- a/baml_language/crates/baml_lsp_server/src/lib.rs +++ b/baml_language/crates/baml_lsp_server/src/lib.rs @@ -234,6 +234,12 @@ fn run_server_inner( let doc_mirror: playground_server::DocMirror = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); + // Per-session bearer token for /api, browser mode only. In LSP/editor + // mode no token is minted and the guard behaves exactly as before (D6). + let session_token: Option> = + matches!(playground_open_target, PlaygroundOpenTarget::Browser) + .then(|| uuid::Uuid::new_v4().simple().to_string().into()); + // Pick the playground port early so we can pass it to the sender. let (playground_listener, playground_port): (Option, u16) = { let bind_result = match options.port { @@ -255,7 +261,7 @@ fn run_server_inner( }; if matches!(playground_open_target, PlaygroundOpenTarget::Browser) { - print_playground_banner(playground_port, &workspace_roots); + print_playground_banner(playground_port, &workspace_roots, session_token.as_deref()); } // Playground sender (needs port + lsp_sender for OpenPlayground) @@ -265,6 +271,7 @@ fn run_server_inner( lsp_sender.clone(), playground_port, matches!(playground_open_target, PlaygroundOpenTarget::Browser), + session_token.clone(), )); // Create the BexLsp (multi-project LSP) @@ -355,6 +362,7 @@ fn run_server_inner( lsp_out_tx, doc_mirror, workspace_roots.clone(), + session_token, )); } @@ -371,6 +379,7 @@ fn run_server_inner( lsp_out_tx, doc_mirror, workspace_roots_for_server, + session_token, ) .await { @@ -442,17 +451,22 @@ fn run_server_inner( } #[allow(clippy::print_stdout)] // user-facing banner; browser mode has no stdio LSP client -fn print_playground_banner(port: u16, roots: &[PathBuf]) { - println!("{}", format_playground_banner(port, roots)); +fn print_playground_banner(port: u16, roots: &[PathBuf], token: Option<&str>) { + println!("{}", format_playground_banner(port, roots, token)); } -fn format_playground_banner(port: u16, roots: &[PathBuf]) -> String { +fn format_playground_banner(port: u16, roots: &[PathBuf], token: Option<&str>) -> String { let root = roots .first() .map(|r| r.display().to_string()) .unwrap_or_else(|| "(no workspace roots)".to_string()); + // The token rides the URL, not the tunnel, so the ssh hint doesn't carry it. + let url = match token { + Some(token) => format!("http://localhost:{port}/?token={token}"), + None => format!("http://localhost:{port}/"), + }; format!( - "\n Playground: http://localhost:{port}/\n Project: {root}\n\n \ + "\n Playground: {url}\n Project: {root}\n\n \ Remote machine? Forward the port, then open the URL locally:\n \ ssh -L {port}:localhost:{port} \n\n Press Ctrl-C to stop.\n" ) @@ -606,8 +620,9 @@ mod tests { #[test] fn playground_banner_shows_url_project_root_and_ssh_hint() { - let banner = format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")]); + let banner = format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")], None); assert!(banner.contains("http://localhost:4265/"), "{banner}"); + assert!(!banner.contains("?token="), "{banner}"); assert!(banner.contains("/home/dev/my-app"), "{banner}"); assert!( banner.contains("ssh -L 4265:localhost:4265 "), @@ -615,11 +630,27 @@ mod tests { ); assert!(banner.contains("Press Ctrl-C to stop."), "{banner}"); - let no_roots = format_playground_banner(4270, &[]); + let no_roots = format_playground_banner(4270, &[], None); assert!(no_roots.contains("(no workspace roots)"), "{no_roots}"); assert!(no_roots.contains("http://localhost:4270/"), "{no_roots}"); } + #[test] + fn playground_banner_carries_the_session_token_on_the_url_only() { + let banner = + format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")], Some("abc123")); + assert!( + banner.contains("http://localhost:4265/?token=abc123"), + "{banner}" + ); + // The token rides the URL, not the tunnel: the ssh hint stays bare. + assert!( + banner.contains("ssh -L 4265:localhost:4265 "), + "{banner}" + ); + assert_eq!(banner.matches("token").count(), 1, "{banner}"); + } + #[test] fn absolutize_workspace_roots_makes_relative_paths_absolute() { let cwd = std::env::current_dir().expect("cwd should be available"); diff --git a/baml_language/crates/baml_lsp_server/src/playground_sender.rs b/baml_language/crates/baml_lsp_server/src/playground_sender.rs index 05422351040..c3148c21e26 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_sender.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_sender.rs @@ -19,6 +19,9 @@ pub struct NativePlaygroundSender { lsp_sender: Arc, playground_port: u16, open_in_browser: bool, + /// Browser-mode session token; carried on the opened URL so the page can + /// authorize its /api requests. + session_token: Option>, } impl NativePlaygroundSender { @@ -27,12 +30,14 @@ impl NativePlaygroundSender { lsp_sender: Arc, playground_port: u16, open_in_browser: bool, + session_token: Option>, ) -> Self { Self { broadcast_tx, lsp_sender, playground_port, open_in_browser, + session_token, } } } @@ -47,7 +52,12 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender { } = notification { if self.open_in_browser { - let url = format!("http://localhost:{}", self.playground_port); + let url = match &self.session_token { + Some(token) => { + format!("http://localhost:{}/?token={token}", self.playground_port) + } + None => format!("http://localhost:{}", self.playground_port), + }; // `webbrowser::open` can block until a text-mode browser (lynx/w3m) // exits on headless hosts; never hold up server startup on it. std::thread::spawn(move || { diff --git a/baml_language/crates/baml_lsp_server/src/playground_server.rs b/baml_language/crates/baml_lsp_server/src/playground_server.rs index c887b6cd18e..75a376e9431 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_server.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_server.rs @@ -816,11 +816,26 @@ fn capture_loss_message(capture_kind: &str, skipped: u64) -> String { } #[derive(Clone, Debug)] -struct PlaygroundAccessGuard {} +struct PlaygroundAccessGuard { + /// When set (browser mode), every /api request must present it as `?token=`. + session_token: Option>, +} impl PlaygroundAccessGuard { - fn new() -> Self { - Self {} + fn new(session_token: Option>) -> Self { + Self { session_token } + } + + /// True when the request may use the API: either no session token is + /// required (LSP/editor mode — origin-only, as before the token existed) + /// or the request's `?token=` matches. Covers Origin-less clients too + /// (curl/websocat), which the origin check alone deliberately allows. + fn is_authorized(&self, uri: &Uri) -> bool { + let Some(expected) = &self.session_token else { + return true; + }; + query_param(uri.query(), "token") + .is_some_and(|got| constant_time_eq(got.as_bytes(), expected.as_bytes())) } fn is_allowed_origin(&self, origin: Option<&HeaderValue>) -> bool { @@ -843,6 +858,21 @@ impl PlaygroundAccessGuard { } } +/// Extract a raw query parameter value (no percent-decoding — the session +/// token is plain `[0-9a-f]{32}`, so none is needed). +fn query_param<'a>(query: Option<&'a str>, key: &str) -> Option<&'a str> { + query?.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=')?; + (k == key).then_some(v) + }) +} + +/// Length-safe constant-time comparison; the token is low-value enough that a +/// dependency like `subtle` isn't warranted for a loopback timing channel. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + /// True when `origin` is an http(s) origin whose host is loopback /// (`localhost`, a 127.0.0.0/8 address, or `[::1]`), on any port. /// @@ -926,9 +956,10 @@ pub async fn run( lsp_out_tx: broadcast::Sender, doc_mirror: DocMirror, workspace_roots: Vec, + session_token: Option>, ) -> anyhow::Result<()> { let local_addr = listener.local_addr()?; - let access_guard = PlaygroundAccessGuard::new(); + let access_guard = PlaygroundAccessGuard::new(session_token); let app = build_router( bex, broadcast_tx, @@ -2400,6 +2431,16 @@ async fn api_guard_middleware( return response; } + // After the OPTIONS branch: CORS preflights carry no credentials by design. + if !access_guard.is_authorized(req.uri()) { + return text_response( + StatusCode::UNAUTHORIZED, + "Missing or invalid playground session token. Open the exact URL \ + (including ?token=...) printed by `baml playground`." + .to_string(), + ); + } + let mut resp = next.run(req).await; apply_api_response_headers(&access_guard, origin.as_ref(), &mut resp); resp @@ -2958,7 +2999,7 @@ mod tests { #[test] fn playground_access_guard_accepts_any_loopback_port_and_vscode_origins() { - let guard = PlaygroundAccessGuard::new(); + let guard = PlaygroundAccessGuard::new(None); assert!(guard.is_allowed_origin(None)); for allowed in [ "http://localhost:4265", @@ -2989,6 +3030,38 @@ mod tests { } } + #[test] + fn session_token_required_when_set() { + let token = "a3f9c2e8b1d04567a3f9c2e8b1d04567"; + let guard = PlaygroundAccessGuard::new(Some(token.into())); + let uri = |s: &str| s.parse::().unwrap(); + + assert!(guard.is_authorized(&uri(&format!("/api/ws?token={token}")))); + assert!(guard.is_authorized(&uri(&format!("/api/source-files?project=x&token={token}")))); + assert!(!guard.is_authorized(&uri("/api/ws"))); + assert!(!guard.is_authorized(&uri("/api/ws?token=wrong"))); + assert!(!guard.is_authorized(&uri("/api/ws?token="))); + assert!(!guard.is_authorized(&uri("/api/ws?project=x"))); + + // No token minted (LSP/editor mode): every URI is authorized. + let open_guard = PlaygroundAccessGuard::new(None); + assert!(open_guard.is_authorized(&uri("/api/ws"))); + assert!(open_guard.is_authorized(&uri("/api/ws?token=whatever"))); + } + + #[test] + fn query_param_extracts_token() { + assert_eq!(query_param(Some("token=abc"), "token"), Some("abc")); + assert_eq!(query_param(Some("token=abc&x=1"), "token"), Some("abc")); + assert_eq!( + query_param(Some("project=p&token=abc"), "token"), + Some("abc") + ); + assert_eq!(query_param(Some("project=p"), "token"), None); + assert_eq!(query_param(Some("token"), "token"), None); + assert_eq!(query_param(None, "token"), None); + } + #[tokio::test] async fn bind_exact_port_reports_conflict_with_actionable_error() { let (occupied, port) = pick_port(4265, 100).await.expect("a free port to occupy"); diff --git a/typescript2/app-vscode-webview/src/App.tsx b/typescript2/app-vscode-webview/src/App.tsx index b1ab9da4413..9695fe6be71 100644 --- a/typescript2/app-vscode-webview/src/App.tsx +++ b/typescript2/app-vscode-webview/src/App.tsx @@ -1,5 +1,13 @@ import { useState, useEffect, useRef, lazy, Suspense } from 'react'; -import { ExecutionPanel, WebSocketRuntimePort, type SourceNavigationTarget } from '@b/pkg-playground'; +import { + ExecutionPanel, + WebSocketRuntimePort, + type WebSocketRuntimePortStatus, + type SourceNavigationTarget, +} from '@b/pkg-playground'; +// Subpath import: the package index re-exports MonacoEditor, which must stay +// out of this bundle (see the lazy() note below). +import { withPlaygroundToken } from '@b/pkg-editor/token'; // Monaco workbench is heavy (monaco-vscode-api) and only needed when the user // opens the editor view; lazy-load it so the plain playground (and the VS Code @@ -54,6 +62,8 @@ function getVsCodeApi() { const App: React.FC = () => { const [port, setPort] = useState(null); + const [connectionStatus, setConnectionStatus] = + useState('connecting'); const [activeProject, setActiveProject] = useState(null); const [showEditor, setShowEditor] = useState(false); const [editorHasUnsaved, setEditorHasUnsaved] = useState(false); @@ -104,15 +114,22 @@ const App: React.FC = () => { useEffect(() => { // When loaded directly in a VS Code webview (no iframe), the extension - // injects __PLAYGROUND_WS_URL. Fall back to location-based URL for + // injects __PLAYGROUND_WS_URL (no session token in that mode). Fall back + // to a location-based URL — carrying the page's ?token= — for // standalone / iframe / dev scenarios. const wsUrl = - window.__PLAYGROUND_WS_URL ?? `ws://${window.location.host}/api/ws`; + window.__PLAYGROUND_WS_URL ?? + withPlaygroundToken(`ws://${window.location.host}/api/ws`); const runtimePort = new WebSocketRuntimePort(wsUrl); setPort(runtimePort); return () => runtimePort.dispose(); }, []); + useEffect(() => { + if (!port) return; + return port.onStatusChange(setConnectionStatus); + }, [port]); + // Track the active project from playground notifications so the editor view // can be rooted at (and load source files for) the real project. useEffect(() => { @@ -210,6 +227,12 @@ const App: React.FC = () => { return (
+ {connectionStatus === 'retrying' && ( +
+ Can't reach the playground server — open the exact URL printed by{' '} + baml playground, including the token. +
+ )} {(inVsCode || canShowEditor) && (
{editorActive && ( diff --git a/typescript2/app-vscode-webview/src/RemoteEditorView.tsx b/typescript2/app-vscode-webview/src/RemoteEditorView.tsx index 2c51df6c411..7e0f1f3c440 100644 --- a/typescript2/app-vscode-webview/src/RemoteEditorView.tsx +++ b/typescript2/app-vscode-webview/src/RemoteEditorView.tsx @@ -17,7 +17,12 @@ */ import { useEffect, useMemo, useState } from 'react'; -import { MonacoEditor, createRemoteBackend, remoteUrlsFromLocation } from '@b/pkg-editor'; +import { + MonacoEditor, + createRemoteBackend, + remoteUrlsFromLocation, + withPlaygroundToken, +} from '@b/pkg-editor'; import '@b/pkg-editor/views-workbench.css'; interface SourceFile { @@ -54,7 +59,9 @@ const RemoteEditorView: React.FC = ({ project, onUnsavedC setError(null); void (async () => { try { - const res = await fetch(`/api/source-files?project=${encodeURIComponent(project)}`); + const res = await fetch( + withPlaygroundToken(`/api/source-files?project=${encodeURIComponent(project)}`), + ); if (!res.ok) throw new Error(`Failed to load source files (${res.status})`); const data = (await res.json()) as SourceFilesResponse; if (cancelled) return; diff --git a/typescript2/app-vscode-webview/vite.config.ts b/typescript2/app-vscode-webview/vite.config.ts index 10deefd7851..dbc66da01a0 100644 --- a/typescript2/app-vscode-webview/vite.config.ts +++ b/typescript2/app-vscode-webview/vite.config.ts @@ -83,6 +83,10 @@ export default defineConfig({ // path so every importer shares a single instance. react: resolve(projectRoot, 'node_modules/react'), 'react-dom': resolve(projectRoot, 'node_modules/react-dom'), + // Must precede the bare '@b/pkg-editor' alias: source-dir aliasing + // bypasses the package's `exports` map, so subpaths need explicit + // entries (with extension — alias results skip extension resolution). + '@b/pkg-editor/token': resolve(projectRoot, '../pkg-editor/src/playground-token.ts'), '@b/pkg-editor': resolve(projectRoot, '../pkg-editor/src'), '@b/pkg-playground': resolve(projectRoot, '../pkg-playground/src'), '@b/pkg-proto': resolve(projectRoot, '../pkg-proto/src'), diff --git a/typescript2/pkg-editor/package.json b/typescript2/pkg-editor/package.json index a6c3d72bbec..1cd4c6e00ae 100644 --- a/typescript2/pkg-editor/package.json +++ b/typescript2/pkg-editor/package.json @@ -13,6 +13,10 @@ "types": "./src/remote-backend.ts", "default": "./src/remote-backend.ts" }, + "./token": { + "types": "./src/playground-token.ts", + "default": "./src/playground-token.ts" + }, "./views-workbench.css": "./src/views-workbench.css" }, "files": [ diff --git a/typescript2/pkg-editor/src/index.ts b/typescript2/pkg-editor/src/index.ts index 8ce442318f2..6e49ebe329b 100644 --- a/typescript2/pkg-editor/src/index.ts +++ b/typescript2/pkg-editor/src/index.ts @@ -19,6 +19,9 @@ export { } from './remote-backend'; export type { RemoteBackendOptions } from './remote-backend'; +// Session token for `baml playground` /api access. +export { getPlaygroundToken, withPlaygroundToken } from './playground-token'; + // Media helpers (text vs data-URL files). export { isMediaPath, diff --git a/typescript2/pkg-editor/src/playground-token.ts b/typescript2/pkg-editor/src/playground-token.ts new file mode 100644 index 00000000000..f711a436d72 --- /dev/null +++ b/typescript2/pkg-editor/src/playground-token.ts @@ -0,0 +1,21 @@ +/** + * Per-session token minted by `baml playground` (browser mode) and carried on + * the page URL as `?token=...`. Every same-origin /api request — fetch and + * WebSocket alike — must present it back to the server; browsers can't set + * custom WS headers, so it rides the query string. + * + * In VS Code webviews (no token minted) `getPlaygroundToken` returns null and + * `withPlaygroundToken` is a no-op. + */ + +/** Session token from the current page URL, or null when absent. */ +export function getPlaygroundToken(): string | null { + return new URLSearchParams(window.location.search).get('token'); +} + +/** Append the session token (if any) to a same-origin API/WS URL. */ +export function withPlaygroundToken(url: string): string { + const token = getPlaygroundToken(); + if (!token) return url; + return `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`; +} diff --git a/typescript2/pkg-editor/src/remote-backend.ts b/typescript2/pkg-editor/src/remote-backend.ts index 1f62602c495..5eb86b76c77 100644 --- a/typescript2/pkg-editor/src/remote-backend.ts +++ b/typescript2/pkg-editor/src/remote-backend.ts @@ -15,6 +15,7 @@ import { WebSocketRuntimePort } from '@b/pkg-playground'; import type { EditorBackend, EditorConnection, WorkbenchHandle } from './backend'; +import { withPlaygroundToken } from './playground-token'; export interface RemoteBackendOptions { /** WebSocket URL for the LSP JSON-RPC endpoint, e.g. `ws://localhost:4265/api/lsp`. */ @@ -31,8 +32,8 @@ export function remoteUrlsFromLocation( ): { lspUrl: string; runtimeUrl: string } { const scheme = loc.protocol === 'https:' ? 'wss' : 'ws'; return { - lspUrl: `${scheme}://${loc.host}/api/lsp`, - runtimeUrl: `${scheme}://${loc.host}/api/ws`, + lspUrl: withPlaygroundToken(`${scheme}://${loc.host}/api/lsp`), + runtimeUrl: withPlaygroundToken(`${scheme}://${loc.host}/api/ws`), }; } diff --git a/typescript2/pkg-playground/src/index.ts b/typescript2/pkg-playground/src/index.ts index 86fbf93ebec..37e515953e5 100644 --- a/typescript2/pkg-playground/src/index.ts +++ b/typescript2/pkg-playground/src/index.ts @@ -6,6 +6,7 @@ export type { ExecutionPanelProps } from './ExecutionPanel'; export type { RuntimePort } from './runtime-port'; export { WorkerRuntimePort } from './ports/WorkerRuntimePort'; export { WebSocketRuntimePort } from './ports/WebSocketRuntimePort'; +export type { WebSocketRuntimePortStatus } from './ports/WebSocketRuntimePort'; export { createRunStoreClient } from './run-store-client'; export { applyRunPatch, createExecutionStore } from './execution-store'; export { decodeRunResultValue } from './run-store-projections'; diff --git a/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts b/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts index 6a831f630f4..7880967d8e0 100644 --- a/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts +++ b/typescript2/pkg-playground/src/ports/WebSocketRuntimePort.ts @@ -22,6 +22,15 @@ import { isPlaygroundProtocolCompatible } from '../protocol'; const MAX_RECONNECT_DELAY = 5000; +/** + * Connection lifecycle as observable from the browser: `connecting` until the + * first handshake resolves, `open` while connected, `retrying` once a + * handshake has failed or the socket closed and the backoff loop is running. + * (Browsers hide the HTTP status of a failed WS upgrade, so a 401 for a + * missing session token is indistinguishable from the server being down.) + */ +export type WebSocketRuntimePortStatus = 'connecting' | 'open' | 'retrying'; + export class WebSocketRuntimePort implements RuntimePort { private url: string; private ws: WebSocket | null = null; @@ -32,6 +41,8 @@ export class WebSocketRuntimePort implements RuntimePort { private reconnectDelay = 500; private reconnectTimer: ReturnType | null = null; private playgroundCompatible = true; + private status: WebSocketRuntimePortStatus = 'connecting'; + private statusHandlers = new Set<(status: WebSocketRuntimePortStatus) => void>(); constructor(url: string) { this.url = url; @@ -49,6 +60,7 @@ export class WebSocketRuntimePort implements RuntimePort { } this.ws.onopen = () => { + this.setStatus('open'); this.reconnectDelay = 500; // reset backoff // Flush queued outgoing messages. for (const msg of this.outQueue) { @@ -88,6 +100,7 @@ export class WebSocketRuntimePort implements RuntimePort { private scheduleReconnect(): void { if (this.disposed || this.reconnectTimer) return; + this.setStatus('retrying'); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); @@ -113,6 +126,24 @@ export class WebSocketRuntimePort implements RuntimePort { } } + private setStatus(status: WebSocketRuntimePortStatus): void { + if (this.status === status) return; + this.status = status; + for (const h of this.statusHandlers) h(status); + } + + /** + * Observe connection status changes. The handler is invoked immediately + * with the current status, then on every transition. Returns unsubscribe. + */ + onStatusChange(handler: (status: WebSocketRuntimePortStatus) => void): () => void { + this.statusHandlers.add(handler); + handler(this.status); + return () => { + this.statusHandlers.delete(handler); + }; + } + onMessage(handler: (msg: WorkerOutMessage) => void): () => void { this.handlers.add(handler); @@ -145,6 +176,7 @@ export class WebSocketRuntimePort implements RuntimePort { this.ws = null; } this.handlers.clear(); + this.statusHandlers.clear(); this.outQueue = []; this.inBuffer = []; } From e8e983cc0e462594e8a782626984785aa739374d Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Wed, 8 Jul 2026 12:53:47 -0700 Subject: [PATCH 6/6] fix(playground): send only project-referenced env vars on WS connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first WS frame used to carry the entire process environment. Now the push computes the project-referenced names (salsa-cached `all_env_var_names`) and sends only those that are set. Wire format is unchanged — `processEnvVars` with fewer entries — so no frontend changes; the env panel simply shows only relevant vars. Dynamically-computed keys still resolve lazily through the EnvVarRequest round-trip, which reads the server process env per key and is untouched. --- .../baml_lsp_server/src/playground_server.rs | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/baml_language/crates/baml_lsp_server/src/playground_server.rs b/baml_language/crates/baml_lsp_server/src/playground_server.rs index 75a376e9431..d2102bc8116 100644 --- a/baml_language/crates/baml_lsp_server/src/playground_server.rs +++ b/baml_language/crates/baml_lsp_server/src/playground_server.rs @@ -440,6 +440,18 @@ pub async fn pick_port(base_port: u16, max_attempts: u16) -> anyhow::Result<(Tcp ) } +/// Resolve the given env var names against `lookup`, keeping only those set. +/// Pure so tests never have to mutate the process environment. +fn collect_referenced_env_vars( + names: &[String], + lookup: impl Fn(&str) -> Option, +) -> std::collections::HashMap { + names + .iter() + .filter_map(|n| lookup(n).map(|v| (n.clone(), v))) + .collect() +} + /// Bind exactly `port` on loopback, with an actionable error when taken. pub async fn bind_exact_port(port: u16) -> anyhow::Result { let addr = SocketAddr::from(([127, 0, 0, 1], port)); @@ -1539,19 +1551,19 @@ async fn playground_ws_session(socket: WebSocket, state: WsState) { let mut broadcast_rx = state.broadcast_tx.subscribe(); - // Send all process env vars so the UI can display them immediately. + // Send the env vars the project actually references (names from BAML + // source, values from the server process env). The full process + // environment is deliberately NOT sent — it contains unrelated secrets. + // Dynamically-computed keys still resolve lazily via the EnvVarRequest + // round-trip (playground_env.rs). { - let vars: std::collections::HashMap = std::env::vars().collect(); + let names = state.bex.all_env_var_names(); + let vars = collect_referenced_env_vars(&names, |name| std::env::var(name).ok()); if let Some(msg) = to_ws_text(&WsOutMessage::ProcessEnvVars { vars }) && sink.send(msg).await.is_err() { return; } - } - - // Send env var names referenced in BAML source code. - { - let names = state.bex.all_env_var_names(); if let Some(msg) = to_ws_text(&WsOutMessage::KnownEnvVarNames { names }) && sink.send(msg).await.is_err() { @@ -3062,6 +3074,24 @@ mod tests { assert_eq!(query_param(None, "token"), None); } + #[test] + fn collect_referenced_env_vars_filters_unset() { + let names = vec![ + "OPENAI_API_KEY".to_string(), + "UNSET_VAR".to_string(), + "ANTHROPIC_API_KEY".to_string(), + ]; + let vars = collect_referenced_env_vars(&names, |name| match name { + "OPENAI_API_KEY" => Some("sk-1".to_string()), + "ANTHROPIC_API_KEY" => Some("sk-2".to_string()), + _ => None, + }); + assert_eq!(vars.len(), 2); + assert_eq!(vars["OPENAI_API_KEY"], "sk-1"); + assert_eq!(vars["ANTHROPIC_API_KEY"], "sk-2"); + assert!(!vars.contains_key("UNSET_VAR")); + } + #[tokio::test] async fn bind_exact_port_reports_conflict_with_actionable_error() { let (occupied, port) = pick_port(4265, 100).await.expect("a free port to occupy");