Three separate symptoms that turned out to share one root cause.
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 — but it defaulted to off, so a hang was the out-of-the-box experience.
Meanwhile isAnthropicSignInUrl made the container the default action for every 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 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.
On Linux the host path was independently broken. The app ships as a single AppImage whose environment leaks into everything it spawns — LD_LIBRARY_PATH, GTK_PATH, GIO_MODULE_DIR and friends under $APPDIR, plus the process-wide WEBKIT_DISABLE_DMABUF_RENDERER from main.rs. An already-running browser shrugs this off; a cold-launched one inherits the lot and dies before painting, with xdg-open still exiting 0. That is triple-c#34, and it is why the button looked like it did nothing at all.
And it reported nothing either way: dismissUrlPrompt() ran beforeopenUrl, so the toast vanished and a rejected promise reached only the devtools console.
Changes
Commit
90b7e4c
auth_bridge_enabled defaults to true; browser-view flag made durable
bf8094d
Sign-in links route by what can actually catch the callback; opener failures surface as toasts
Existing projects get the bridge. The default applies at deserialisation, and migrate_from_value writes neither flag, so a projects.json record predating the feature picks it up. A regression test pins that against MAIN_SHAPE_PROJECT, a real record written by a shipped binary.
The bridge counts as live on enabled && no conflicts, not on active_ports being non-empty. There is nothing to bridge until the CLI binds its listener, and that races the URL reaching the transcript — gating on ports would flip the default button between two identical sign-ins. A port conflict is the genuine "on but cannot catch it" state.
The durable browser-view flag removed the cache rather than syncing it.BrowserViewManager lost its enabled: Mutex<HashSet<String>>; the project record is now the single home, mirroring AuthBridgeManager. stop() deliberately does not clear it — container teardown and migration reach that path and neither is the user changing their mind.
No XDG portal.org.freedesktop.portal.OpenURI would pull a D-Bus client stack in 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.
opener:allow-open-url is gone.default.json carried it as an explicitly accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL. With no frontend caller left, that closes rather than stays recorded. The plugin remains a dependency — macOS and Windows reach it through OpenerExt::open_url, whose desktop implementation calls crate::open::open directly and is not gated by capabilities (verified against tauri-plugin-opener-2.5.3/src/lib.rs:60, since the whole point is that the Rust path keeps working).
Scope creep, declared
Wiring up open_url_external revealed the same Linux bug in three dialogs nobody had mentioned — DockerInstallDialog's docs link, ClaudeAuthModal's sign-in link, UpdateDialog's release link. All three would have reported success while launching nothing. They now share one sink. That in turn is what emptied out the capability grant.
sanitizeRelayUrl is byte-for-byte unchanged, so the embedded duplicate in web_terminal/terminal.html needs no matching edit and urlRelay.embedded.test.ts passes untouched
None of this is runtime-tested. The Windows callback, the Linux AppImage launch and the sign-in button ordering all need a real build on a real desktop; unit tests cannot reach any of them. The AppImage path especially — its whole premise is behaviour that only exists inside a packaged bundle. Relatedly, which *_ORIG save convention linuxdeploy's AppRun uses at runtime cannot be established from the tree, since Tauri fetches linuxdeploy at build time and finalize-appimage.sh only patches the generated AppRun; both spellings are checked defensively and the $APPDIR-entry filter is the load-bearing mechanism regardless.
## What this fixes
Three separate symptoms that turned out to share one root cause.
`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 — but it defaulted to **off**, so a hang was the out-of-the-box experience.
Meanwhile `isAnthropicSignInUrl` made the **container** the default action for every 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 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.
On Linux the host path was independently broken. The app ships as a single AppImage whose environment leaks into everything it spawns — `LD_LIBRARY_PATH`, `GTK_PATH`, `GIO_MODULE_DIR` and friends under `$APPDIR`, plus the process-wide `WEBKIT_DISABLE_DMABUF_RENDERER` from `main.rs`. An already-running browser shrugs this off; a **cold-launched** one inherits the lot and dies before painting, with `xdg-open` still exiting 0. That is triple-c#34, and it is why the button looked like it did nothing at all.
And it reported nothing either way: `dismissUrlPrompt()` ran *before* `openUrl`, so the toast vanished and a rejected promise reached only the devtools console.
## Changes
| Commit | |
|---|---|
| `90b7e4c` | `auth_bridge_enabled` defaults to true; browser-view flag made durable |
| `bf8094d` | Sign-in links route by what can actually catch the callback; opener failures surface as toasts |
| `9297020` | `open_url_external` — env-sanitized spawn + Rust-side re-validation |
| `5a09254` | Every host-browser open routed through it |
| `5a452e7` `db64823` | `opener:allow-open-url` dropped |
| `afe9d5c` | BUILDING.md packaging corrected |
### Notes on specific decisions
**Existing projects get the bridge.** The default applies at deserialisation, and `migrate_from_value` writes neither flag, so a `projects.json` record predating the feature picks it up. A regression test pins that against `MAIN_SHAPE_PROJECT`, a real record written by a shipped binary.
**The bridge counts as live on `enabled && no conflicts`**, not on `active_ports` being non-empty. There is nothing to bridge until the CLI binds its listener, and that races the URL reaching the transcript — gating on ports would flip the default button between two identical sign-ins. A port *conflict* is the genuine "on but cannot catch it" state.
**The durable browser-view flag removed the cache rather than syncing it.** `BrowserViewManager` lost its `enabled: Mutex<HashSet<String>>`; the project record is now the single home, mirroring `AuthBridgeManager`. `stop()` deliberately does not clear it — container teardown and migration reach that path and neither is the user changing their mind.
**No XDG portal.** `org.freedesktop.portal.OpenURI` would pull a D-Bus client stack in 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.
**`opener:allow-open-url` is gone.** `default.json` carried it as an explicitly accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL. With no frontend caller left, that closes rather than stays recorded. The plugin remains a dependency — macOS and Windows reach it through `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is not gated by capabilities (verified against `tauri-plugin-opener-2.5.3/src/lib.rs:60`, since the whole point is that the Rust path keeps working).
## Scope creep, declared
Wiring up `open_url_external` revealed the same Linux bug in three dialogs nobody had mentioned — DockerInstallDialog's docs link, ClaudeAuthModal's sign-in link, UpdateDialog's release link. All three would have reported success while launching nothing. They now share one sink. That in turn is what emptied out the capability grant.
## Verification
- Rust: **577 passed, 0 failed**
- Frontend: **774 passed, 0 failed**, `tsc --noEmit` clean
- `sanitizeRelayUrl` is byte-for-byte unchanged, so the embedded duplicate in `web_terminal/terminal.html` needs no matching edit and `urlRelay.embedded.test.ts` passes untouched
**None of this is runtime-tested.** The Windows callback, the Linux AppImage launch and the sign-in button ordering all need a real build on a real desktop; unit tests cannot reach any of them. The AppImage path especially — its whole premise is behaviour that only exists inside a packaged bundle. Relatedly, which `*_ORIG` save convention linuxdeploy's AppRun uses at runtime cannot be established from the tree, since Tauri fetches linuxdeploy at build time and `finalize-appimage.sh` only patches the generated AppRun; both spellings are checked defensively and the `$APPDIR`-entry filter is the load-bearing mechanism regardless.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
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>
`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>
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>
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>
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>
Tracked build output; regenerated by the Tauri build from
capabilities/default.json.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from review of this branch.
Awaiting the open instead of dismissing up front bought a window: on Linux
it is at least OPENER_GRACE, doubled when xdg-open fails and gio is tried.
If the container relays a second URL inside that window, the first open's
resolution blanked the second prompt -- losing a link that exists only in
the container's transcript, which is the failure "dismiss on success only"
was made to prevent. The slot already carried a `seq` for exactly this
reason; dismissal is now conditional on it.
`urlPromptRef` is written eagerly by the two functions that change the slot
rather than synced by an effect. That is load-bearing: an effect-synced
mirror lags state by a commit, and a promise microtask can resolve between
`setUrlPrompt` and React flushing passive effects -- so it answers "did a
newer prompt land?" wrong in precisely the window the guard exists for.
Dropping the functional updater also fixes `promptSeqRef.current += 1`
being mutated inside a state updater React is free to invoke twice.
The guard is a sibling function rather than an optional argument on
`dismissUrlPrompt`, because that function is passed by reference as
UrlToast's `onDismiss` and React would hand it a MouseEvent as its first
argument -- the seq check would fail and the close button would silently
stop working, with the types still assignable.
Separately, the sign-in hint was binary on which button leads, but "host
leads" covers both a live bridge and a fallback where nothing is set up to
catch the callback at all. In the second case the toast promised the bridge
would carry it and the login hung to its timeout. The target is now
three-state, the hint tells the truth in the fallback case and names the
control that fixes it, and the hook starts at `host-fallback` rather than
assuming a bridge it has not confirmed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of this branch found that `update_project` restored
`browser_view_enabled` from the store but took `auth_bridge_enabled` from
the IPC payload, on a comment claiming the Config tab edits it through that
save. The comment was wrong. `AuthBridgeRow` is the only writer, it calls
`set_auth_bridge_enabled` out of band precisely so the switch works while a
login is hanging, and it never writes the value back into frontend state --
so a payload's copy of that flag is always a stale snapshot.
The consequence was not cosmetic: turn the bridge off, then close a renamed
terminal tab, and `useTerminal` round-trips the stale `true` and the
reconcile block restarts a bridge whose own UI warns that a bridged port is
unauthenticated and reachable by any local process. Defaulting the flag to
true earlier in this branch made it worse, since the stale value is now
true for every pre-existing project.
Both flags are now restored from the store by `restore_store_owned_fields`,
and the reconcile block is gone rather than corrected: with the value
always restored it could only re-assert what was already true, and every
writer already owns its own side effect -- the setter starts and stops
synchronously, container start arms the bridge, launch reconcile re-arms
it, and the poller re-reads the flag each tick and self-terminates.
Re-adding a start path to the one function that no longer owns the flag is
what caused this.
Turning the browser view off also stopped tearing the session down when the
project record had vanished, because the persist used `?` and returned
early -- the supervisor's own `store.get()` check exists because records do
vanish mid-session. Teardown is now unconditional and the write error still
surfaces afterwards, since the stored flag saying "enabled" means the view
returns on next launch and that is worth reporting.
Finally, the opener no longer falls through to `gio` on any non-zero exit.
xdg-open's 1, 2 and 3 assert no handler ran; 4 also covers a handler that
was launched and then failed, which would have opened the link twice --
two authorize requests for one click in an OAuth flow. Reasoned from
documented exit codes rather than an observed double-open, and the cost is
stated: a genuine code-4 failure no longer reaches gio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A high-effort review of this branch surfaced five issues. All five were verified against the code before being fixed; notably four of the five sat on the seams between the parallel workstreams rather than inside any one of them.
A disabled auth bridge could be silently re-enabled.update_project restored browser_view_enabled from the store but took auth_bridge_enabled from the IPC payload, justified by a comment claiming the Config tab edits it through that save. The comment was wrong: AuthBridgeRow is the only writer, calls set_auth_bridge_enabled out of band so the switch works while a login is hanging, and never writes the value back into frontend state. So turning the bridge off and then closing a renamed terminal tab round-tripped the stale true and restarted a bridge whose own UI warns the port is unauthenticated and reachable by any local process. Defaulting the flag to true earlier in this branch made it worse. Both flags now come from the store, and the reconcile block is removed rather than corrected — every writer already owns its own side effect.
An in-flight open could blank a newer prompt. Awaiting the open instead of dismissing up front opened a window of at least OPENER_GRACE; a URL relayed inside it was dismissed by the previous open's resolution, losing a link that exists only in the container transcript. Dismissal is now guarded on the prompt's seq. Fixing it surfaced two further defects: urlPromptRef was effect-synced and therefore lagged state by a commit — wrong in exactly the window the guard needs — and promptSeqRef.current += 1 was being mutated inside a state updater React may invoke twice.
Turning the browser view off leaked the session when the project record had vanished, because the persist used ? and returned early. Teardown is now unconditional; the write error still surfaces afterwards, since a stored flag saying "enabled" means the view returns on next launch.
The toast promised a bridge that might be off. "Host leads" covered both a live bridge and a fallback where nothing could catch the callback; in the second case the hint claimed the bridge would carry it and the login hung to timeout. The target is now three-state and the fallback hint names the control that fixes it.
The opener could open the link twice. Falling through to gio on any non-zero exit meant an xdg-open that handed off a tab and then returned non-zero produced two tabs — two authorize requests in an OAuth flow. Fall-through is now limited to xdg-open's documented "no handler ran" codes. Reasoned from documented exit codes rather than an observed double-open; the accepted cost is that a genuine code-4 failure no longer reaches gio.
Verification
Rust: 586 passed, 0 failed (was 577)
Frontend: 780 passed, 0 failed (was 774)
The prompt-race regression test was confirmed non-vacuous by reverting the guard and observing it fail.
Caveat on the earlier "tsc clean" claim: tsconfig.json excludes src/**/*.test.tsx, so tsc --noEmit does not type-check test files — the suite run is what covers them.
The runtime caveat in the original description still stands in full: none of this is exercised on a real desktop, and the AppImage path especially needs a packaged build.
## Review findings addressed (`6018861`, `943c83b`)
A high-effort review of this branch surfaced five issues. All five were verified against the code before being fixed; notably **four of the five sat on the seams between the parallel workstreams** rather than inside any one of them.
**A disabled auth bridge could be silently re-enabled.** `update_project` restored `browser_view_enabled` from the store but took `auth_bridge_enabled` from the IPC payload, justified by a comment claiming the Config tab edits it through that save. The comment was wrong: `AuthBridgeRow` is the only writer, calls `set_auth_bridge_enabled` out of band so the switch works while a login is hanging, and never writes the value back into frontend state. So turning the bridge off and then closing a renamed terminal tab round-tripped the stale `true` and restarted a bridge whose own UI warns the port is unauthenticated and reachable by any local process. Defaulting the flag to true earlier in this branch made it worse. Both flags now come from the store, and the reconcile block is removed rather than corrected — every writer already owns its own side effect.
**An in-flight open could blank a newer prompt.** Awaiting the open instead of dismissing up front opened a window of at least `OPENER_GRACE`; a URL relayed inside it was dismissed by the *previous* open's resolution, losing a link that exists only in the container transcript. Dismissal is now guarded on the prompt's `seq`. Fixing it surfaced two further defects: `urlPromptRef` was effect-synced and therefore lagged state by a commit — wrong in exactly the window the guard needs — and `promptSeqRef.current += 1` was being mutated inside a state updater React may invoke twice.
**Turning the browser view off leaked the session** when the project record had vanished, because the persist used `?` and returned early. Teardown is now unconditional; the write error still surfaces afterwards, since a stored flag saying "enabled" means the view returns on next launch.
**The toast promised a bridge that might be off.** "Host leads" covered both a live bridge and a fallback where nothing could catch the callback; in the second case the hint claimed the bridge would carry it and the login hung to timeout. The target is now three-state and the fallback hint names the control that fixes it.
**The opener could open the link twice.** Falling through to `gio` on any non-zero exit meant an `xdg-open` that handed off a tab and *then* returned non-zero produced two tabs — two authorize requests in an OAuth flow. Fall-through is now limited to xdg-open's documented "no handler ran" codes. Reasoned from documented exit codes rather than an observed double-open; the accepted cost is that a genuine code-4 failure no longer reaches `gio`.
### Verification
- Rust: **586 passed, 0 failed** (was 577)
- Frontend: **780 passed, 0 failed** (was 774)
- The prompt-race regression test was confirmed non-vacuous by reverting the guard and observing it fail.
Caveat on the earlier "tsc clean" claim: `tsconfig.json` excludes `src/**/*.test.tsx`, so `tsc --noEmit` does not type-check test files — the suite run is what covers them.
The runtime caveat in the original description still stands in full: none of this is exercised on a real desktop, and the AppImage path especially needs a packaged build.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp
merged commit 73a6e3d8b4 into main2026-09-18 04:32:07 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this fixes
Three separate symptoms that turned out to share one root cause.
claude logininside 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 — but it defaulted to off, so a hang was the out-of-the-box experience.Meanwhile
isAnthropicSignInUrlmade the container the default action for every 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 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.On Linux the host path was independently broken. The app ships as a single AppImage whose environment leaks into everything it spawns —
LD_LIBRARY_PATH,GTK_PATH,GIO_MODULE_DIRand friends under$APPDIR, plus the process-wideWEBKIT_DISABLE_DMABUF_RENDERERfrommain.rs. An already-running browser shrugs this off; a cold-launched one inherits the lot and dies before painting, withxdg-openstill exiting 0. That is triple-c#34, and it is why the button looked like it did nothing at all.And it reported nothing either way:
dismissUrlPrompt()ran beforeopenUrl, so the toast vanished and a rejected promise reached only the devtools console.Changes
90b7e4cauth_bridge_enableddefaults to true; browser-view flag made durablebf8094d9297020open_url_external— env-sanitized spawn + Rust-side re-validation5a092545a452e7db64823opener:allow-open-urldroppedafe9d5cNotes on specific decisions
Existing projects get the bridge. The default applies at deserialisation, and
migrate_from_valuewrites neither flag, so aprojects.jsonrecord predating the feature picks it up. A regression test pins that againstMAIN_SHAPE_PROJECT, a real record written by a shipped binary.The bridge counts as live on
enabled && no conflicts, not onactive_portsbeing non-empty. There is nothing to bridge until the CLI binds its listener, and that races the URL reaching the transcript — gating on ports would flip the default button between two identical sign-ins. A port conflict is the genuine "on but cannot catch it" state.The durable browser-view flag removed the cache rather than syncing it.
BrowserViewManagerlost itsenabled: Mutex<HashSet<String>>; the project record is now the single home, mirroringAuthBridgeManager.stop()deliberately does not clear it — container teardown and migration reach that path and neither is the user changing their mind.No XDG portal.
org.freedesktop.portal.OpenURIwould pull a D-Bus client stack in for one call on the one platform where we ship self-contained, and it only helps where a portal is running — the same case wherexdg-openalready works once the environment is clean.gio openas a second candidate recovers most of the missing-MIME-association case for free.opener:allow-open-urlis gone.default.jsoncarried it as an explicitly accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL. With no frontend caller left, that closes rather than stays recorded. The plugin remains a dependency — macOS and Windows reach it throughOpenerExt::open_url, whose desktop implementation callscrate::open::opendirectly and is not gated by capabilities (verified againsttauri-plugin-opener-2.5.3/src/lib.rs:60, since the whole point is that the Rust path keeps working).Scope creep, declared
Wiring up
open_url_externalrevealed the same Linux bug in three dialogs nobody had mentioned — DockerInstallDialog's docs link, ClaudeAuthModal's sign-in link, UpdateDialog's release link. All three would have reported success while launching nothing. They now share one sink. That in turn is what emptied out the capability grant.Verification
tsc --noEmitcleansanitizeRelayUrlis byte-for-byte unchanged, so the embedded duplicate inweb_terminal/terminal.htmlneeds no matching edit andurlRelay.embedded.test.tspasses untouchedNone of this is runtime-tested. The Windows callback, the Linux AppImage launch and the sign-in button ordering all need a real build on a real desktop; unit tests cannot reach any of them. The AppImage path especially — its whole premise is behaviour that only exists inside a packaged bundle. Relatedly, which
*_ORIGsave convention linuxdeploy's AppRun uses at runtime cannot be established from the tree, since Tauri fetches linuxdeploy at build time andfinalize-appimage.shonly patches the generated AppRun; both spellings are checked defensively and the$APPDIR-entry filter is the load-bearing mechanism regardless.🤖 Generated with Claude Code
Review findings addressed (
6018861,943c83b)A high-effort review of this branch surfaced five issues. All five were verified against the code before being fixed; notably four of the five sat on the seams between the parallel workstreams rather than inside any one of them.
A disabled auth bridge could be silently re-enabled.
update_projectrestoredbrowser_view_enabledfrom the store but tookauth_bridge_enabledfrom the IPC payload, justified by a comment claiming the Config tab edits it through that save. The comment was wrong:AuthBridgeRowis the only writer, callsset_auth_bridge_enabledout of band so the switch works while a login is hanging, and never writes the value back into frontend state. So turning the bridge off and then closing a renamed terminal tab round-tripped the staletrueand restarted a bridge whose own UI warns the port is unauthenticated and reachable by any local process. Defaulting the flag to true earlier in this branch made it worse. Both flags now come from the store, and the reconcile block is removed rather than corrected — every writer already owns its own side effect.An in-flight open could blank a newer prompt. Awaiting the open instead of dismissing up front opened a window of at least
OPENER_GRACE; a URL relayed inside it was dismissed by the previous open's resolution, losing a link that exists only in the container transcript. Dismissal is now guarded on the prompt'sseq. Fixing it surfaced two further defects:urlPromptRefwas effect-synced and therefore lagged state by a commit — wrong in exactly the window the guard needs — andpromptSeqRef.current += 1was being mutated inside a state updater React may invoke twice.Turning the browser view off leaked the session when the project record had vanished, because the persist used
?and returned early. Teardown is now unconditional; the write error still surfaces afterwards, since a stored flag saying "enabled" means the view returns on next launch.The toast promised a bridge that might be off. "Host leads" covered both a live bridge and a fallback where nothing could catch the callback; in the second case the hint claimed the bridge would carry it and the login hung to timeout. The target is now three-state and the fallback hint names the control that fixes it.
The opener could open the link twice. Falling through to
gioon any non-zero exit meant anxdg-openthat handed off a tab and then returned non-zero produced two tabs — two authorize requests in an OAuth flow. Fall-through is now limited to xdg-open's documented "no handler ran" codes. Reasoned from documented exit codes rather than an observed double-open; the accepted cost is that a genuine code-4 failure no longer reachesgio.Verification
Caveat on the earlier "tsc clean" claim:
tsconfig.jsonexcludessrc/**/*.test.tsx, sotsc --noEmitdoes not type-check test files — the suite run is what covers them.The runtime caveat in the original description still stands in full: none of this is exercised on a real desktop, and the AppImage path especially needs a packaged build.
🤖 Generated with Claude Code