Skip to content

fix: correct bridge mode cross-directory dispatch and race conditions#41

Merged
A-Souhei merged 2 commits intomainfrom
fix/bridge-mode
Mar 13, 2026
Merged

fix: correct bridge mode cross-directory dispatch and race conditions#41
A-Souhei merged 2 commits intomainfrom
fix/bridge-mode

Conversation

@A-Souhei
Copy link
Owner

Summary

Fix multiple bugs in bridge mode that prevented correct cross-directory task dispatch and introduced race conditions in both the CLI and web app.

CLI fixes

  • Missing x-opencode-directory header (task.ts): The master's fetch to /bridge/dispatch-task had no directory header, causing the friend to fall back to process.cwd() instead of its registered directory. Added header with CRLF sanitization.
  • ALS context lost for fire-and-forget (bridge.ts): SessionPrompt.prompt() was called fire-and-forget after the HTTP handler returned, tearing down the Instance.provide() AsyncLocalStorage context. Wrapped the call in Instance.provide() with async/await.
  • Stale session.directory in bridge registration (app.tsx): Both master and friend registered their bridge directory using session.directory, frozen at session creation time. Changed to process.cwd() captured once at App() init.
  • bridgeInited set before POST resolves (app.tsx): Failures were permanent with no retry. Moved bridgeInited = true into the .then() success branch only.

Web app fixes

  • Missing Authorization header (dialog-become-friend.tsx): /bridge/set-friend fetch silently 401'd on password-protected servers. Added shared authHeaders() utility and spread into all bridge fetch calls.
  • Bridge poll race condition (bridge.tsx): After becomeMaster() optimistically set bridge state, the background poll could race in with stale server response and overwrite back to null. Added suppressUntil timestamp and a monotonic generation counter to prevent stale poll results from overwriting optimistic state.

Security fixes

  • CRLF injection (task.ts): node.directory was placed directly into an HTTP header without sanitization.
  • Non-constant-time bridge ID comparison (bridge.ts): !== string comparison replaced with timingSafeEqual from Node's crypto module.
  • Duplicated authHeaders() logic: Extracted to packages/app/src/utils/auth.ts and used consistently in bridge.tsx, session-info-panel.tsx, and dialog-become-friend.tsx.

Other fixes

  • Idempotency: Added activeTaskIDs set in bridge.ts to prevent duplicate task dispatch on network retry.
  • Double-submit guard: Added early return in dialog-become-friend.tsx if a submit is already in flight.
  • dialog.close() ordering: Moved before props.onSuccess() so dialog always closes even if the callback throws.
  • Silent error swallowing: Fire-and-forget .catch() now logs via Log.Default.error.

Testing

  • 1,370 unit tests pass in packages/opencode
  • 230 unit tests pass in packages/app
  • Type-check and Vite build clean in both packages

- Add x-opencode-directory header to bridge task dispatch with CRLF sanitization
- Wrap fire-and-forget SessionPrompt.prompt() in Instance.provide() to preserve ALS context
- Use timingSafeEqual for x-bridge-id comparison to prevent timing attacks
- Extract shared authHeaders() utility and use it consistently across bridge UI
- Add suppressUntil + generation counter to prevent poll race conditions in bridge.tsx
- Add activeTaskIDs idempotency set to prevent duplicate task dispatch
- Fix process.cwd() vs session.directory for bridge registration in app.tsx
- Move bridgeInited = true to success branch to allow retry on failure
- Add double-submit guard and fix dialog.close() ordering in dialog-become-friend.tsx
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes bridge-mode correctness issues across the CLI and web app, focusing on cross-directory dispatch, auth header consistency, and race-condition prevention.

Changes:

  • CLI: include x-opencode-directory on cross-node task dispatch; fix bridge registration directory and init retry behavior.
  • Server: harden /bridge/dispatch-task auth comparison and make friend task dispatch more robust (fire-and-forget + logging + idempotency guard).
  • Web app: centralize Authorization header creation and prevent bridge poll races from overwriting optimistic state.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/opencode/src/tool/task.ts Adds directory header (with CRLF sanitization) and improved HTTP error reporting for bridge dispatch.
packages/opencode/src/server/routes/bridge.ts Uses constant-time bridge ID comparison; adds task idempotency set; wraps prompt execution in Instance.provide() and logs failures.
packages/opencode/src/cli/cmd/tui/app.tsx Registers bridge directory from process.cwd() and only marks bridge initialized after successful POST.
packages/app/src/utils/auth.ts Introduces shared authHeaders() helper for Basic Auth.
packages/app/src/pages/session/session-info-panel.tsx Switches to shared authHeaders() for bridge actions (master/leave).
packages/app/src/pages/session/dialog-become-friend.tsx Adds auth headers to join request; adds double-submit guard; closes dialog before invoking success callback.
packages/app/src/context/bridge.tsx Uses shared authHeaders() and adds suppression/generation logic to prevent stale polls overwriting optimistic bridge state.
Comments suppressed due to low confidence (1)

packages/opencode/src/server/routes/bridge.ts:395

  • activeTaskIDs is added before several awaited operations that can throw (e.g., Session.create). If an exception occurs before the fire-and-forget Instance.provide() branch runs, the handler will error and the taskID will remain in activeTaskIDs, causing a memory leak and potentially making retries of the same taskID return the "dup" response without actually processing. Ensure the taskID is removed on all failure paths (e.g., try/finally around the handler body, or add to the set only after all awaited setup steps succeed).
        activeTaskIDs.add(taskID)
        const dir = Instance.directory
        const session = await Session.create({
          title: description,
          permission: [

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +382 to 386
const incoming = c.req.header("x-bridge-id") ?? ""
const safe = (a: string, b: string) =>
a.length === b.length && timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))
if (!Bridge.isFriend() || !bid || !safe(incoming, bid)) return c.json({ error: "Unauthorized" }, 401)
const { taskID, prompt, description } = c.req.valid("json")
Comment on lines +102 to +106
function set(...args: any[]) {
suppressUntil = Date.now() + SUPPRESS_MS
// @ts-expect-error — forward all overloads
setState(...args)
}
- Fix describeRoute responses for /dispatch-task: document 401 (not 403) to match runtime behavior
- Fix activeTaskIDs leak: wrap Session.create and setup in try/catch to delete taskID on synchronous failure
- Fix set() wrapper in bridge.tsx: type as typeof setState instead of any[] for proper type-safety
@A-Souhei A-Souhei merged commit d3c7e1d into main Mar 13, 2026
2 checks passed
@A-Souhei A-Souhei deleted the fix/bridge-mode branch March 13, 2026 18:34
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.

2 participants