From 034cb5dea9810a8b4673f3040c4236bcd0a4669f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 14 Mar 2026 13:37:57 +0100 Subject: [PATCH 1/3] fix(tauri): isolate desktop auth cookies per app --- packages/server/src/auth/manager.ts | 14 +++- packages/server/src/index.ts | 11 ++- .../tauri-app/src-tauri/src/cli_manager.rs | 67 +++++++++++++++---- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index dde0f72f..f12b8761 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -16,16 +16,18 @@ export interface AuthManagerInit { password?: string generateToken: boolean dangerouslySkipAuth?: boolean + cookieName?: string } export class AuthManager { private readonly authStore: AuthStore | null private readonly tokenManager: TokenManager | null private readonly sessionManager = new SessionManager() - private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME + private readonly cookieName: string private readonly authEnabled: boolean constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { + this.cookieName = sanitizeCookieName(init.cookieName) this.authEnabled = !Boolean(init.dangerouslySkipAuth) if (!this.authEnabled) { @@ -139,6 +141,16 @@ export class AuthManager { } } +function sanitizeCookieName(value: string | undefined): string { + const trimmed = value?.trim() + if (!trimmed) { + return DEFAULT_AUTH_COOKIE_NAME + } + + const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_") + return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME +} + function resolveAuthFilePath(configPath: string) { const resolvedConfigPath = resolvePath(configPath) return path.join(path.dirname(resolvedConfigPath), "auth.json") diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a317a5d3..a4ab017d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -19,7 +19,7 @@ import { InstanceEventBridge } from "./workspaces/instance-events" import { createLogger } from "./logger" import { launchInBrowser } from "./launcher" import { resolveUi } from "./ui/remote-ui" -import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" +import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" import { resolveNetworkAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" @@ -54,6 +54,7 @@ interface CliOptions { launch: boolean authUsername: string authPassword?: string + authCookieName: string generateToken: boolean dangerouslySkipAuth: boolean } @@ -99,6 +100,11 @@ function parseCliOptions(argv: string[]): CliOptions { .default(DEFAULT_AUTH_USERNAME), ) .addOption(new Option("--password ", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD")) + .addOption( + new Option("--auth-cookie-name ", "Cookie name for server authentication") + .env("CODENOMAD_AUTH_COOKIE_NAME") + .default(DEFAULT_AUTH_COOKIE_NAME), + ) .addOption( new Option("--generate-token", "Emit a one-time bootstrap token for desktop") .env("CODENOMAD_GENERATE_TOKEN") @@ -138,6 +144,7 @@ function parseCliOptions(argv: string[]): CliOptions { launch?: boolean username: string password?: string + authCookieName: string generateToken?: boolean dangerouslySkipAuth?: boolean }>() @@ -184,6 +191,7 @@ function parseCliOptions(argv: string[]): CliOptions { launch: Boolean(parsed.launch), authUsername: parsed.username, authPassword: parsed.password, + authCookieName: parsed.authCookieName, generateToken: Boolean(parsed.generateToken), dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), } @@ -265,6 +273,7 @@ async function main() { configPath: configLocation.configYamlPath, username: options.authUsername, password: options.authPassword, + cookieName: options.authCookieName, generateToken: options.generateToken, dangerouslySkipAuth: options.dangerouslySkipAuth, }, diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index e193ef83..518e6cd4 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -14,7 +14,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; fn log_line(message: &str) { @@ -32,7 +32,7 @@ fn workspace_root() -> Option { }) } -const SESSION_COOKIE_NAME: &str = "codenomad_session"; +const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session"; const CLI_STOP_GRACE_SECS: u64 = 30; @@ -66,7 +66,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { Some(value.to_string()) } -fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result> { +fn exchange_bootstrap_token( + base_url: &str, + token: &str, + cookie_name: &str, +) -> anyhow::Result> { let parsed = Url::parse(base_url)?; let host = parsed.host_str().unwrap_or("127.0.0.1"); let port = parsed.port_or_known_default().unwrap_or(80); @@ -101,11 +105,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result anyhow::Result anyhow::Result<()> { +fn set_session_cookie( + app: &AppHandle, + base_url: &str, + cookie_name: &str, + session_id: &str, +) -> anyhow::Result<()> { let parsed = Url::parse(base_url)?; let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string(); - let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id)) + let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string())) .domain(domain) .path("/") .http_only(true) @@ -132,6 +141,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh Ok(()) } +fn generate_auth_cookie_name() -> String { + let pid = std::process::id(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + + format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") +} + const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; #[derive(Debug, Deserialize)] @@ -412,7 +431,13 @@ impl CliProcessManager { "resolved CLI entry runner={:?} entry={} host={}", resolution.runner, resolution.entry, host )); - let args = resolution.build_args(dev, &host); + let auth_cookie_name = Arc::new(generate_auth_cookie_name()); + emit_perf_startup( + &app, + "cli.auth.cookie.generated", + json!({ "cookieName": auth_cookie_name }), + ); + let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { log_line("development mode: will prefer tsx + source if present"); @@ -487,6 +512,7 @@ impl CliProcessManager { let app_clone = app.clone(); let ready_clone = ready.clone(); let token_clone = bootstrap_token.clone(); + let auth_cookie_name_clone = auth_cookie_name.clone(); thread::spawn(move || { let stdout = child_clone @@ -508,6 +534,7 @@ impl CliProcessManager { &status_clone, &ready_clone, &token_clone, + auth_cookie_name_clone.as_str(), ); } if let Some(reader) = stderr { @@ -518,6 +545,7 @@ impl CliProcessManager { &status_clone, &ready_clone, &token_clone, + auth_cookie_name_clone.as_str(), ); } }); @@ -617,6 +645,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + auth_cookie_name: &str, ) { let mut buffer = String::new(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); @@ -652,7 +681,14 @@ impl CliProcessManager { .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .map(|m| m.as_str().to_string()) { - Self::mark_ready(app, status, ready, bootstrap_token, url); + Self::mark_ready( + app, + status, + ready, + bootstrap_token, + auth_cookie_name, + url, + ); continue; } @@ -667,6 +703,7 @@ impl CliProcessManager { status, ready, bootstrap_token, + auth_cookie_name, format!("http://localhost:{port}"), ); continue; @@ -679,6 +716,7 @@ impl CliProcessManager { status, ready, bootstrap_token, + auth_cookie_name, format!("http://localhost:{}", port), ); continue; @@ -697,6 +735,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + auth_cookie_name: &str, base_url: String, ) { ready.store(true, Ordering::SeqCst); @@ -720,9 +759,11 @@ impl CliProcessManager { if scheme.as_deref() != Some("http") { navigate_main(app, &base_url); } else { - match exchange_bootstrap_token(&base_url, &token) { + match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) { Ok(Some(session_id)) => { - if let Err(err) = set_session_cookie(app, &base_url, &session_id) { + if let Err(err) = + set_session_cookie(app, &base_url, &auth_cookie_name, &session_id) + { log_line(&format!("failed to set session cookie: {err}")); navigate_main(app, &format!("{base_url}/login")); } else { @@ -818,11 +859,13 @@ impl CliEntry { )) } - fn build_args(&self, dev: bool, host: &str) -> Vec { + fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec { let mut args = vec![ "serve".to_string(), "--host".to_string(), host.to_string(), + "--auth-cookie-name".to_string(), + auth_cookie_name.to_string(), "--generate-token".to_string(), ]; From 800133361dfaf510424ceb0097a4d39fa892b2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 15 Mar 2026 01:10:05 +0100 Subject: [PATCH 2/3] fix(tauri): remove stray perf emission from auth cookie PR Drop the startup instrumentation call that leaked into the auth-cookie isolation branch. The helper is not defined on this PR branch, and the PR does not need to serialize the generated cookie name to fix the multi-instance auth collision. --- packages/tauri-app/src-tauri/src/cli_manager.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 518e6cd4..002f00ec 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -432,11 +432,6 @@ impl CliProcessManager { resolution.runner, resolution.entry, host )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); - emit_perf_startup( - &app, - "cli.auth.cookie.generated", - json!({ "cookieName": auth_cookie_name }), - ); let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { From 2abda0e6b4bf2260363571d4593b906fc2adfba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 15 Mar 2026 09:38:00 +0100 Subject: [PATCH 3/3] fix(desktop): isolate Electron auth cookies per app Make the legacy Electron desktop client generate and pass a per-launch auth cookie name too, so parallel desktop instances stop clobbering each other's localhost session cookie just like the Tauri client. --- packages/electron-app/electron/main/main.ts | 6 +++--- packages/electron-app/electron/main/process-manager.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 80038b02..500bd4c9 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -327,7 +327,6 @@ function finalizeCliSwap(url: string) { mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) } -const SESSION_COOKIE_NAME = "codenomad_session" let bootstrapExchangeInFlight = false function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null { @@ -350,6 +349,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name } async function exchangeBootstrapToken(baseUrl: string, token: string): Promise { + const sessionCookieName = cliManager.getAuthCookieName() const target = new URL("/api/auth/token", baseUrl) const body = JSON.stringify({ token }) @@ -380,14 +380,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise { @@ -132,6 +134,7 @@ export class CliProcessManager extends EventEmitter { this.stdoutBuffer = "" this.stderrBuffer = "" this.bootstrapToken = null + this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` this.requestedStop = false this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) @@ -328,6 +331,10 @@ export class CliProcessManager extends EventEmitter { return { ...this.status } } + getAuthCookieName(): string { + return this.authCookieName + } + private resolveListeningMode(): ListeningMode { return readListeningModeFromConfig() } @@ -416,7 +423,7 @@ export class CliProcessManager extends EventEmitter { } private buildCliArgs(options: StartOptions, host: string): string[] { - const args = ["serve", "--host", host, "--generate-token"] + const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName] if (options.dev) { // Dev: run plain HTTP + Vite dev server proxy.