Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion baml_language/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -701,10 +701,6 @@ 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,4 @@ 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`.
1 change: 0 additions & 1 deletion baml_language/crates/baml_lsp_server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ tokio-tungstenite = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
vfs = { workspace = true }
webbrowser = { workspace = true }

Expand Down
45 changes: 7 additions & 38 deletions baml_language/crates/baml_lsp_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,6 @@ 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<Arc<str>> =
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<TcpListener>, u16) = {
let bind_result = match options.port {
Expand All @@ -261,7 +255,7 @@ fn run_server_inner(
};

if matches!(playground_open_target, PlaygroundOpenTarget::Browser) {
print_playground_banner(playground_port, &workspace_roots, session_token.as_deref());
print_playground_banner(playground_port, &workspace_roots);
}

// Tracks the target of the most recent OpenPlayground so browser-mode pages
Expand All @@ -277,7 +271,6 @@ fn run_server_inner(
lsp_sender.clone(),
playground_port,
matches!(playground_open_target, PlaygroundOpenTarget::Browser),
session_token.clone(),
current_open_target.clone(),
));

Expand Down Expand Up @@ -369,7 +362,6 @@ fn run_server_inner(
lsp_out_tx,
doc_mirror,
workspace_roots.clone(),
session_token,
current_open_target.clone(),
));
}
Expand All @@ -387,7 +379,6 @@ fn run_server_inner(
lsp_out_tx,
doc_mirror,
workspace_roots_for_server,
session_token,
current_open_target.clone(),
)
.await
Expand Down Expand Up @@ -460,22 +451,17 @@ 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], token: Option<&str>) {
println!("{}", format_playground_banner(port, roots, token));
fn print_playground_banner(port: u16, roots: &[PathBuf]) {
println!("{}", format_playground_banner(port, roots));
}

fn format_playground_banner(port: u16, roots: &[PathBuf], token: Option<&str>) -> String {
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());
// 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: {url}\n Project: {root}\n\n \
"\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} <user@host>\n\n Press Ctrl-C to stop.\n"
)
Expand Down Expand Up @@ -629,37 +615,20 @@ 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")], None);
let banner = format_playground_banner(4265, &[PathBuf::from("/home/dev/my-app")]);
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 <user@host>"),
"{banner}"
);
assert!(banner.contains("Press Ctrl-C to stop."), "{banner}");

let no_roots = format_playground_banner(4270, &[], None);
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 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 <user@host>"),
"{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");
Expand Down
13 changes: 1 addition & 12 deletions baml_language/crates/baml_lsp_server/src/playground_sender.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ pub struct NativePlaygroundSender {
lsp_sender: Arc<dyn bex_project::LspClientSenderTrait + Send + Sync>,
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<Arc<str>>,
/// Last requested open target, shared with the WS server for replay.
current_open_target: SharedOpenTarget,
/// When we last spawned a browser window, to debounce double-opens.
Expand All @@ -60,15 +57,13 @@ impl NativePlaygroundSender {
lsp_sender: Arc<dyn bex_project::LspClientSenderTrait + Send + Sync>,
playground_port: u16,
open_in_browser: bool,
session_token: Option<Arc<str>>,
current_open_target: SharedOpenTarget,
) -> Self {
Self {
broadcast_tx,
lsp_sender,
playground_port,
open_in_browser,
session_token,
current_open_target,
last_browser_open: Mutex::new(None),
}
Expand Down Expand Up @@ -121,12 +116,7 @@ impl bex_project::PlaygroundSender for NativePlaygroundSender {
// debounce avoids a second window while the first is still
// loading (before it connects and bumps `receiver_count`).
if self.broadcast_tx.receiver_count() == 0 && self.claim_browser_open() {
let url = match &self.session_token {
Some(token) => {
format!("http://localhost:{}/?token={token}", self.playground_port)
}
None => format!("http://localhost:{}", self.playground_port),
};
let url = format!("http://localhost:{}", self.playground_port);
// `webbrowser::open` can block until a text-mode browser
// (lynx/w3m) exits on headless hosts; never hold up the
// server on it.
Expand Down Expand Up @@ -201,7 +191,6 @@ mod tests {
Arc::new(NoopLspSender),
4265,
true, // browser mode
None,
target,
)
}
Expand Down
83 changes: 5 additions & 78 deletions baml_language/crates/baml_lsp_server/src/playground_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -831,26 +831,11 @@ fn capture_loss_message(capture_kind: &str, skipped: u64) -> String {
}

#[derive(Clone, Debug)]
struct PlaygroundAccessGuard {
/// When set (browser mode), every /api request must present it as `?token=`.
session_token: Option<Arc<str>>,
}
struct PlaygroundAccessGuard {}

impl PlaygroundAccessGuard {
fn new(session_token: Option<Arc<str>>) -> 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 new() -> Self {
Self {}
}

fn is_allowed_origin(&self, origin: Option<&HeaderValue>) -> bool {
Expand All @@ -873,21 +858,6 @@ 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.
///
Expand Down Expand Up @@ -971,11 +941,10 @@ pub async fn run(
lsp_out_tx: broadcast::Sender<lsp_server::Message>,
doc_mirror: DocMirror,
workspace_roots: Vec<PathBuf>,
session_token: Option<Arc<str>>,
current_open_target: crate::playground_sender::SharedOpenTarget,
) -> anyhow::Result<()> {
let local_addr = listener.local_addr()?;
let access_guard = PlaygroundAccessGuard::new(session_token);
let access_guard = PlaygroundAccessGuard::new();
let app = build_router(
bex,
broadcast_tx,
Expand Down Expand Up @@ -2469,16 +2438,6 @@ 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
Expand Down Expand Up @@ -3037,7 +2996,7 @@ mod tests {

#[test]
fn playground_access_guard_accepts_any_loopback_port_and_vscode_origins() {
let guard = PlaygroundAccessGuard::new(None);
let guard = PlaygroundAccessGuard::new();
assert!(guard.is_allowed_origin(None));
for allowed in [
"http://localhost:4265",
Expand Down Expand Up @@ -3068,38 +3027,6 @@ 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::<Uri>().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);
}

#[test]
fn collect_referenced_env_vars_filters_unset() {
let names = vec![
Expand Down
30 changes: 4 additions & 26 deletions typescript2/app-vscode-webview/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import { useState, useEffect, useRef, lazy, Suspense } from 'react';
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';
import { ExecutionPanel, WebSocketRuntimePort, type SourceNavigationTarget } from '@b/pkg-playground';

// 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
Expand Down Expand Up @@ -62,8 +54,6 @@ function getVsCodeApi() {

const App: React.FC = () => {
const [port, setPort] = useState<WebSocketRuntimePort | null>(null);
const [connectionStatus, setConnectionStatus] =
useState<WebSocketRuntimePortStatus>('connecting');
const [activeProject, setActiveProject] = useState<string | null>(null);
const [showEditor, setShowEditor] = useState(false);
const [editorHasUnsaved, setEditorHasUnsaved] = useState(false);
Expand Down Expand Up @@ -114,22 +104,16 @@ const App: React.FC = () => {

useEffect(() => {
// When loaded directly in a VS Code webview (no iframe), the extension
// injects __PLAYGROUND_WS_URL (no session token in that mode). Fall back
// to a location-based URL — carrying the page's ?token= — for
// injects __PLAYGROUND_WS_URL. Fall back to location-based URL for
// standalone / iframe / dev scenarios.
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl =
window.__PLAYGROUND_WS_URL ??
withPlaygroundToken(`ws://${window.location.host}/api/ws`);
window.__PLAYGROUND_WS_URL ?? `${scheme}://${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(() => {
Expand Down Expand Up @@ -227,12 +211,6 @@ const App: React.FC = () => {

return (
<div className="playground-root flex h-full min-h-0 w-full flex-col overflow-hidden">
{connectionStatus === 'retrying' && (
<div className="shrink-0 border-b border-amber-600/50 bg-amber-500/15 px-3 py-1.5 text-xs text-amber-600">
Can&apos;t reach the playground server — open the exact URL printed by{' '}
<code>baml playground</code>, including the token.
</div>
)}
{(inVsCode || canShowEditor) && (
<header className="flex h-10 shrink-0 items-center justify-end gap-2 border-b border-border bg-background px-2">
{editorActive && (
Expand Down
Loading
Loading