Skip to content

fix(browser): stop bridge token divergence (atomic write + host-only + reload)#57

Open
stephane-segning wants to merge 2 commits into
claude/browser-bad-token-hardeningfrom
claude/bridge-token-divergence
Open

fix(browser): stop bridge token divergence (atomic write + host-only + reload)#57
stephane-segning wants to merge 2 commits into
claude/browser-bad-token-hardeningfrom
claude/bridge-token-divergence

Conversation

@stephane-segning

Copy link
Copy Markdown
Contributor

Stacked on #55 (base = claude/browser-bad-token-hardening). Merge #55 first, then retarget this to main. The diff here is #57-only.

What

Fixes the bug we diagnosed live: the extension sends the current token but a long-lived host (an IDE-embedded OpenCode) keeps rejecting it as bad_token.

Root cause

Every instance wrote bridge.json with a non-atomic writeFileSync at load. When the desktop app boots many instances at once, one can read the file mid-write (torn/empty), fall through to generate a fresh token in resolveSharedToken, and overwrite the shared one — orphaning the already-running host, which resolves its token once at boot and never re-reads. Confirmed in a real log: bridge.json held c856e1cf… while the running host still checked against ea9cdad6…, so every handshake (with the correct file token) was rejected.

Fix — three layers

  1. Atomic writeBridgeFile — per-writer pid+uuid temp then rename (+ unlink on failure). A reader can never catch a torn write, so no spurious regenerate. (Same lesson as fix(oauth2): per-writer temp file for model-sync cache (concurrent-boot ENOENT) #54.)
  2. Host-only writewriteBridgeFile moved into the onHost path instead of running unconditionally at load, and it never clobbers a token the file already carries. A guest that resolved a fresh token on a transient miss can no longer overwrite the live host's token; the file stays the single source of truth.
  3. Host reloads on mismatch — on a bad-token handshake the broker re-reads bridge.json (injected reloadToken dep) and adopts a rotated token (logs browser_bridge_token_reloaded), then re-checks the handshake. So a rotation reaches a running host within the extension's retry window — no process kill needed (the exact thing that bit us: rotating the file did nothing while the old host lived).

Tests / docs

  • token-file: atomic concurrent-write test (no .tmp leftovers, last writer wins).
  • broker: adopts a rotated token on mismatch; still rejects when the reload doesn't match either.
  • Updated the opencode.test.ts token-file mock (new readBridgeFile export).
  • docs/browser.md troubleshooting row (host now auto-reloads; restart is the older-build fallback) + CHANGELOG.md.

Full pre-push gate green (browser 119 tests, 91% stmts — every coverage threshold met).

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses bridge token divergence issues by implementing atomic writes for bridge.json and enabling long-lived hosts to auto-reload rotated tokens on handshake mismatches. Additionally, token writing and advertising are restricted to the host instance. A review comment points out a critical bug in advertiseToken where port changes and explicit tokens are ignored because the code only checks for the existence of a shared token before writing. A suggestion is provided to check if the existing port or token has changed before skipping the write.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 158 to 170
const advertiseToken = () => {
const shared = readBridgeFile()?.token;
if (!shared) {
writeBridgeFile(options.port, token);
}
if (source !== "explicit") {
logger.info("browser_bridge_token", { paste_into_extension: token, source, mode: "host" });
logger.info("browser_bridge_token", {
paste_into_extension: shared ?? token,
source,
mode: "host"
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ Critical Bug: Port Changes and Explicit Tokens are Ignored / Stale Tokens Logged

The current implementation of advertiseToken checks if (!shared) before writing the token to bridge.json. This introduces several critical issues:

  1. Port Changes are Ignored: If bridge.json already exists with a token (e.g., from a previous run on port 4517), shared will be truthy. If the host is restarted on a different port (e.g., 4518), writeBridgeFile is never called. Thus, bridge.json is never updated with the new port, breaking discovery for guest adapters (like the MCP server) which will continue trying to connect to the old port.
  2. Explicit Tokens are Ignored: If the user configures an explicit token, it will not be written to bridge.json if a token already exists. Furthermore, on a handshake mismatch, the broker's reloadToken will read the stale token from bridge.json and overwrite the explicit token, completely ignoring the user's explicit configuration.
  3. Stale Token Logging: When the port or token changes, shared ?? token will evaluate to the stale shared token from the file, logging an incorrect token that mismatches the actual running broker's token.

Solution

We should only skip writing if the file already exists and matches both the current port and token. Otherwise, we must write the updated port and token to bridge.json to keep it as the single source of truth. Additionally, ensure that connection tokens or critical initialization logs are emitted dynamically on every transition to active/host mode rather than only once during initial startup.

Suggested change
const advertiseToken = () => {
const shared = readBridgeFile()?.token;
if (!shared) {
writeBridgeFile(options.port, token);
}
if (source !== "explicit") {
logger.info("browser_bridge_token", { paste_into_extension: token, source, mode: "host" });
logger.info("browser_bridge_token", {
paste_into_extension: shared ?? token,
source,
mode: "host"
});
}
};
const advertiseToken = () => {
const existing = readBridgeFile();
if (!existing || existing.port !== options.port || existing.token !== token) {
writeBridgeFile(options.port, token);
}
if (source !== "explicit") {
logger.info("browser_bridge_token", {
paste_into_extension: token,
source,
mode: "host"
});
}
};
References
  1. Ensure that connection tokens or critical initialization logs are emitted dynamically on every transition to active/host mode (such as during re-election or failover), rather than only once during initial startup.

@stephane-segning stephane-segning force-pushed the claude/browser-bad-token-hardening branch from 8484a64 to 9cbbb74 Compare June 15, 2026 19:49
stephane-segning and others added 2 commits June 15, 2026 21:50
…+ reload)

The failure where the extension sends the *current* bridge token but a
long-lived host keeps rejecting it. Root cause: every instance wrote
`bridge.json` non-atomically at load, so a concurrent boot could catch a
torn/empty file, fall through to GENERATE a fresh token, and overwrite the
shared one — orphaning the already-running host (which resolves its token
once at boot and never re-reads). Confirmed in a real log: file held
`c856e1cf…` while the host still checked against `ea9cdad6…`.

Three changes:
1. `writeBridgeFile` is now atomic (per-writer pid+uuid temp → rename,
   unlink on failure) — a reader can never catch a torn write, so no
   spurious regenerate.
2. Only the **host** writes `bridge.json` (moved into the onHost path, not
   unconditionally at load), and it never clobbers a token the file already
   carries — so the file stays the single source of truth and a guest that
   resolved a fresh token on a transient miss can't overwrite it.
3. On a bad-token handshake the host re-reads `bridge.json` via an injected
   `reloadToken` and adopts a rotated token (logged
   `browser_bridge_token_reloaded`), so a rotation reaches a running host
   without a restart — then re-checks the handshake.

Threaded `reloadToken` through endpoint → broker deps. Adds token-file
atomic-write + broker token-adoption tests, updates the opencode token-file
mock, the browser.md troubleshooting row, and CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gemini flagged that 'skip write if any token exists' regressed two cases:
a port change left a stale port in bridge.json (breaking guest discovery),
and an explicit operator token could be ignored — then a file reload would
override it. Now the host writes whenever the file doesn't match its
(port, token), and reloadToken is disabled for an explicit token so the
file can never override a pinned secret. Adds regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@stephane-segning stephane-segning force-pushed the claude/bridge-token-divergence branch from afd25e2 to 188e8d2 Compare June 15, 2026 19:51
@stephane-segning

Copy link
Copy Markdown
Contributor Author

Addressed the Gemini high-priority finding on advertiseToken — it was a real regression I introduced. Fixes:

  • The host now writes bridge.json whenever the file does not match its (port, token) (not just when some token exists), so a port change stays in sync and an explicit operator token overwrites a stale generated one.
  • reloadToken is disabled for an explicit token, so a file value can never override a pinned secret.
  • Logs the host's real token, not the stale file value.
  • Added regression tests (port-change rewrite, explicit-over-stale, no-op when matching).

Rebased onto the updated #55.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant