Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5 db648230ee chore: regenerate capabilities schema after dropping the opener grant
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m57s
Build App (Preview) / build-linux (pull_request) Successful in 5m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m54s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Tracked build output; regenerated by the Tauri build from
capabilities/default.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:11:03 -07:00
shadowdaoandClaude Opus 5 5a452e7a2a security: drop opener:allow-open-url now that nothing calls it
default.json carried this grant with an explicit accepted residual risk:
a compromised webview could make the OS open an attacker-chosen http(s)
URL. It was accepted because it could not be narrowed -- WebLinksAddon
opens links Claude printed inside the container, which are arbitrary by
construction, so a host allowlist would have deleted the feature.

Now that every host-browser open routes through `open_url_external`, the
webview has no reason to reach the plugin directly, and the risk closes
rather than stays recorded. The plugin remains a dependency: macOS and
Windows still use it, through `OpenerExt::open_url`, whose desktop
implementation calls `crate::open::open` directly and is not gated by
capabilities at all (tauri-plugin-opener-2.5.3/src/lib.rs:60) -- verified
rather than assumed, since the whole point is that the Rust path keeps
working. What is removed is the webview's ability to reach the opener
without passing the Rust-side validation.

The census note in default.json is rewritten to match, and lib.rs's
grant-list test is updated deliberately, as its own assertion message
demands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:10:45 -07:00
shadowdaoandClaude Opus 5 5a09254538 fix: route every host-browser open through open_url_external
The Rust command existed but nothing called it. All four frontend call
sites still used `openUrl` from `@tauri-apps/plugin-opener`, so the
environment fix was inert and the three dialogs carried the same Linux bug
as the terminal: DockerInstallDialog's docs link, ClaudeAuthModal's sign-in
link and UpdateDialog's release link would all have reported success while
launching nothing.

`openUrlExternal` in tauri-commands.ts is now the single sink. There is no
platform branch: Linux gets the sanitized spawn, macOS and Windows reach
the same plugin as before but from Rust, and every platform picks up the
Rust-side re-validation, which matters because these URLs originate in an
untrusted container.

