|
| 1 | +# Workspace Binding Popup Loop Fix |
| 2 | + |
| 3 | +**Last Updated:** Jul 23, 2026 |
| 4 | +**Status:** Implemented (Jul 23, 2026) |
| 5 | +**Depends on:** `workspace-machine-binding.md` (Tier 1 machine-scoped lookup), `per-device-machine-header.md` (`request_machine_id` signal) |
| 6 | +**Unblocks:** Reliable machine-scoped resolution for native Cursor MCP clients since Cloud Agents were connected |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## Problem |
| 11 | + |
| 12 | +Since connecting Cursor Cloud Agents, the "NEW WORKSPACE DETECTED" binding panel kept popping up for the `jsg-tech-check` workspace regardless of dismissal, and re-saving the binding did not fix it. |
| 13 | + |
| 14 | +Traced via DB + gateway log inspection: |
| 15 | + |
| 16 | +- The client is `mcp_36740f70` — Cursor's native "Cursor" MCP client (OAuth/DCR, registered May 16, 2026, `redirect_uri: cursor://anysphere.cursor-mcp/oauth/callback`), reconnecting with a fresh session roughly every 5 minutes. |
| 17 | +- Every reconnect reported `roots=["/Users/joe/Desktop/Repos/Personal/jsg-tech-check"]` and resolved `source=Unbound`. |
| 18 | +- A `workspace_bindings` row already existed for that exact path scoped to `machine_id=Gondor` (`ec211deb-4fa7-489f-ba15-35f4577d7e71`) with 2 FeatureSets attached, and `mcp_36740f70`'s own `inbound_clients.machine_id` is also Gondor. |
| 19 | + |
| 20 | +Two compounding issues turned one bug into a permanent nuisance: |
| 21 | + |
| 22 | +1. **No dedup on `WorkspaceNeedsBinding`.** Every Unbound-resolving reconnect refired the event (`log_and_notify_resolution` in [`handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs)), and [`bindingPanelStore.ts`](../../apps/desktop/src/stores/bindingPanelStore.ts) had no dismiss memory. |
| 23 | +2. **"Save binding" re-targeted a binding that already existed** for `(Gondor, jsg-tech-check)`. It could not fix a read-side mismatch. |
| 24 | + |
| 25 | +Separately (unrelated root cause, same investigation): `mcp_36740f70` had `approved=0` in `inbound_clients`, and [`live_runtime.rs`](../../crates/mcpmux-gateway/src/admin/live_runtime.rs) `get_oauth_clients` filters the Connections list on `approved` — so this client was invisible in the UI. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Root cause (actual) |
| 30 | + |
| 31 | +Not a no-header Tier 1 miss. Live repro found a **stale / wrong `X-Mcpmux-Machine-Id`** on the native Cursor OAuth session (likely inherited from Cloud Agents config). The header branch found no binding for that machine, then `continue`d to the next root — skipping the client's registered Gondor machine and the existing Gondor binding. |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## Decisions |
| 36 | + |
| 37 | +| # | Decision | Choice | Rationale | |
| 38 | +| - | -------- | ------ | --------- | |
| 39 | +| 1 | Root-cause method | Temporary debug logging + live repro against `mcp_36740f70` reconnect | Static reading of the no-header path looked correct; mismatch only showed up with real request-time header values. | |
| 40 | +| 2 | Resolver fix | When header machine ≠ OAuth client's registered machine, treat header as stale and fall through to client → local → global | Preserves tunnel isolation when header matches registered machine or caller is anonymous. | |
| 41 | +| 3 | Popup suppression | Persist dismissals per `(client_id, workspace_root)` in SQLite | Survives gateway restarts; clear on binding create/update for that root. | |
| 42 | +| 4 | `approved` backfill | One-time `UPDATE ... WHERE approved = 0 AND last_seen IS NOT NULL` | Real access was already granted; flag never caught up. Legacy auto-approve TODO left out of scope. | |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## Scope |
| 47 | + |
| 48 | +**In (shipped):** |
| 49 | +- Tier 1 stale-header fallthrough + regression tests |
| 50 | +- `workspace_binding_prompt_dismissals` table + gateway emit skip + Tauri/FE wiring |
| 51 | +- Clearing dismissals when a binding is saved for that workspace root |
| 52 | +- Backfill migration for stale `approved` flags |
| 53 | + |
| 54 | +**Out (unchanged):** |
| 55 | + |
| 56 | +| Item | Reason | |
| 57 | +| ---- | ------ | |
| 58 | +| Removing the `approved` filter from `get_oauth_clients` | Backfill fixes the symptom; filter is correct for genuinely-unapproved clients. | |
| 59 | +| Fixing the legacy auto-approve TODO in `handlers.rs` `oauth_authorize` | Separate pre-existing gap. | |
| 60 | +| Unifying `client_id` and `machine_id` scoping axes | Tracked in `workspace-machine-binding.md` Future TODOs. | |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +## What shipped |
| 65 | + |
| 66 | +| Commit | Phase | Summary | |
| 67 | +| ------ | ----- | ------- | |
| 68 | +| `4001253` | 1 | Stale-header fallthrough in `find_binding_for_roots`; storage + integration regression tests; temp instrumentation removed | |
| 69 | +| `8d752fb` | 2 | Migration 041 dismissals; repo CRUD; gateway skip; Tauri dismiss commands; panel close + WorkspacesPage auto-open wiring; clear on create/update | |
| 70 | +| `b1680a5` | 3 | Migration 042 approved backfill | |
| 71 | + |
| 72 | +### Autonomous decisions during implement |
| 73 | + |
| 74 | +- Dismissal CRUD lives on `InboundClientRepository` (no new repo type). |
| 75 | +- WorkspacesPage auto-open uses **root-only** dismissal check when `client_id` is unknown (page-load catch-up without session context). Event-driven opens still pass `clientId` and persist per-client dismissals. |
| 76 | +- `clear_binding_prompt_dismissals_for_root` on create/update clears all client rows for that root so a later regression re-prompts everyone. |
| 77 | + |
| 78 | +--- |
| 79 | + |
| 80 | +## Files created |
| 81 | + |
| 82 | +| File | Purpose | |
| 83 | +| ---- | ------- | |
| 84 | +| [`crates/mcpmux-storage/src/migrations/041_workspace_binding_prompt_dismissals.sql`](../../crates/mcpmux-storage/src/migrations/041_workspace_binding_prompt_dismissals.sql) | `workspace_binding_prompt_dismissals` table, PK `(client_id, workspace_root)` | |
| 85 | +| [`crates/mcpmux-storage/src/migrations/042_backfill_approved_clients.sql`](../../crates/mcpmux-storage/src/migrations/042_backfill_approved_clients.sql) | Backfill stale `approved=0` clients with real traffic | |
| 86 | + |
| 87 | +## Files modified |
| 88 | + |
| 89 | +| File | Change | |
| 90 | +| ---- | ------ | |
| 91 | +| [`crates/mcpmux-gateway/src/services/feature_set_resolver.rs`](../../crates/mcpmux-gateway/src/services/feature_set_resolver.rs) | Stale-header fallthrough in `find_binding_for_roots` | |
| 92 | +| [`crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs`](../../crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs) | Regression unit test (canonical machine + registered client shape) | |
| 93 | +| [`tests/rust/tests/integration/feature_set_resolver.rs`](../../tests/rust/tests/integration/feature_set_resolver.rs) | `wrong_request_machine_header_falls_back_to_client_machine_binding` | |
| 94 | +| [`crates/mcpmux-storage/src/database.rs`](../../crates/mcpmux-storage/src/database.rs) | Register migrations 041 + 042; fork test expects version 42 | |
| 95 | +| [`crates/mcpmux-storage/src/repositories/inbound_client_repository.rs`](../../crates/mcpmux-storage/src/repositories/inbound_client_repository.rs) | Dismissal check/insert/clear methods | |
| 96 | +| [`crates/mcpmux-gateway/src/mcp/handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs) | Skip `WorkspaceNeedsBinding` emit when dismissal exists | |
| 97 | +| [`apps/desktop/src-tauri/src/commands/workspace_binding.rs`](../../apps/desktop/src-tauri/src/commands/workspace_binding.rs) | Dismiss / is-dismissed commands; clear on create/update | |
| 98 | +| [`apps/desktop/src/lib/api/workspaceBindings.ts`](../../apps/desktop/src/lib/api/workspaceBindings.ts) | TS wrappers for dismiss commands | |
| 99 | +| [`apps/desktop/src/features/workspaces/workspace-binding-panel.component.tsx`](../../apps/desktop/src/features/workspaces/workspace-binding-panel.component.tsx) | Persist dismissal on close for `create-from-live` with `clientId` | |
| 100 | +| [`apps/desktop/src/features/workspaces/WorkspacesPage.tsx`](../../apps/desktop/src/features/workspaces/WorkspacesPage.tsx) | Root-only dismissal check before independent auto-open | |
| 101 | + |
| 102 | +--- |
| 103 | + |
| 104 | +## Related Documentation |
| 105 | + |
| 106 | +- [`workspace-machine-binding.md`](./workspace-machine-binding.md) — machine catalog, `find_exact_for_machine` |
| 107 | +- [`per-device-machine-header.md`](./per-device-machine-header.md) — header priority + stale-header fallthrough exception (updated Jul 23, 2026) |
0 commit comments