Comments in urlRelay.ts and urlDetector.ts that named `openUrl` as the sink
they guard are updated to match, and the two test files that mocked
`@tauri-apps/plugin-opener` now mock the command instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:09:57 -07:00
shadowdaoandClaude Opus 5 9297020688 fix: open host URLs with a clean environment (triple-c#34)
On Linux the app ships as a single AppImage, and the AppImage environment
leaks into everything it spawns. linuxdeploy's AppRun, linuxdeploy-plugin-gtk
and our own wayland fallback hook all export LD_LIBRARY_PATH, GTK_PATH,
GIO_MODULE_DIR and friends pointing inside $APPDIR, and main.rs sets
WEBKIT_DISABLE_DMABUF_RENDERER process-wide for the webview. A browser that
is already running shrugs this off, because xdg-open just hands the URL to
the existing process. A cold-launched one inherits the lot and dies before
painting -- with xdg-open still exiting 0, which is why this looked like the
button doing nothing at all.

`url_open` captures a pristine snapshot of the environment in main() before
any mutation runs, then hands children a repaired copy: a saved original is
restored where one exists, otherwise the process-start value is restored
where we changed it, otherwise only the colon-separated entries that live
under $APPDIR are dropped and the user's own are kept. Outside an AppImage
it is a no-op.

The command re-validates the URL in Rust rather than trusting the frontend,
because the URL originates in an untrusted container: http/https only, no
embedded credentials, no control characters or whitespace, length capped,
ASCII asserted before it reaches execvp, and error messages never echo the
input. Spawning is Command with explicit args and never a shell, trying
xdg-open then gio open.

No portal. org.freedesktop.portal.OpenURI would pull in a D-Bus client stack
for one call on the one platform where we ship self-contained, and it only
helps where a portal is running -- the same case where xdg-open already
works once the environment is clean. `gio open` as a second candidate
recovers most of the missing-MIME-association case for free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:08:20 -07:00
shadowdaoandClaude Opus 5 bf8094dbc4 fix: route sign-in links by what can actually catch the callback
`isAnthropicSignInUrl` made the container the default action for every
Anthropic sign-in link, justified by "the host has nothing to catch it
with". That was wrong in both directions. The host does have something --
the auth bridge -- and the container side is not a general browser at all
but Playwright's dashboard, whose packages and chromium are deliberately
not baked into the image. So the default pointed at the one path that is
uninstalled on a fresh project, on every platform, while the path that
works sat behind a switch.

The decision now lives in `useSignInOpenTarget`: a live auth bridge picks
the host, otherwise a container that can actually launch a browser picks
the container, otherwise the host. It resolves at mount rather than when a
URL arrives, so the buttons do not swap under a moving mouse, and it
re-decides on `auth-bridge-changed` so flipping the switch during a
hanging login takes effect. A bridge with port conflicts reads as not
live; an empty `active_ports` does not, since there is nothing to bridge
until the CLI binds its listener and that races the URL.

Both buttons still render either way -- this changes which one leads.
`sanitizeRelayUrl` is byte-for-byte unchanged, so the embedded copy in
web_terminal/terminal.html needs no matching edit.

The host "Open" path also failed silently: `dismissUrlPrompt()` ran before
`openUrl`, so the toast vanished and a rejected promise reached only the
devtools console. Dismissal now happens on success only, leaving "In
container" one click away after a failure, and the error surfaces through
the same toast the container path already used. On Linux this catch will
not fire for the common case -- `xdg-open` routinely exits 0 having done
nothing -- so it complements the AppImage environment fix rather than
replacing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:07:50 -07:00
shadowdaoandClaude Opus 5 90b7e4ccb2 fix: default the auth bridge on, and make the browser-view flag durable
A CLI running `claude login` inside the container binds a random ephemeral
loopback port and hands the provider a redirect pointing at it. The browser
is on the host, so the callback lands on a closed host port and the login
hangs with no diagnostic. The auth bridge is the thing that fixes this --
it mirrors container loopback listeners onto the same host port -- so
having it default to off made a hang the out-of-the-box experience.

`auth_bridge_enabled` now defaults to true through a
`default_auth_bridge_enabled()` serde helper, matching the shape already
used by `use_shared_auth_token`. Because the default is applied at
deserialisation, projects stored before the bridge existed pick it up too;
`migrate_from_value` writes neither flag, so nothing defeats it, and a
regression test pins that.

Separately, `BrowserViewManager.enabled` was in-memory only and the durable
`browser_view_enabled` field on the project record was never implemented.
Rather than sync the two, the cache is removed and the record becomes the
single home for the flag, mirroring how `AuthBridgeManager` already works.
`stop()` deliberately does not clear it, since container teardown and
migration reach that path and neither is the user changing their mind.
Durable does not mean auto-started: a restarted app reports enabled with
the viewer off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:06:09 -07:00
shadowdaoandClaude Opus 5 afe9d5cdb2 docs: correct Linux packaging in BUILDING.md
BUILDING.md listed AppImage, .deb and .rpm as build artifacts, but Linux
ships as AppImage only -- CI passes `--bundles appimage`, and the .deb and
.rpm were dropped because neither could self-update. A bare `npx tauri
build` still emits all three, since tauri.conf.json keeps "targets": "all"
to leave macOS and Windows untouched, so the table now marks which are
actually released rather than pretending the others do not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:29 -07:00
28 changed files with 1772 additions and 121 deletions
+21 -5
View File
@@ -71,13 +71,29 @@ npm ci
npx tauri build
```
Linux ships as **AppImage only**. To match what CI produces, pass the bundle
explicitly:
```bash
npx tauri build --bundles appimage
```
The `.deb` and `.rpm` bundles were dropped — two more artifacts to build and
publish for an audience the AppImage already serves, and neither could
self-update. A bare `npx tauri build` still emits them, because
`tauri.conf.json` keeps `"targets": "all"` so that macOS and Windows are
untouched; they are not released and not tested.
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
| Format | Path |
|------------|-------------------------------|
| AppImage | `appimage/*.AppImage` |
| Debian pkg | `deb/*.deb` |
| RPM pkg | `rpm/*.rpm` |
| Format | Path | Released |
|------------|-------------------------------|----------|
| AppImage | `appimage/*.AppImage` | yes |
| Debian pkg | `deb/*.deb` | no |
| RPM pkg | `rpm/*.rpm` | no |
`scripts/finalize-appimage.sh` post-processes the AppImage; see the Packaging
section of `CLAUDE.md` for why both of its steps are load-bearing.
## macOS
+1
View File
@@ -5306,6 +5306,7 @@ dependencies = [
"tauri-plugin-opener",
"tokio",
"tower-http",
"url",
"uuid",
"zeroize",
]
+4
View File
@@ -39,6 +39,10 @@ local-ip-address = "0.6"
argon2 = "0.5"
aes-gcm = "0.10"
zeroize = "1"
# WHATWG URL parsing for `url_open`'s re-validation of URLs arriving from the
# container. Already in the tree transitively (reqwest), and the point of
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
url = "2"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+46 -7
View File
@@ -15,6 +15,12 @@ use crate::AppState;
/// non-`Running` status carrying an explanation rather than an error, so the
/// pane always has something specific to say. This is host-side only — no
/// container recreation is involved either way.
///
/// Either way the choice is persisted, so it survives an app restart. This is
/// the only caller allowed to write `false`: every other path to
/// [`BrowserViewManager::stop`](crate::browser_view::BrowserViewManager::stop)
/// is a teardown rather than the user changing their mind. Enabling persists
/// inside `start`, which is the single funnel for it.
#[tauri::command]
pub async fn set_browser_view_enabled(
project_id: String,
@@ -23,9 +29,15 @@ pub async fn set_browser_view_enabled(
state: State<'_, AppState>,
) -> Result<BrowserViewStatus, String> {
if !enabled {
// Persist first, then tear down: the supervisor's own teardown emit
// reads this flag back out of the store, and reading it mid-stop would
// announce a view that is going away as still enabled.
state
.projects_store
.set_browser_view_enabled(&project_id, false)?;
// Awaits the supervisor, so the host port is released before we return.
manager().stop(&project_id).await;
return Ok(manager().status(&project_id).await);
return Ok(manager().status(&project_id, false).await);
}
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
@@ -40,10 +52,17 @@ pub async fn set_browser_view_enabled(
.await
}
/// Current status. Cheap: reads in-process state only, never the container.
/// Current status. Cheap: the session map in this process plus the stored flag,
/// never the container.
///
/// The two are independent on purpose — this is what the pane reads on mount,
/// and after an app restart the honest answer is "enabled, nothing running".
#[tauri::command]
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
Ok(manager().status(&project_id).await)
pub async fn get_browser_view_status(
project_id: String,
state: State<'_, AppState>,
) -> Result<BrowserViewStatus, String> {
Ok(manager().status(&project_id, enabled_for(&state, &project_id)).await)
}
/// Probe the container for Playwright without starting anything.
@@ -110,7 +129,9 @@ pub async fn open_browser_view_popout(
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
return Err(
"The browser view isn't running. Start it before opening it in its own window."
@@ -209,7 +230,9 @@ pub async fn open_page_in_container_browser(
// the user to go and press Start in the Browser tab themselves — and from
// the terminal's URL prompt, with no indication that was even needed.
// Asking for a page *is* asking to watch it, so the viewer comes up too.
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
if status.state != BrowserViewState::Running {
crate::commands::project_commands::emit_progress(
&app_handle,
@@ -229,7 +252,9 @@ pub async fn open_page_in_container_browser(
// From the terminal there is no pane on screen to fill, so the page needs a
// window of its own or it lands somewhere the user isn't looking.
if show_window {
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
if let Some(url) = status.url.as_deref() {
let name = state
.projects_store
@@ -311,6 +336,20 @@ pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, S
Ok(popout::match_window(&project_id))
}
/// The project's stored browser-view opt-in.
///
/// The manager holds no copy of this — see
/// [`BrowserViewManager`](crate::browser_view::BrowserViewManager) — so every
/// status call reads it here, the way `get_auth_bridge_status` does. A project
/// that has gone away reads as off, which is the only answer that can be given
/// about a record that no longer exists.
fn enabled_for(state: &State<'_, AppState>, project_id: &str) -> bool {
state
.projects_store
.get(project_id)
.is_some_and(|p| p.browser_view_enabled)
}
/// The project's container, or a sentence saying why there isn't one.
///
/// Every command here needs a *running* container, and every one of them used
+81 -32
View File
@@ -34,14 +34,22 @@
//!
//! ## Lifecycle
//!
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
//! Off by default and per-project opt-in. The opt-in itself is
//! [`Project::browser_view_enabled`](crate::models::Project), persisted like
//! `auth_bridge_enabled` and read from the store on demand rather than cached
//! here — so the pane comes back the way it was left. What does *not* persist
//! is the session: nothing starts a viewer on app start, so a project left
//! enabled reports `enabled: true` with a state of `Off` until the pane asks
//! for one. That is deliberate, and the reason the flag and the session are
//! separate ideas — see [`BrowserViewManager::status`].
//!
//! One supervisor task per session owns the proxy and the viewer process, and it
//! is the only thing that tears them down, so every way a session can end funnels
//! through one code path:
//!
//! | Trigger | Path |
//! |---|---|
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
//! | Project deleted | supervisor's `store.get()` check |
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
@@ -59,7 +67,10 @@
//! orphan is reachable on container loopback only: the host-side port dies with
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
//! precisely so the bridge will not mirror an orphan the next time the app
//! starts. The next [`BrowserViewManager::start`] reclaims it.
//! starts. The next [`BrowserViewManager::start`] reclaims it — and since the
//! opt-in is now durable, the restarted app says `enabled` with nothing running,
//! which is exactly the state that invites the user to press the button that
//! reclaims it. Nothing reclaims it on its own, because nothing auto-starts.
pub mod commands;
pub mod detect;
@@ -134,7 +145,10 @@ pub enum BrowserViewState {
#[derive(Debug, Clone, Serialize)]
pub struct BrowserViewStatus {
/// The per-project opt-in. Off by default.
/// The per-project opt-in, read from the persisted project record. Off by
/// default, and true without a `Running` state whenever the view is turned
/// on but has nothing up — a stopped container, or an app that has just
/// restarted and does not auto-start viewers.
pub enabled: bool,
pub state: BrowserViewState,
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
@@ -201,17 +215,20 @@ struct Session {
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
/// Live sessions, and nothing else.
///
/// The per-project opt-in deliberately is **not** a field here. It lives on
/// the project record as
/// [`browser_view_enabled`](crate::models::Project::browser_view_enabled) and
/// is read from [`ProjectsStore`] at each use, exactly as
/// [`crate::auth_bridge::AuthBridgeManager`] treats `auth_bridge_enabled`:
/// one copy, durable across a restart, and impossible to get out of step with
/// what the Config tab shows. A cached copy here was the previous design and
/// its only observable behaviour was forgetting the user's choice on every
/// app start.
#[derive(Default)]
pub struct BrowserViewManager {
sessions: SessionMap,
/// The per-project opt-in.
///
/// NOTE: in memory only, so it does not survive an app restart. The durable
/// home for this is a `browser_view_enabled: bool` field on
/// `models::Project` (see the report) — `models/project.rs` is out of scope
/// for this change, so the flag lives here and the wiring is otherwise
/// identical to `auth_bridge_enabled`.
enabled: Mutex<std::collections::HashSet<String>>,
next_epoch: AtomicU64,
}
@@ -226,22 +243,15 @@ pub fn manager() -> &'static Arc<BrowserViewManager> {
}
impl BrowserViewManager {
pub async fn is_enabled(&self, project_id: &str) -> bool {
self.enabled.lock().await.contains(project_id)
}
async fn set_enabled(&self, project_id: &str, enabled: bool) {
let mut set = self.enabled.lock().await;
if enabled {
set.insert(project_id.to_string());
} else {
set.remove(project_id);
}
}
/// Current status without touching the container.
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
let enabled = self.is_enabled(project_id).await;
///
/// `enabled` is passed in rather than looked up, the way
/// [`crate::auth_bridge::AuthBridgeManager::status`] takes it: the flag is
/// the caller's to read from the store, and keeping it out of here is what
/// stops a second copy of it appearing. A project whose view is enabled but
/// whose container is stopped — or whose app has just restarted — reports
/// `enabled: true` with a state of `Off`, which is the honest answer.
pub async fn status(&self, project_id: &str, enabled: bool) -> BrowserViewStatus {
match self.sessions.lock().await.get(project_id) {
Some(session) => BrowserViewStatus {
enabled,
@@ -261,6 +271,14 @@ impl BrowserViewManager {
///
/// Idempotent: a call while a live session exists returns that session's
/// status untouched, so re-opening the tab does not restart the dashboard.
///
/// This is the single funnel for turning the view **on**, so it is also
/// where the durable flag is written — both call sites (the toggle and
/// `open_page_in_container_browser`, which opens a page and then shows it)
/// mean "on", and neither can forget. The **off** direction is not
/// symmetric and must not be: [`Self::stop`] is reached by teardown paths
/// that are not the user changing their mind, so the command owns that
/// write. See [`Self::stop`].
pub async fn start(
&self,
project_id: String,
@@ -268,7 +286,7 @@ impl BrowserViewManager {
app: AppHandle,
store: Arc<ProjectsStore>,
) -> Result<BrowserViewStatus, String> {
self.set_enabled(&project_id, true).await;
store.set_browser_view_enabled(&project_id, true)?;
// Bind the answer before acting on it: `status()` takes the same lock,
// and this mutex is not reentrant.
@@ -279,7 +297,7 @@ impl BrowserViewManager {
.get(&project_id)
.is_some_and(|s| !s.supervisor.is_finished());
if already_live {
return Ok(self.status(&project_id).await);
return Ok(self.status(&project_id, true).await);
}
let detection = detect::detect(&container_id).await?;
@@ -364,14 +382,21 @@ impl BrowserViewManager {
},
);
let status = self.status(&project_id).await;
let status = self.status(&project_id, true).await;
emit(&app, &project_id, &status);
Ok(status)
}
/// Stop one project's view and wait until its host port has been released.
///
/// Tears the *session* down and deliberately leaves the durable flag alone.
/// Most callers are not the user turning the feature off — a migration
/// removes the container out from under a running view
/// (`migration_commands`), and the container can stop for any other reason
/// — and persisting `false` for those would quietly opt the project out of
/// a feature it never asked to lose. `set_browser_view_enabled(false)` is
/// the one caller that means it, and it writes the flag itself first.
pub async fn stop(&self, project_id: &str) {
self.set_enabled(project_id, false).await;
// Remove under the lock, then release it before awaiting: the
// supervisor takes the same lock to deregister itself on exit.
let session = self.sessions.lock().await.remove(project_id);
@@ -483,7 +508,12 @@ async fn supervise(
// longer exists. The session owns it, and this is where the session ends.
let _ = popout::close(&app, &project_id);
let enabled = manager().is_enabled(&project_id).await;
// Straight from the store, like the auth bridge's own teardown emit: the
// session is over, but the project may well still be opted in — a stopped
// container is not a changed mind, and the pane has to show the difference.
let enabled = store
.get(&project_id)
.is_some_and(|p| p.browser_view_enabled);
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
}
@@ -915,6 +945,25 @@ mod tests {
assert!(s.url.is_none());
}
#[tokio::test]
async fn the_opt_in_and_the_live_session_are_separate_answers() {
let manager = BrowserViewManager::default();
// Exactly what the pane reads on mount after an app restart of a
// project that was left enabled: the durable flag says on, and nothing
// auto-starts, so the state is honestly `Off`. The old in-memory flag
// could not express this — it came back `false` and the pane silently
// showed the feature as never having been turned on.
let status = manager.status("p1", true).await;
assert!(status.enabled);
assert_eq!(status.state, BrowserViewState::Off);
assert!(status.url.is_none());
// The flag belongs to the caller, read from the store. The manager
// keeps no copy, so it has nothing to contradict it with.
assert!(!manager.status("p1", false).await.enabled);
}
#[test]
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
let mut d = PlaywrightDetection::default();
@@ -1100,6 +1100,15 @@ pub async fn update_project(
project.container_id = stored.container_id;
project.status = stored.status;
// `browser_view_enabled` is owned by `set_browser_view_enabled` and is
// restored here rather than taken from the payload, exactly like
// `container_id` and `status` above. The Config tab has no control for it
// — the Browser tab's toggle is the only way it ever changes — so the
// project object the frontend round-trips carries whatever it was told at
// load time and would silently undo a toggle made since. `auth_bridge_enabled`
// is different and does arrive through this save: the Config tab edits it,
// which is why the reconcile below follows whatever was just persisted.
project.browser_view_enabled = stored.browser_view_enabled;
project.created_at = stored.created_at;
project.updated_at = chrono::Utc::now().to_rfc3339();
+4 -1
View File
@@ -7,6 +7,7 @@ mod logging;
mod models;
mod project_lock;
mod storage;
pub mod url_open;
pub mod web_terminal;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -552,6 +553,9 @@ pub fn run() {
commands::update_commands::check_image_update,
// Help
commands::help_commands::get_help_content,
// Opening a link in the host browser (see `url_open` for why this
// is not `@tauri-apps/plugin-opener` on Linux)
url_open::open_url_external,
// Install helper
commands::install_helper_commands::detect_install_options,
commands::install_helper_commands::run_docker_install,
@@ -934,7 +938,6 @@ mod tests {
"core:webview:allow-internal-toggle-devtools",
"dialog:allow-open",
"dialog:allow-save",
"opener:allow-open-url",
];
expected.sort();
assert_eq!(
+12
View File
@@ -63,6 +63,12 @@
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
/// worth knowing before chasing the "links don't open" half of triple-c#34
/// as a separate, unrelated cause.
///
/// That leak is now plugged rather than merely documented: `url_open` hands
/// the opener a child environment with this variable (and the AppImage's own
/// `LD_LIBRARY_PATH`/`GTK_PATH`/... ) restored or removed. Setting it here
/// stays process-wide because GTK/WebKitGTK need it; what changed is that the
/// children no longer inherit it.
#[cfg(target_os = "linux")]
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
@@ -138,6 +144,12 @@ mod tests {
}
fn main() {
// Before *any* `std::env::set_var` — `url_open` hands a child process the
// environment this app was started with, and the workaround below is one
// of the things that must not leak into it (see triple-c#34). Anything
// added here that mutates the environment belongs after this line.
triple_c_lib::url_open::capture_pristine_environment();
#[cfg(target_os = "linux")]
apply_webkit_wayland_workaround();
+103 -5
View File
@@ -132,6 +132,26 @@ fn default_use_shared_auth_token() -> bool {
true
}
/// `auth_bridge_enabled` defaults to **on**, and the default is what makes
/// `claude login` work at all.
///
/// The login flow binds a *random* ephemeral loopback port inside the
/// container and then sends the host's browser to `127.0.0.1:<that port>`.
/// On the host nothing is listening there, so the callback lands on a closed
/// port and the CLI waits for a redirect that can never arrive. The bridge
/// mirrors the container's loopback listeners onto the same host port, which
/// is the only thing that closes that loop — so off-by-default made a hang the
/// out-of-the-box experience.
///
/// Returning `true` from a `#[serde(default)]` helper (rather than flipping the
/// constructor alone) is deliberate: existing `projects.json` records were
/// written before this field existed, or while it was off, and an absent key is
/// what the default is read for. A project that wants the old behaviour turns
/// the toggle off, which persists an explicit `false`.
fn default_auth_bridge_enabled() -> bool {
true
}
/// How much autonomy Claude Code is granted inside the container.
///
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
@@ -336,17 +356,30 @@ pub struct Project {
pub sandbox_mode_enabled: bool,
#[serde(default)]
pub mission_control_enabled: bool,
/// Opt in to the auth bridge: while the container runs, its loopback
/// listeners are mirrored onto the host's loopback so browser OAuth
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
/// The auth bridge: while the container runs, its loopback listeners are
/// mirrored onto the host's loopback so browser OAuth callbacks
/// (`claude login`, `fly login`, `aws sso login`) can reach them.
/// Purely host-side — it deliberately has no container-recreation label,
/// because toggling it changes nothing about the container itself.
#[serde(default)]
///
/// **On by default**, and opt-*out* rather than opt-in — see
/// [`default_auth_bridge_enabled`] for why the default is the feature.
#[serde(default = "default_auth_bridge_enabled")]
pub auth_bridge_enabled: bool,
/// Opt in to the browser-view pane, which watches and takes over the
/// browser Claude drives with Playwright inside the container. Purely
/// host-side like `auth_bridge_enabled`, so it likewise has no
/// container-recreation label.
///
/// This is the *durable* home of the flag: `BrowserViewManager` reads it
/// rather than keeping its own copy, so the pane comes back the way it was
/// left. Off by default, and unlike the auth bridge it stays that way — a
/// view costs a container exec, a Node daemon and a host port, and a
/// container without Playwright cannot serve one at all.
///
/// Durable does **not** mean auto-started: nothing brings a viewer up on
/// app start, so a project left enabled reports `enabled` with a state of
/// `Off` until the pane (or `open_page_in_container_browser`) asks for one.
#[serde(default)]
pub browser_view_enabled: bool,
/// Grant the container what a VPN client needs to build a tunnel:
@@ -639,7 +672,7 @@ impl Project {
allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false,
auth_bridge_enabled: false,
auth_bridge_enabled: default_auth_bridge_enabled(),
browser_view_enabled: false,
vpn_support_enabled: false,
use_shared_auth_token: default_use_shared_auth_token(),
@@ -885,4 +918,69 @@ mod tests {
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped, partial);
}
// ── The host-side per-project toggles ─────────────────────────────────
#[test]
fn a_project_stored_before_the_auth_bridge_existed_gets_it_turned_on() {
// The whole point of the serde default: `MAIN_SHAPE_PROJECT` is a real
// record written by a shipped binary and has no `auth_bridge_enabled`
// key at all. Without this, every existing project keeps hanging on
// `claude login` until its owner finds the toggle.
assert!(!MAIN_SHAPE_PROJECT.contains("auth_bridge_enabled"));
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
assert!(project.auth_bridge_enabled);
// The browser view is the other way round and must stay so: it costs a
// Node daemon, a container exec loop and a host port, and most
// containers have no Playwright to serve it with.
assert!(!project.browser_view_enabled);
}
#[test]
fn turning_the_auth_bridge_off_survives_the_default() {
// Opt-out has to be expressible, or the toggle does nothing across a
// restart. An explicit `false` in the file beats the default.
let json = r#"{ "auth_bridge_enabled": false }"#;
#[derive(Deserialize)]
struct JustTheFlag {
#[serde(default = "default_auth_bridge_enabled")]
auth_bridge_enabled: bool,
}
let parsed: JustTheFlag = serde_json::from_str(json).unwrap();
assert!(!parsed.auth_bridge_enabled);
// And a saved project always writes the key, so the choice is pinned
// rather than re-defaulted on the next load.
let mut p = Project::new("demo".to_string(), Vec::new());
p.auth_bridge_enabled = false;
let round_tripped: Project =
serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
assert!(!round_tripped.auth_bridge_enabled);
}
#[test]
fn a_new_project_starts_with_the_bridge_on_and_the_view_off() {
let p = Project::new("demo".to_string(), Vec::new());
assert!(p.auth_bridge_enabled);
assert!(!p.browser_view_enabled);
}
#[test]
fn the_path_migration_never_writes_the_flags_and_so_cannot_defeat_the_default() {
// `ProjectsStore::new` runs every record through this before
// deserialising. If it inserted either key — even as `false` — the
// serde default above would never be consulted for an existing project
// and this change would be a no-op on exactly the projects it is for.
let legacy = serde_json::json!({
"id": "p1",
"name": "demo",
"path": "/home/u/demo",
});
let migrated = Project::migrate_from_value(legacy);
let obj = migrated.as_object().unwrap();
assert!(obj.contains_key("paths"), "the migration should still do its own job");
assert!(!obj.contains_key("auth_bridge_enabled"));
assert!(!obj.contains_key("browser_view_enabled"));
}
}
@@ -241,6 +241,21 @@ impl ProjectsStore {
}
}
/// Granular setter for the browser view's opt-in, for the same reason
/// [`Self::set_auth_bridge_enabled`] has one: the pane toggles this while
/// the Config tab may be holding an older copy of the whole record.
pub fn set_browser_view_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
p.browser_view_enabled = enabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
self.save(&projects)?;
Ok(())
} else {
Err(format!("Project {} not found", project_id))
}
}
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
@@ -338,4 +353,61 @@ mod tests {
fs::remove_dir_all(&dir).ok();
}
/// A store over a temp file. `new()` insists on `dirs::data_dir()`, which
/// is the real user's; the fields are right here, so the granular setters
/// can be exercised against a directory the test owns.
fn store_over(dir: &Path, projects: Vec<Project>) -> ProjectsStore {
ProjectsStore {
projects: Mutex::new(projects),
file_path: dir.join("projects.json"),
}
}
#[test]
fn the_browser_view_flag_is_written_to_disk_and_read_back() {
// The point of the whole exercise: before this the flag lived in a
// `HashSet` in `BrowserViewManager` and an app restart forgot it.
let dir = temp_dir("browser-view");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
assert!(!store.get(&id).unwrap().browser_view_enabled);
store.set_browser_view_enabled(&id, true).unwrap();
assert!(store.get(&id).unwrap().browser_view_enabled);
// Durable, not merely in memory — this is what a restart reads.
let on_disk: Vec<Project> =
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
assert!(on_disk[0].browser_view_enabled);
store.set_browser_view_enabled(&id, false).unwrap();
assert!(!store.get(&id).unwrap().browser_view_enabled);
assert!(store.set_browser_view_enabled("no-such-project", true).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_granular_toggle_leaves_every_other_field_alone() {
// Why these setters exist at all: the Config tab can be holding an
// older copy of the whole record while the pane flips one flag.
let dir = temp_dir("granular");
let mut project = Project::new("demo".to_string(), Vec::new());
project.claude_instructions = Some("keep me".to_string());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
store.set_browser_view_enabled(&id, true).unwrap();
store.set_auth_bridge_enabled(&id, false).unwrap();
let saved = store.get(&id).unwrap();
assert_eq!(saved.claude_instructions.as_deref(), Some("keep me"));
assert!(saved.browser_view_enabled);
assert!(!saved.auth_bridge_enabled);
fs::remove_dir_all(&dir).ok();
}
}
+701
View File
@@ -0,0 +1,701 @@
//! Opening a URL in the *host's* browser — the half of triple-c#34 where
//! "Open" appeared to do nothing on Linux.
//!
//! # Why this module exists rather than `openUrl` from `@tauri-apps/plugin-opener`
//!
//! The plugin's Linux path shells out to `xdg-open`, and the child inherits
//! this process's environment verbatim. Inside an AppImage that environment is
//! not the user's — it is the AppImage's, and it is actively hostile to any
//! program that is not the one the bundle was built for:
//!
//! - linuxdeploy's `AppRun`/`AppRun.wrapped` prepends the bundle's own
//! directories to `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `PYTHONPATH`,
//! `PERLLIB`, `QT_PLUGIN_PATH` and `GSETTINGS_SCHEMA_DIR`.
//! - `linuxdeploy-plugin-gtk`'s hook adds `GTK_PATH`, `GTK_EXE_PREFIX`,
//! `GTK_DATA_PREFIX`, `GTK_IM_MODULE_FILE`, `GIO_MODULE_DIR` and
//! `GDK_PIXBUF_MODULE_FILE`.
//! - `scripts/finalize-appimage.sh` installs one more hook of our own
//! (`triple-c-wayland-fallback.sh`) that can prepend
//! `$APPDIR/usr/lib/wayland-fallback` to `LD_LIBRARY_PATH`.
//! - `main.rs` sets `WEBKIT_DISABLE_DMABUF_RENDERER` process-wide, and the
//! comment there has flagged this leak for a while: it reaches whatever the
//! app spawns afterwards.
//!
//! A browser that is *already running* is unaffected — `xdg-open` just hands
//! the URL to the existing instance over D-Bus/IPC and the new process exits.
//! A **cold-launched** browser loads our bundled GTK/glib/pixbuf stack against
//! the host's, aborts before it ever paints, and `xdg-open` has already
//! returned 0. From the app's point of view the click did nothing. That is the
//! reported symptom, and it is why the bug only reproduces for some people.
//!
//! # What this does instead
//!
//! `open_url_external` re-validates the URL (see below) and spawns the opener
//! with a **sanitized child environment**. Sanitizing is
//! [`sanitize_child_env`], a pure function over two maps so it can be tested
//! without touching process-wide state:
//!
//! 1. If the AppImage saved the pre-launch value under a `*_ORIG` /
//! `APPIMAGE_ORIGINAL_*` name, restore that. Restoring a saved original is
//! strictly better than unsetting, because the user may genuinely have had
//! an `LD_LIBRARY_PATH` of their own.
//! 2. Otherwise, if the variable differs from the value this process started
//! with, restore the start-up value. That is what undoes *our own*
//! `std::env::set_var` — `main.rs` snapshots the environment via
//! [`capture_pristine_environment`] before any mutation runs.
//! 3. Otherwise, drop only the entries that point inside `$APPDIR`, keeping
//! the rest of the list intact. Blanket-unsetting would also discard
//! whatever the user's session had set; this removes exactly the
//! bundle's own contribution.
//!
//! Nothing is invented: a variable the pristine environment did not have and
//! that does not point into `$APPDIR` is left alone, so outside an AppImage
//! (`cargo tauri dev`, a distro build) this is very close to a no-op.
//!
//! # Portal vs. `xdg-open`
//!
//! `org.freedesktop.portal.OpenURI` would sidestep both the environment leak
//! *and* a missing `x-scheme-handler/https` association, but reaching it means
//! a D-Bus client — `zbus` and its async stack — as a new dependency for one
//! call, on the only platform where we ship a single self-contained binary.
//! It also only helps where a portal is running, which is precisely the
//! desktop-environment case in which `xdg-open` already works once the
//! environment is clean. The environment *is* the bug here, so the cheap fix
//! is the complete one. `gio open` is kept as a second candidate because it
//! goes through GIO's own handler lookup rather than `xdg-open`'s shell
//! heuristics, which covers most of what the portal would have covered.
//!
//! # Security
//!
//! The URL reaching this command originates in an **untrusted container** (see
//! `app/src/lib/urlRelay.ts`). The frontend validates with `sanitizeRelayUrl`,
//! but a compromised webview can call this command directly, so the rules are
//! mirrored here and enforced again: `http`/`https` only, a non-empty host, no
//! embedded credentials, no control characters or whitespace, and a length
//! cap. The URL is never passed through a shell — `std::process::Command` with
//! explicit arguments, so there is no word-splitting, no globbing and no
//! metacharacter to escape.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use url::Url;
/// Hard cap on a URL we will hand to the OS. Mirrors `MAX_RELAY_URL_LENGTH`
/// in `app/src/lib/urlRelay.ts`.
const MAX_URL_LEN: usize = 8192;
/// The environment this process was started with, captured before anything
/// mutates it. See [`capture_pristine_environment`].
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
static PRISTINE_ENV: OnceLock<BTreeMap<String, String>> = OnceLock::new();
/// Record the environment as it was at process start.
///
/// Must be called from `main()` **before** any `std::env::set_var` — today
/// that means before `apply_webkit_wayland_workaround()`, which is the only
/// mutation in the tree. Calling it twice is harmless; the first call wins.
///
/// This is the only reliable source of truth for "what did the user actually
/// have?" for variables *we* set. It cannot recover what `AppRun` overwrote
/// before `main()` ran — that is what the `*_ORIG` and `$APPDIR` rules in
/// [`sanitize_child_env`] are for.
pub fn capture_pristine_environment() {
let _ = PRISTINE_ENV.set(std::env::vars().collect());
}
/// Variables an AppImage launcher is known to override, and that break a
/// cold-launched child that is not this app.
///
/// `PATH` is in the list for the same reason as the rest: `AppRun` prepends
/// `$APPDIR/usr/bin`, and resolving `xdg-open` (or anything the browser's own
/// wrapper script calls) out of the bundle is its own failure mode.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const SANITIZED_VARS: &[&str] = &[
"GDK_PIXBUF_MODULEDIR",
"GDK_PIXBUF_MODULE_FILE",
"GIO_MODULE_DIR",
"GSETTINGS_SCHEMA_DIR",
"GTK_DATA_PREFIX",
"GTK_EXE_PREFIX",
"GTK_IM_MODULE_FILE",
"GTK_PATH",
"LD_LIBRARY_PATH",
"PATH",
"PERLLIB",
"PYTHONPATH",
"QT_PLUGIN_PATH",
"XDG_DATA_DIRS",
// Set by `main.rs`, not by AppRun — rule 2 (the pristine snapshot) is what
// removes it, since the pristine environment almost never has it.
"WEBKIT_DISABLE_DMABUF_RENDERER",
];
/// What to do to one variable in the child: `Some(value)` sets it, `None`
/// removes it.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
type EnvChange = (String, Option<String>);
/// True when `entry` is `appdir` itself or a path inside it.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn is_inside(entry: &str, appdir: &str) -> bool {
let appdir = appdir.trim_end_matches('/');
if appdir.is_empty() {
return false;
}
entry == appdir || entry.strip_prefix(appdir).is_some_and(|r| r.starts_with('/'))
}
/// Drop the `$APPDIR` entries from a colon-separated list, keeping order and
/// keeping everything else.
///
/// Single-valued variables (`GDK_PIXBUF_MODULE_FILE`, say) are just lists of
/// one, so they need no separate case: a value inside `$APPDIR` filters down
/// to nothing and the variable is removed.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
let kept: Vec<&str> = value
.split(':')
.filter(|entry| !entry.is_empty() && !is_inside(entry, appdir))
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join(":"))
}
}
/// Compute the changes that turn `current` into an environment safe to hand a
/// cold-launched host program.
///
/// Pure on purpose — `current` and `pristine` are passed in rather than read
/// from the process, so the rules can be tested without a global mutex around
/// the environment. Returns changes sorted by variable name so assertions are
/// deterministic.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn sanitize_child_env(
current: &BTreeMap<String, String>,
pristine: &BTreeMap<String, String>,
appdir: Option<&str>,
) -> Vec<EnvChange> {
let mut changes: Vec<EnvChange> = Vec::new();
for var in SANITIZED_VARS {
let now = current.get(*var);
// 1. A saved original always wins. Both spellings are checked because
// which one exists depends on the launcher: linuxdeploy's AppRun
// and the various `AppRun.wrapped` generations have used each.
// An empty saved value means "it was unset", not "set it to empty".
let saved = current
.get(&format!("{var}_ORIG"))
.or_else(|| current.get(&format!("APPIMAGE_ORIGINAL_{var}")));
if let Some(saved) = saved {
let restored = if saved.is_empty() {
None
} else {
Some(saved.clone())
};
if restored.as_ref() != now {
changes.push((var.to_string(), restored));
}
continue;
}
// 2. We changed it ourselves after start-up — put back what was there.
let at_start = pristine.get(*var);
if at_start != now {
changes.push((var.to_string(), at_start.cloned()));
continue;
}
// 3. Polluted before `main()` ran, with nothing saved. Remove the
// bundle's own entries and keep the user's.
let (Some(now), Some(appdir)) = (now, appdir) else {
continue;
};
let stripped = strip_appdir_entries(now, appdir);
if stripped.as_deref() != Some(now.as_str()) {
changes.push((var.to_string(), stripped));
}
}
changes.sort_by(|a, b| a.0.cmp(&b.0));
changes
}
/// Whether `candidate` holds a character that disqualifies it before parsing.
///
/// Mirrors `hasForbiddenChar` in `app/src/lib/urlRelay.ts`, and for the same
/// reasons: C0/C1 controls and whitespace are invisible in the UI and are
/// stripped rather than rejected by some URL parsers, and quote characters are
/// illegal in a URL per RFC 3986 while being exactly what an argument-splitting
/// opener downstream would act on. Written as a scan over code points rather
/// than a regex so the control ranges cannot be mangled by an editing tool.
fn has_forbidden_char(candidate: &str) -> bool {
candidate.chars().any(|ch| {
let code = ch as u32;
code <= 0x20
|| code == 0x7f
|| (0x80..=0x9f).contains(&code)
|| ch == '"'
|| ch == '\''
|| ch == '`'
|| ch.is_whitespace()
})
}
/// Validate a URL an untrusted source asked the host to open.
///
/// Returns the normalized URL, or a message safe to show the user. The message
/// never echoes the input: it is the input that is untrusted, and this error
/// is rendered in a toast.
fn validate_external_url(raw: &str) -> Result<String, String> {
// Rust's `trim` strips slightly more than JavaScript's (NEL, U+0085, for
// one), so a string the frontend would have rejected can reach the parser
// here with its edges shaved. That only ever removes outer whitespace —
// everything that survives still has to pass every check below — so the
// divergence cannot widen what gets opened.
let candidate = raw.trim();
if candidate.is_empty() {
return Err("Refused to open an empty URL.".to_string());
}
if candidate.len() > MAX_URL_LEN {
return Err(format!(
"Refused to open a URL longer than {MAX_URL_LEN} characters."
));
}
if has_forbidden_char(candidate) {
return Err(
"Refused to open a URL containing whitespace, quotes or control characters."
.to_string(),
);
}
let parsed = Url::parse(candidate).map_err(|_| "Refused to open a malformed URL.".to_string())?;
// Scheme allowlist. Nothing else, ever — `file:`, `javascript:`, `data:`
// and every registered protocol handler stay out of reach of the
// container. The scheme is safe to interpolate: the parser restricts it to
// ASCII alphanumerics, `+`, `-` and `.`.
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(format!(
"Refused to open a {}: URL — only http and https are allowed.",
parsed.scheme()
));
}
if parsed.host_str().is_none_or(str::is_empty) {
return Err("Refused to open a URL with no host.".to_string());
}
// `https://claude.ai@evil.tld/x` reads as claude.ai anywhere the string is
// truncated, and navigates to evil.tld.
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("Refused to open a URL containing embedded credentials.".to_string());
}
let normalized = parsed.to_string();
if normalized.len() > MAX_URL_LEN {
return Err(format!(
"Refused to open a URL longer than {MAX_URL_LEN} characters."
));
}
// A normalized http(s) URL is ASCII by construction — the host is
// punycoded and everything after it is percent-encoded. Asserting it means
// nothing non-ASCII can reach an `execvp` argument, whatever the parser
// decides to do in a future version.
if !normalized.is_ascii() {
return Err("Refused to open a URL with non-ASCII characters.".to_string());
}
Ok(normalized)
}
/// Openers to try, in order, each as (program, leading arguments).
///
/// `xdg-open` first because it is what the desktop expects to be asked and
/// honours the user's `mimeapps.list`. `gio open` second: it is present
/// wherever glib is (which, for a GTK app's host, is everywhere) and resolves
/// the handler through GIO rather than `xdg-open`'s shell heuristics, so it
/// still works when the `x-scheme-handler/https` association `xdg-open` looks
/// for is missing or points at something broken.
#[cfg(target_os = "linux")]
const OPENERS: &[(&str, &[&str])] = &[("xdg-open", &[]), ("gio", &["open"])];
/// How long a candidate opener is given to fail before it is assumed to have
/// worked.
///
/// `xdg-open` usually returns immediately (it hands the URL to a running
/// browser and exits), but in its generic fallback mode it *is* the browser's
/// parent and stays alive for the session. So "still running" cannot be read
/// as failure, and "exited non-zero quickly" is the only reliable signal
/// there is.
#[cfg(target_os = "linux")]
const OPENER_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
/// Spawn `url` with an opener, under a sanitized environment.
#[cfg(target_os = "linux")]
fn spawn_with_clean_env(url: &str) -> Result<(), String> {
let current: BTreeMap<String, String> = std::env::vars().collect();
let pristine = PRISTINE_ENV.get().cloned().unwrap_or_else(|| current.clone());
let appdir = current.get("APPDIR").cloned();
let changes = sanitize_child_env(&current, &pristine, appdir.as_deref());
let mut failures: Vec<String> = Vec::new();
for (program, leading) in OPENERS {
let mut command = std::process::Command::new(program);
command.args(*leading).arg(url);
// The bundle's own identity is not the child's business either, and a
// browser that re-execs itself through a wrapper script can pick these
// up.
for var in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] {
command.env_remove(var);
}
for (key, value) in &changes {
match value {
Some(value) => command.env(key, value),
None => command.env_remove(key),
};
}
// Detached: the opener must not inherit our stdio, or a browser
// writing to stderr keeps a pipe to us open for the session.
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
failures.push(format!("{program}: {err}"));
continue;
}
};
std::thread::sleep(OPENER_GRACE);
match child.try_wait() {
Ok(Some(status)) if !status.success() => {
failures.push(format!("{program} exited with {status}"));
continue;
}
Ok(_) => {}
Err(err) => {
failures.push(format!("{program}: could not be waited on: {err}"));
continue;
}
}
// Still running (it is the browser's parent) — reap it off-thread so it
// does not become a zombie for the life of the app.
std::thread::spawn(move || {
let _ = child.wait();
});
return Ok(());
}
Err(format!(
"Could not open the link. Tried: {}. Check that xdg-utils is installed and that a default browser is set.",
failures.join("; ")
))
}
/// Open `url` in the user's browser.
///
/// On Linux this goes through [`spawn_with_clean_env`] rather than
/// `@tauri-apps/plugin-opener`, for the AppImage reasons in this module's
/// documentation (triple-c#34). macOS and Windows keep the plugin's path —
/// neither has the environment problem, and `open`/`ShellExecute` are the
/// right calls there — but they are reached through this same command so the
/// frontend has one call site with one set of validation rules.
///
/// Errors are returned rather than logged-and-swallowed: "Open" silently doing
/// nothing is the bug being fixed, so the failure has to be something the UI
/// can show.
#[tauri::command]
pub async fn open_url_external(app: tauri::AppHandle, url: String) -> Result<(), String> {
let validated = validate_external_url(&url)?;
#[cfg(target_os = "linux")]
{
let _ = &app;
tauri::async_runtime::spawn_blocking(move || spawn_with_clean_env(&validated))
.await
.map_err(|err| format!("Could not open the link: {err}"))?
}
#[cfg(not(target_os = "linux"))]
{
use tauri_plugin_opener::OpenerExt;
app.opener()
.open_url(validated, None::<&str>)
.map_err(|err| format!("Could not open the link: {err}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
// ── URL re-validation ────────────────────────────────────────────────
#[test]
fn plain_http_and_https_urls_are_accepted() {
for url in [
"https://claude.ai/",
"http://localhost:1420/callback?code=abc",
"https://example.com/path#frag",
] {
assert!(validate_external_url(url).is_ok(), "{url} should be allowed");
}
}
#[test]
fn urls_are_returned_normalized() {
assert_eq!(
validate_external_url("https://Example.COM").unwrap(),
"https://example.com/"
);
}
#[test]
fn only_http_and_https_survive() {
for url in [
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,<script>",
"ftp://example.com/x",
"vscode://foo/bar",
"mailto:someone@example.com",
] {
assert!(
validate_external_url(url).is_err(),
"{url} must not be openable"
);
}
}
#[test]
fn embedded_credentials_are_refused() {
for url in [
"https://claude.ai@evil.tld/x",
"https://user:pass@example.com/",
"https://:pass@example.com/",
] {
assert!(
validate_external_url(url).is_err(),
"{url} must not be openable"
);
}
}
#[test]
fn control_characters_and_whitespace_are_refused() {
// `\n` in particular: parsers that strip it would turn the first of
// these into a `javascript:` URL.
for url in [
"java\nscript:alert(1)",
"https://example.com/\u{7f}",
"https://example.com/\u{85}x",
"https://example.com/a b",
"https://example.com/\u{00a0}x",
"https://example.com/\"",
"https://example.com/'",
"https://example.com/`",
] {
assert!(
validate_external_url(url).is_err(),
"{url:?} must not be openable"
);
}
}
#[test]
fn empty_and_oversized_are_refused() {
assert!(validate_external_url("").is_err());
assert!(validate_external_url(" ").is_err());
let long = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
assert!(validate_external_url(&long).is_err());
}
#[test]
fn a_host_is_required() {
assert!(validate_external_url("https://").is_err());
assert!(validate_external_url("http://:8080/").is_err());
// Not a missing host: WHATWG's "special authority ignore slashes"
// state eats the third slash, so this is the host `path` in both
// `new URL()` and here. Asserted so the parity is on the record.
assert_eq!(
validate_external_url("http:///path").unwrap(),
"http://path/"
);
}
#[test]
fn error_messages_never_echo_the_input() {
// The input is attacker-controlled and the message goes into a toast.
let err = validate_external_url("file:///home/someone/.ssh/id_rsa").unwrap_err();
assert!(!err.contains("id_rsa"), "message leaked the input: {err}");
}
// ── Environment sanitization ─────────────────────────────────────────
#[test]
fn appdir_entries_are_stripped_and_the_users_own_are_kept() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib:/opt/mine/lib"),
("XDG_DATA_DIRS", "/tmp/.mount_abc/usr/share:/usr/share"),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![
(
"LD_LIBRARY_PATH".to_string(),
Some("/opt/mine/lib".to_string())
),
("XDG_DATA_DIRS".to_string(), Some("/usr/share".to_string())),
]
);
}
#[test]
fn a_variable_that_is_entirely_appdir_is_removed() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
(
"GDK_PIXBUF_MODULE_FILE",
"/tmp/.mount_abc/usr/lib/gdk-pixbuf/loaders.cache",
),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![
("GDK_PIXBUF_MODULE_FILE".to_string(), None),
("GTK_PATH".to_string(), None),
]
);
}
#[test]
fn a_saved_original_is_restored_rather_than_unset() {
// Restoring beats unsetting: the user may have had one of their own.
for saved_as in ["LD_LIBRARY_PATH_ORIG", "APPIMAGE_ORIGINAL_LD_LIBRARY_PATH"] {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
(saved_as, "/home/someone/lib"),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![(
"LD_LIBRARY_PATH".to_string(),
Some("/home/someone/lib".to_string())
)],
"{saved_as} should be restored"
);
}
}
#[test]
fn an_empty_saved_original_means_it_was_unset() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
("LD_LIBRARY_PATH_ORIG", ""),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(changes, vec![("LD_LIBRARY_PATH".to_string(), None)]);
}
#[test]
fn our_own_set_var_is_undone_from_the_pristine_snapshot() {
// The leak `main.rs` documents: we set this after start-up, so the
// start-up snapshot is what says it should not exist at all.
let pristine = map(&[("HOME", "/home/someone")]);
let current = map(&[
("HOME", "/home/someone"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]);
let changes = sanitize_child_env(&current, &pristine, None);
assert_eq!(
changes,
vec![("WEBKIT_DISABLE_DMABUF_RENDERER".to_string(), None)]
);
}
#[test]
fn a_value_the_user_set_themselves_is_left_alone() {
let pristine = map(&[("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]);
let current = pristine.clone();
assert!(sanitize_child_env(&current, &pristine, None).is_empty());
}
#[test]
fn outside_an_appimage_nothing_is_touched() {
let env = map(&[
("PATH", "/usr/bin:/bin"),
("LD_LIBRARY_PATH", "/opt/mine/lib"),
("XDG_DATA_DIRS", "/usr/share"),
]);
assert!(
sanitize_child_env(&env, &env, None).is_empty(),
"a dev build or distro build must not have its environment rewritten"
);
}
#[test]
fn nothing_is_invented_for_variables_that_were_never_set() {
let env = map(&[("APPDIR", "/tmp/.mount_abc")]);
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
}
#[test]
fn a_prefix_that_merely_looks_like_appdir_is_not_stripped() {
// `/tmp/.mount_abc-other` is not inside `/tmp/.mount_abc`.
let env = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc-other/lib"),
]);
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
}
#[test]
fn a_trailing_slash_on_appdir_still_matches() {
let env = map(&[
("APPDIR", "/tmp/.mount_abc/"),
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
]);
let changes = sanitize_child_env(&env, &env, Some("/tmp/.mount_abc/"));
assert_eq!(changes, vec![("GTK_PATH".to_string(), None)]);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useInstallHelper } from "../hooks/useInstallHelper";
import { openUrlExternal } from "../lib/tauri-commands";
import { useDocker } from "../hooks/useDocker";
import Modal from "./ui/Modal";
import Button from "./ui/Button";
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
const handleOpenDocs = async () => {
if (!options) return;
try {
await openUrl(options.docs_url);
await openUrlExternal(options.docs_url);
} catch (e) {
console.error("Failed to open docs URL:", e);
}
@@ -23,6 +23,7 @@ import {
setBrowserViewMatchWindow,
setBrowserViewPopoutAlwaysOnTop,
} from "../../../lib/tauri-commands";
import { isBrowserViewUsable } from "../../../lib/browserViewSupport";
import { useAppState } from "../../../store/appState";
import OpenPageDialog from "./OpenPageDialog";
import AccordionSection from "../../ui/AccordionSection";
@@ -338,7 +339,7 @@ export default function BrowserTab({ project, active }: Props) {
// Prefer the probe: it is the fresher of the two, and it is the one that
// reflects an install that just finished.
const probed = detection ?? status.detection;
const ready = isUsable(probed);
const ready = isBrowserViewUsable(probed);
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
// apt package, so it never shows up in `browsers`, and a container that has
// it is not missing a browser.
@@ -539,11 +540,6 @@ export default function BrowserTab({ project, active }: Props) {
);
}
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
function isUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/**
* Mirrors Rust `PlaywrightDetection::revision_skew`.
*
@@ -627,7 +623,7 @@ function Setup({
onInstall: (which: Exclude<SetupJob, null>) => void;
}) {
const busy = job !== null;
const havePackages = isUsable(detection);
const havePackages = isBrowserViewUsable(detection);
const missing = missingParts(detection);
const browsers = detection?.browsers ?? [];
const chrome = detection?.chrome_channel ?? null;
@@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({
hasClaudeToken: vi.fn(),
clearClaudeToken: vi.fn(),
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
}));
const cancelClaudeToken = vi.fn(() => Promise.resolve());
const openUrl = vi.fn();
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: (...args: unknown[]) => openUrl(...args),
}));
const openUrlExternal = vi.fn();
/** Captured event handlers, keyed by event name, so tests can emit. */
const handlers = new Map<string, (event: { payload: unknown }) => void>();
@@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => {
const link = await screen.findByRole("link", { name: url });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
});
it("ignores output belonging to a different project", async () => {
@@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => {
const link = await screen.findByRole("link", { name: FULL_URL });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
});
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
@@ -270,7 +268,7 @@ describe("ClaudeAuthModal", () => {
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(openUrl).not.toHaveBeenCalled();
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("ignores a hyperlink belonging to a different project", async () => {
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { cancelClaudeToken } from "../../lib/tauri-commands";
import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
@@ -118,7 +117,7 @@ export default function ClaudeAuthModal({
return;
}
try {
await openUrl(target);
await openUrlExternal(target);
} catch (e) {
setLinkError(
authErrorMessage(
+2 -2
View File
@@ -1,5 +1,5 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "../../lib/types";
import { openUrlExternal } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { formatBytes } from "../../lib/formatBytes";
@@ -19,7 +19,7 @@ export default function UpdateDialog({
}: Props) {
const handleDownload = async (url: string) => {
try {
await openUrl(url);
await openUrlExternal(url);
} catch (e) {
console.error("Failed to open URL:", e);
}
@@ -2,7 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
import {
uploadHostFileToTerminal,
openUrlExternal,
} from "../../lib/tauri-commands";
import {
chooseSignInTarget,
resetBrowserSupportCache,
} from "../../hooks/useSignInOpenTarget";
import type { AuthBridgeStatus, PlaywrightDetection } from "../../lib/types";
import { URL_TOAST_SELECTOR } from "./UrlToast";
/**
@@ -16,6 +24,18 @@ const dragDrop = vi.hoisted(() => ({
handler: null as null | ((event: unknown) => unknown),
}));
/**
* What the project's container answers about itself.
*
* `TerminalView` asks two questions on mount — is the auth bridge live, and is
* there a browser inside to open a page in — because together they decide which
* of the URL toast's two buttons leads for a sign-in link.
*/
const containerEnv = vi.hoisted(() => ({
bridge: { enabled: false, active_ports: [], conflicts: [] } as unknown,
detection: null as unknown,
}));
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */
const ptyOutput = vi.hoisted(() => ({
listeners: new Map<string, (e: { payload: number[] }) => void>(),
@@ -45,6 +65,9 @@ vi.mock("../../lib/tauri-commands", () => ({
awsSsoRefresh: vi.fn(async () => {}),
openPageInContainerBrowser: vi.fn(async () => ({ error: null })),
uploadHostFileToTerminal: vi.fn(async () => ""),
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
openUrlExternal: vi.fn(async () => {}),
}));
vi.mock("@tauri-apps/api/event", () => ({
@@ -54,10 +77,6 @@ vi.mock("@tauri-apps/api/event", () => ({
},
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(async () => {}),
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
@@ -128,6 +147,14 @@ beforeEach(() => {
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
dragDrop.handler = null;
ptyOutput.listeners.clear();
vi.mocked(openUrlExternal).mockReset();
vi.mocked(openUrlExternal).mockResolvedValue(undefined);
containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] };
containerEnv.detection = null;
// The Playwright probe is memoized across mounts (it is a container exec), so
// a case that changes the answer has to drop what an earlier one cached.
resetBrowserSupportCache();
useAppState.setState({ toasts: [] });
document.body.innerHTML = "";
useAppState.setState({ sessions: [] });
});
@@ -559,6 +586,214 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
});
});
/**
* A container with Playwright *and* a browser in the cache — i.e. one where
* "In container" would actually open something.
*/
function usableDetection(
over: Partial<PlaywrightDetection> = {},
): PlaywrightDetection {
return {
node_version: "v22.11.0",
playwright_version: "1.56.0",
playwright_path: "/workspace/node_modules/playwright",
playwright_cli: "/workspace/node_modules/playwright/cli.js",
has_bind: true,
cli_version: "1.56.0",
cli_entry: "/workspace/node_modules/@playwright/cli/index.js",
browsers: ["chromium-1200"],
chrome_channel: null,
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1200/chrome",
chromium_executable_exists: true,
script_playwright_version: "1.56.0",
script_chromium_executable: null,
script_chromium_executable_exists: false,
searched: [],
...over,
};
}
const LIVE_BRIDGE: AuthBridgeStatus = {
enabled: true,
active_ports: [],
conflicts: [],
};
describe("chooseSignInTarget — which action leads for a sign-in link", () => {
// The rule this replaced was "container, always", justified by the callback
// listener living inside the container. Both halves of that justification
// stopped being true: the auth bridge mirrors that listener onto the host,
// and the container-side target is Playwright's pane, whose browsers are not
// in the image.
it("prefers the host browser whenever the bridge is live", () => {
expect(chooseSignInTarget(LIVE_BRIDGE, usableDetection())).toBe("host");
});
it("does not call a bridge live while it is holding a port conflict", () => {
// Enabled and unable to catch the callback anyway — the one state where
// "on" must not read as "will work".
const conflicted: AuthBridgeStatus = {
enabled: true,
active_ports: [],
conflicts: [{ port: 54545, reason: "already in use on the host" }],
};
expect(chooseSignInTarget(conflicted, usableDetection())).toBe("container");
});
it("does not wait for a bridged port before trusting an enabled bridge", () => {
// There is nothing to bridge until the CLI binds its listener, and that
// races the URL reaching the transcript. Requiring a port would make the
// default flip between two identical sign-ins.
expect(chooseSignInTarget(LIVE_BRIDGE, null)).toBe("host");
});
it("falls to the container only when it has a browser to open", () => {
const off: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
expect(chooseSignInTarget(off, usableDetection())).toBe("container");
expect(chooseSignInTarget(off, null)).toBe("host");
// Packages installed, cache empty — the fresh-project state, and the one
// that used to be the silent default.
expect(
chooseSignInTarget(
off,
usableDetection({ browsers: [], chromium_executable_exists: false }),
),
).toBe("host");
// Playwright too old to bind: the pane cannot show it either.
expect(chooseSignInTarget(off, usableDetection({ has_bind: false }))).toBe("host");
});
it("answers host when nothing is known at all", () => {
expect(chooseSignInTarget(null, null)).toBe("host");
});
});
describe("TerminalView — the sign-in default follows the project", () => {
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
function relaySequence(url: string): number[] {
return Array.from(
new TextEncoder().encode(`\x1b]7777;open;${btoa(url)}\x07`),
);
}
async function mountWithPrompt() {
const view = mountSession("claude");
await act(async () => {});
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: relaySequence(SIGN_IN) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
return view;
}
function primaryLabel(): string | null {
return document.querySelector<HTMLElement>(
'[data-url-toast-primary="true"]',
)?.textContent ?? null;
}
function actionOrder(): (string | null)[] {
return Array.from(document.querySelectorAll("button"))
.map((b) => b.textContent)
.filter((t) => t === "Open" || t === "In container");
}
it("leads with the host browser when the auth bridge is on", async () => {
containerEnv.bridge = LIVE_BRIDGE;
containerEnv.detection = usableDetection();
await mountWithPrompt();
expect(primaryLabel()).toBe("Open");
// Both are still offered — this changes which leads, never which exist.
expect(actionOrder()).toEqual(["Open", "In container"]);
});
it("leads with the container when the bridge is off and a browser is there", async () => {
containerEnv.detection = usableDetection();
await mountWithPrompt();
expect(primaryLabel()).toBe("In container");
expect(actionOrder()).toEqual(["In container", "Open"]);
});
it("leads with the host on a fresh project, where neither is set up", async () => {
// Playwright is deliberately not baked into the image, so this is what a
// project looks like until someone presses install — and pointing the
// default at it failed on every platform, silently.
await mountWithPrompt();
expect(primaryLabel()).toBe("Open");
});
});
describe("TerminalView — a host open that fails says so", () => {
const URL = "https://github.com/login/device?code=ABCD-EFGH";
function relaySequence(url: string): number[] {
return Array.from(
new TextEncoder().encode(`\x1b]7777;open;${btoa(url)}\x07`),
);
}
async function mountWithPrompt() {
const view = mountSession("claude");
await act(async () => {});
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: relaySequence(URL) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
return view;
}
function openButton(): HTMLElement {
const el = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Open",
);
if (!el) throw new Error("Open button not found");
return el as HTMLElement;
}
it("pushes a toast instead of a console line nobody reads", async () => {
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
await Promise.resolve();
});
const toasts = useAppState.getState().toasts;
expect(toasts).toHaveLength(1);
expect(toasts[0].kind).toBe("error");
expect(toasts[0].detail).toContain("no opener");
});
it("keeps the prompt on screen, so the other route is still one click away", async () => {
// Dismissing first is what this replaced: the toast vanished, nothing
// opened, and the URL only existed in the container's transcript.
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
await Promise.resolve();
});
expect(document.querySelector(URL_TOAST_SELECTOR)).not.toBeNull();
});
it("dismisses the prompt once the handoff actually succeeded", async () => {
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(URL);
expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull();
});
});
describe("TerminalView — focus on request", () => {
/** Mount, then deliberately give focus away, so what the assertions below
* observe is the *request* taking effect and never the focus `active`
+56 -5
View File
@@ -3,7 +3,6 @@ import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
@@ -11,6 +10,7 @@ import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import {
awsSsoRefresh,
openPageInContainerBrowser,
openUrlExternal,
uploadHostFileToTerminal,
} from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
@@ -23,6 +23,7 @@ import {
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR,
@@ -409,7 +410,18 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("Refusing to open a link that failed validation");
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
// Same failure reporting as the toast's Open button — see the long note
// on `handleOpenUrl`, including what this catch does *not* catch on
// Linux. A click that appears to do nothing is the complaint either way.
openUrlExternal(safe).catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open that link in your browser",
detail: String(e),
// A dead opener fails for every link in the buffer. One card.
dedupeKey: "host-open-failed",
}),
);
}, { urlRegex });
term.loadAddon(webLinksAddon);
@@ -786,20 +798,58 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => clearTimeout(timer);
}, [imagePasteMsg]);
/**
* Hand the prompted URL to the host's browser.
*
* Two things here are ordering, not decoration:
*
* - **The toast is dismissed on success only.** It used to go first, so a
* failed open left the user with an empty screen and no way back to a URL
* that only exists in the container's transcript. Now a failure keeps the
* prompt exactly where it was, which also leaves "In container" one click
* away — the fallback this failure is the argument for.
* - **The failure is a toast, not a `console.error`.** Same `pushToast` the
* container-browser branch below uses, because from the user's side the
* two actions fail identically: nothing happens.
*
* What this does *not* cover, and must not be described as covering: on Linux
* `xdg-open` routinely exits 0 having done nothing useful, so the most common
* Linux failure resolves this promise and reports success. Stripping the
* leaked AppImage environment before the browser is spawned is what addresses
* that; this is the complement that catches everything which does report.
*/
const handleOpenUrl = useCallback(() => {
if (!urlPrompt) return;
// Validated again at the sink. `promptUrl` is the only writer and already
// sanitizes, so this can only fail if that invariant is broken — which is
// precisely when it matters that the last thing before `openUrl` checks.
// precisely when it matters that the last thing before the opener checks.
const safe = sanitizeRelayUrl(urlPrompt.url);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
dismissUrlPrompt();
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
openUrlExternal(safe)
.then(() => dismissUrlPrompt())
.catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open it in your browser",
detail: String(e),
dedupeKey: "host-open-failed",
}),
);
}, [urlPrompt, dismissUrlPrompt]);
/**
* Which action leads when the prompt is holding an Anthropic sign-in link.
*
* Resolved per project, not per URL — see `useSignInOpenTarget`. The toast
* offers both regardless; this is only which one is filled in and reachable
* with {@link URL_TOAST_SHORTCUT}.
*/
const signInDefault = useSignInOpenTarget(projectId);
/**
* Open the prompted URL in the container's own browser instead of the host's.
*
@@ -896,6 +946,7 @@ export default function TerminalView({ sessionId, active }: Props) {
label={urlPrompt.label}
onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer}
signInDefault={signInDefault}
onDismiss={dismissUrlPrompt}
/>
)}
+50 -6
View File
@@ -105,6 +105,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -150,6 +151,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -166,10 +168,11 @@ describe("UrlToast", () => {
describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in
// and then posts the result where nothing is listening, and the terminal
// hangs to its timeout — so for these, and only these, the container-side
// browser leads.
// container, so a sign-in is the one case where the host browser may be the
// wrong lead. Whether it actually is depends on the project — a live auth
// bridge carries the callback back, and the container-side alternative is
// not installed on a fresh project — so the owner decides and passes
// `signInDefault`. This component only renders the decision.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
@@ -180,12 +183,13 @@ describe("UrlToast", () => {
.filter((t) => t === "Open" || t === "In container");
}
it("puts the container browser first", () => {
it("puts the container browser first when the caller asks for it", () => {
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -195,6 +199,42 @@ describe("UrlToast", () => {
);
});
it("leads with the host when the caller says so, without hiding the other", () => {
// A live auth bridge, or a container with no browser installed. The pair
// is unchanged; only the order and which one is filled.
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
signInDefault="host"
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("Open");
// Still recognised as a sign-in, so the explanation stays.
expect(screen.getByTestId("url-toast-signin-hint")).toHaveTextContent(
/auth bridge/i,
);
});
it("defaults to the host when the caller passes nothing", () => {
// The safe fallback: the answer more likely to work, and the one that
// reports its own failure.
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(actions()).toEqual(["Open", "In container"]);
});
it("keeps the host browser available as a fallback", () => {
const onOpen = vi.fn();
render(
@@ -202,6 +242,7 @@ describe("UrlToast", () => {
url={SIGN_IN}
onOpen={onOpen}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -211,12 +252,14 @@ describe("UrlToast", () => {
it("leaves an ordinary URL alone", () => {
// A `gh auth login` device code, a docs page, a preview build — the host
// browser is the right answer for all of them and stays the default.
// browser is the right answer for all of them and stays the default,
// whatever the project's sign-in preference happens to be.
render(
<UrlToast
url="https://github.com/login/device?code=ABCD-EFGH"
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
@@ -232,6 +275,7 @@ describe("UrlToast", () => {
url="https://claude.ai.evil.tld/oauth/authorize?x=1"
onOpen={noop}
onOpenInContainer={noop}
signInDefault="container"
onDismiss={noop}
/>,
);
+38 -18
View File
@@ -37,6 +37,18 @@ interface Props {
/** Open it in the container's own browser instead of the host's. Omitted when
* the project has no browser to open it in. */
onOpenInContainer?: () => void;
/**
* Which action leads for a *sign-in* link (see the note below). Nothing else
* in the toast moves: both buttons are offered either way, in either order.
*
* This component does not work it out, because the answer depends on the
* project's auth bridge and on what is installed inside its container —
* neither of which a presentational component should be reaching for.
* `hooks/useSignInOpenTarget.ts` owns the rule. `"host"` is the default here
* for the same reason it is the fallback there: it is the answer that is more
* likely to work, and the one that reports its own failure.
*/
signInDefault?: "host" | "container";
onDismiss: () => void;
}
@@ -57,17 +69,20 @@ interface Props {
* text swaps with no animation, and a user reading URL A can click Open on URL
* B that arrived a second later.
*
* ## Anthropic sign-in links default to the container's browser
* ## Anthropic sign-in links get their default from the caller
*
* For an ordinary URL the host browser is the right answer and stays the
* default. For a sign-in it is the *wrong* one: the callback listener the CLI
* is waiting on is inside the container, so a host browser completes the sign-in
* and then posts the result somewhere nothing is listening, and the terminal
* hangs until it times out. Making the host button primary there was quietly
* steering every user into that. The container-side browser closes the loop
* with no host round trip and no auth bridge, so it leads — and the host button
* stays, because a user who has the auth bridge on, or who wants their existing
* browser session, still needs it.
* default, unconditionally. A sign-in is the one case where it might not be:
* the callback listener the CLI is waiting on is inside the container, so a
* host browser can complete the sign-in and then post the result where nothing
* is listening, leaving the terminal to hang to its timeout.
*
* *Can*, not *does* — which is why this is no longer decided from the URL. The
* auth bridge mirrors that container listener onto the same host port, and the
* container-side alternative is Playwright's dashboard pane, which a fresh
* project has not installed. Both of those are project facts, so the owner
* passes {@link Props.signInDefault} and this only renders it: the leading
* button is filled and comes first, the other keeps its place beside it.
*
* ## Reachable without a mouse, and it does not take focus to manage it
*
@@ -96,6 +111,7 @@ export default function UrlToast({
label = "Long URL detected",
onOpen,
onOpenInContainer,
signInDefault = "host",
onDismiss,
}: Props) {
const origin = urlOrigin(url);
@@ -103,18 +119,22 @@ export default function UrlToast({
// Only when there is somewhere to send it: without `onOpenInContainer` the
// host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
// A sign-in link the caller has decided is better completed inside the
// container. Everything below keys off this rather than off `signIn`, so the
// two orderings differ only in which of the pair leads.
const containerLeads = signIn && signInDefault === "container";
// `Button` already owns the filled/outlined variants — including the rule
// that filled uses `--accent-emphasis` and never `--accent`, which is the
// foreground/link accent and fails WCAG AA behind white text.
const hostButton = (
<Button
variant={signIn ? "secondary" : "primary"}
data-url-toast-primary={signIn ? undefined : "true"}
variant={containerLeads ? "secondary" : "primary"}
data-url-toast-primary={containerLeads ? undefined : "true"}
onClick={onOpen}
className="flex-shrink-0"
title={
signIn
containerLeads
? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined
}
@@ -128,8 +148,8 @@ export default function UrlToast({
// the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge.
<Button
variant={signIn ? "primary" : "secondary"}
data-url-toast-primary={signIn ? "true" : undefined}
variant={containerLeads ? "primary" : "secondary"}
data-url-toast-primary={containerLeads ? "true" : undefined}
onClick={onOpenInContainer}
className="flex-shrink-0"
title="Open in a browser inside the container, and watch it in the Browser tab"
@@ -235,14 +255,14 @@ export default function UrlToast({
lineHeight: 1.35,
}}
>
Sign-in link the callback listener is inside the container.
Opening it there closes the loop; the host browser needs the auth
bridge.
{containerLeads
? "Sign-in link — the callback listener is inside the container. Opening it there closes the loop; the host browser needs the auth bridge."
: "Sign-in link — the callback listener is inside the container. The auth bridge is what carries the callback back to it from your own browser."}
</div>
)}
</div>
{signIn ? (
{containerLeads ? (
<>
{containerButton}
{hostButton}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
checkBrowserViewSupport,
getAuthBridgeStatus,
} from "../lib/tauri-commands";
import { canOpenPageInContainerBrowser } from "../lib/browserViewSupport";
import type {
AuthBridgeChangedEvent,
AuthBridgeStatus,
PlaywrightDetection,
} from "../lib/types";
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
/** Which of the URL toast's two buttons should lead for a sign-in link. */
export type SignInOpenTarget = "host" | "container";
/**
* Whether the auth bridge can be relied on to catch a callback for this
* project.
*
* Deliberately **not** gated on `active_ports` being non-empty. There is only
* something to bridge once the CLI has bound its callback listener, and the
* order in which that happens against the URL landing in the transcript is not
* ours to control — requiring a port here would make the answer depend on a
* race and flip the default button between two otherwise identical sign-ins.
* `enabled` is the durable fact: the poller is watching, and it will mirror the
* port the moment it appears.
*
* A conflict is the exception, because it is the one state where the bridge is
* on and nevertheless *cannot* catch the callback — the host port it needed was
* already taken. That is precisely when the container-side browser is the
* better default, so it must not read as live.
*/
export function authBridgeIsLive(status: AuthBridgeStatus | null): boolean {
if (!status || !status.enabled) return false;
return status.conflicts.length === 0;
}
/**
* The rule, as a pure function of the two things it depends on.
*
* Both fallbacks land on the host, for different reasons:
*
* - With the bridge live, the host browser is strictly better — it is the
* user's own signed-in profile, and the callback still reaches the container.
* - With neither available, the host is the *more likely to work* of two
* imperfect answers, and it is the one that reports its own failure (see
* `handleOpenUrl` in `TerminalView`). The container-side target is
* Playwright's dashboard pane, and Playwright's browsers are not baked into
* the image, so on a fresh project pointing there fails on every platform
* after a several-second wait.
*
* Whichever way it goes, both buttons stay in the toast. This chooses which one
* leads, never which ones exist.
*/
export function chooseSignInTarget(
bridge: AuthBridgeStatus | null,
detection: PlaywrightDetection | null,
): SignInOpenTarget {
if (authBridgeIsLive(bridge)) return "host";
if (canOpenPageInContainerBrowser(detection)) return "container";
return "host";
}
/**
* How long a Playwright probe is reused for.
*
* `check_browser_view_support` is a `docker exec` running a Node probe, and
* every terminal tab of a project would otherwise run its own on mount. Five
* minutes is long enough that opening a handful of tabs costs one exec, and
* short enough that pressing "Set up Playwright" in the Browser tab is
* reflected in the default before the user has finished reading the result.
*/
const DETECTION_TTL_MS = 5 * 60_000;
const detectionCache = new Map<
string,
{ at: number; probe: Promise<PlaywrightDetection | null> }
>();
/** The shared, rate-limited probe. Never rejects — "didn't answer" is `null`. */
function probeBrowserSupport(projectId: string): Promise<PlaywrightDetection | null> {
const hit = detectionCache.get(projectId);
if (hit && Date.now() - hit.at < DETECTION_TTL_MS) return hit.probe;
const probe = checkBrowserViewSupport(projectId).catch(() => {
// A failure is usually a stopped container, which is a state the user
// leaves — so it is not worth remembering for five minutes.
detectionCache.delete(projectId);
return null;
});
detectionCache.set(projectId, { at: Date.now(), probe });
return probe;
}
/** Test seam: drops the memoized probes so a case starts from nothing. */
export function resetBrowserSupportCache(): void {
detectionCache.clear();
}
/**
* Resolve the default action for Anthropic sign-in links in this project.
*
* Resolved at mount rather than when a URL arrives, on purpose: the toast has
* two buttons side by side, and a default that settles a second after the
* toast appears moves them under a mouse that is already travelling.
*
* The expensive half is only paid when it can change the answer. The bridge
* status is host-side and cheap; the Playwright probe is a container exec, and
* a live bridge decides the question before it is ever asked — which, with the
* bridge now on by default, is the ordinary case.
*/
export function useSignInOpenTarget(projectId: string | undefined): SignInOpenTarget {
const [target, setTarget] = useState<SignInOpenTarget>("host");
useEffect(() => {
if (!projectId) {
setTarget("host");
return;
}
let cancelled = false;
let bridge: AuthBridgeStatus | null = null;
let detection: PlaywrightDetection | null = null;
const settle = () => {
if (!cancelled) setTarget(chooseSignInTarget(bridge, detection));
};
const consider = (next: AuthBridgeStatus) => {
bridge = next;
settle();
// Only now is the container's side of it worth an exec.
if (authBridgeIsLive(bridge)) return;
probeBrowserSupport(projectId).then((d) => {
if (cancelled) return;
detection = d;
settle();
});
};
getAuthBridgeStatus(projectId)
.then((s) => {
if (!cancelled) consider(s);
})
// Nothing to say to the user here: this only picks which button is
// filled in, and the fallback is the one that reports its own failures.
.catch(() => {
if (!cancelled) consider({ enabled: false, active_ports: [], conflicts: [] });
});
// The switch can be flipped *while a login is hanging* — that is the whole
// reason `set_auth_bridge_enabled` exists outside the Config tab's save —
// so the default has to follow it rather than reflect whatever was true
// when this terminal was opened.
let unlisten: (() => void) | undefined;
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
if (event.payload.project_id !== projectId) return;
consider(event.payload.status);
})
.then((un) => {
if (cancelled) un();
else unlisten = un;
})
.catch(() => {});
return () => {
cancelled = true;
unlisten?.();
};
}, [projectId]);
return target;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* What a container has to have before anything can be opened *inside* it.
*
* The Browser tab asks this to decide what to offer; the terminal's URL toast
* asks it to decide which of its two buttons should lead. Both need the same
* answer, so the predicates live here rather than beside either caller — the
* failure this avoids is the toast steering a user at a container-side browser
* that the Browser tab is, on the very same screen, offering to install.
*
* The important thing to know about `PlaywrightDetection` is that browsers are
* deliberately **not** baked into the image: the libraries they link against
* are, the binaries are a user-pressed install. So "Playwright is present" and
* "a page can actually be opened" are two different questions, and a fresh
* project answers yes to neither.
*/
import type { PlaywrightDetection } from "./types";
/**
* Mirrors Rust `PlaywrightDetection::is_usable` — the packages the live
* dashboard needs. Says nothing about whether a browser exists to show in it.
*/
export function isBrowserViewUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/**
* Whether `openPageInContainerBrowser` has a browser to launch.
*
* Stricter than {@link isBrowserViewUsable} on purpose: the packages can be
* installed with `~/.cache/ms-playwright` still empty, which is exactly the
* state a `playwright install` step exists to leave behind, and launching into
* it fails several seconds after the click.
*
* Unknown reads as "no". A probe that could not run (stopped container, an
* image predating these fields) leaves the executable fields absent, and the
* caller's fallback — the host browser — is the one that at least reports its
* own failure. Over-refusing costs a user one extra click on a button that is
* still right there; over-accepting costs them a sign-in that goes nowhere.
*/
export function canOpenPageInContainerBrowser(d: PlaywrightDetection | null): boolean {
if (!isBrowserViewUsable(d) || !d) return false;
// The viewer's own Chromium, confirmed on disk by the probe.
if (d.chromium_executable_exists) return true;
// Google Chrome is an apt package, so it is never in `browsers` and has no
// revision to skew against.
if (d.chrome_channel !== null) return true;
// `== null`, not `=== null`: a probe from a container predating the
// executable fields omits them entirely, and `undefined` there means "didn't
// answer", not "missing". In that case a non-empty bundle list is the only
// evidence available, and it is better than nothing.
return d.chromium_executable == null && d.browsers.length > 0;
}
+15
View File
@@ -398,3 +398,18 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });
/** Open a URL in the user's own browser.
*
* Replaces `openUrl` from `@tauri-apps/plugin-opener` at every call site. On
* Linux the app ships as an AppImage whose environment leaks into everything
* it spawns, which kills a *cold-launched* browser before it paints while
* `xdg-open` still exits 0 — so the plugin path reported success and did
* nothing (triple-c#34). The Rust side hands the child a repaired environment
* and re-validates the URL, which matters because these URLs originate in an
* untrusted container. macOS and Windows still reach the plugin, just from
* Rust, so there is no platform branch here.
*
* Rejects with a string already phrased for a toast. */
export const openUrlExternal = (url: string) =>
invoke<void>("open_url_external", { url });
+3 -2
View File
@@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void;
* A direct port of `usable_sign_in_link` in
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
* only thing standing between any of this and `openUrl`, and duplicating its
* only thing standing between any of this and `openUrlExternal`, and
* duplicating its
* rules here would be a second place for them to go stale.
*
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
@@ -293,7 +294,7 @@ export class UrlDetector {
// include the *whole* C0 range and DEL, not just BEL: an escape or a NUL
// swallowed into the middle of a match becomes a URL that renders as one
// thing in the toast and resolves as another. Everything emitted here is
// still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`;
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
// stopping the match early only means the legitimate prefix survives
// instead of the whole candidate being thrown away.
// eslint-disable-next-line no-control-regex
+51
View File
@@ -4,6 +4,7 @@ import {
MAX_RELAY_URL_LENGTH,
RelayRateLimiter,
URL_RELAY_OSC,
isAnthropicSignInUrl,
parseUrlRelayOsc,
sanitizeRelayUrl,
urlOrigin,
@@ -321,3 +322,53 @@ describe("RelayRateLimiter", () => {
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
});
});
describe("isAnthropicSignInUrl", () => {
// Classification only. Where a sign-in link should be opened is decided by
// `hooks/useSignInOpenTarget.ts`, from facts about the project — this answers
// the narrower question of whether it is a sign-in link at all, and it does
// so through the same allowlist the sign-in flow itself uses.
it("recognises the links `claude setup-token` and `claude login` print", () => {
expect(
isAnthropicSignInUrl(
"https://claude.ai/oauth/authorize?code=true&client_id=abc",
),
).toBe(true);
expect(
isAnthropicSignInUrl("https://platform.claude.com/oauth/code/callback?x=1"),
).toBe(true);
expect(isAnthropicSignInUrl("https://console.anthropic.com/login?x=1")).toBe(
true,
);
});
it("is not fooled by a host that merely contains an allowed domain", () => {
// The thing the allowlist exists for: `claude.ai.evil.tld` ends with
// neither `claude.ai` nor `.claude.ai`.
expect(isAnthropicSignInUrl("https://claude.ai.evil.tld/oauth/authorize")).toBe(
false,
);
expect(isAnthropicSignInUrl("https://notclaude.ai/login")).toBe(false);
});
it("holds the full validator, not just the host test", () => {
// It runs `sanitizeRelayUrl`, so everything that cannot be opened at all
// is not a sign-in link either — no separate, weaker copy of the rules.
expect(isAnthropicSignInUrl("javascript:claude.ai/login")).toBe(false);
expect(isAnthropicSignInUrl("https://claude.ai@evil.tld/login")).toBe(false);
expect(isAnthropicSignInUrl("https://claude\nai/login")).toBe(false);
});
it("does not claim every allowlisted URL is a sign-in", () => {
expect(isAnthropicSignInUrl("https://claude.ai/chat/abc")).toBe(false);
expect(isAnthropicSignInUrl("https://www.anthropic.com/news")).toBe(false);
});
it("leaves an ordinary link alone, whatever it says in its path", () => {
// A `gh auth login` device code is the common one, and sending it to a
// container-side browser would be actively wrong.
expect(isAnthropicSignInUrl("https://github.com/login/device?code=A")).toBe(
false,
);
});
});
+18 -6
View File
@@ -1,6 +1,7 @@
/**
* URL relay — host side of `container/triple-c-open` — and the single URL
* validator every `openUrl` call site in the app is required to go through.
* validator every `openUrlExternal` call site in the app is required to go
* through.
*
* A CLI inside the container has no browser. When it wants to open a URL
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring
@@ -182,11 +183,22 @@ export function extendsUrl(next: string, current: string): boolean {
/**
* Whether this is a URL that signs the user in to Anthropic.
*
* Used to decide *presentation*, not permission — the toast makes the
* container-side browser the default action for these, because the OAuth
* callback listener is inside the container and the host has nothing to catch
* it with. It is deliberately the same host allowlist the sign-in flow itself
* uses, so the two cannot disagree about what a sign-in link is.
* Classification only. It answers "is this a sign-in link", never "where should
* it be opened" — that decision moved out to `hooks/useSignInOpenTarget.ts`,
* because it depends on things this module has no business knowing: whether the
* project's auth bridge is live, and whether a browser is actually installed in
* the container. This function stays here because the *rule* it encodes is a
* URL rule, and it is deliberately the same host allowlist the sign-in flow
* itself uses, so the two cannot disagree about what a sign-in link is.
*
* It used to carry the default with it — container-side always, on the grounds
* that "the OAuth callback listener is inside the container and the host has
* nothing to catch it with". Both halves of that are now wrong. The host does
* have something to catch it with (the auth bridge mirrors the container's
* loopback listener onto the same host port), and the container-side target is
* not a general browser but Playwright's dashboard pane, whose browsers are
* deliberately not baked into the image — so on a fresh project the default
* pointed at something that was not installed, on every platform.
*/
export function isAnthropicSignInUrl(url: string): boolean {
const safe = sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS });