Skip to content

Commit 51209c5

Browse files
committed
docs(planning): mark workspace binding popup loop fix implemented
Reconcile stale planning status with shipped Phase 1–3 commits and document the stale-header fallthrough exception. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 88978bd commit 51209c5

3 files changed

Lines changed: 124 additions & 7 deletions

File tree

docs/planning/per-device-machine-header.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Per-Device Machine Identity Header
22

3-
**Last Updated:** Jun 30, 2026
4-
**Status:** Implemented (Jun 30, 2026)
3+
**Last Updated:** Jul 23, 2026
4+
**Status:** Implemented (Jun 30, 2026; stale-header fallthrough Jul 23, 2026)
55
**Depends on:** `workspace-machine-binding.md`, `deny-by-default-bindable-callers.md`
66
**Unblocks:** Machine-scoped bindings work when multiple physical devices share one tunneled gateway
77

@@ -28,11 +28,19 @@ Add optional per-request header `X-Mcpmux-Machine-Id: <machine-uuid>` in each de
2828
**Header present** (`X-Mcpmux-Machine-Id` with valid UUID):
2929

3030
```text
31-
1. Header machine id only
31+
1. Header machine id
3232
2. global binding (machine_id IS NULL)
3333
```
3434

35-
Client and gateway-local machine tags are skipped when the header is set, so a tunneled Rohan caller is not mistaken for Gondor.
35+
Then, only if the header **disagrees** with the OAuth client's registered `inbound_clients.machine_id` (stale / wrong header, e.g. cloud-agent config on a native localhost session):
36+
37+
```text
38+
3. inbound_clients.machine_id
39+
4. gateway.local_machine_id
40+
5. global binding (already tried above; no-op if none)
41+
```
42+
43+
If the header matches the registered machine, or the caller is anonymous (`client_id` absent), client and gateway-local tags stay skipped — tunneled Rohan caller is not mistaken for Gondor.
3644

3745
## Files modified
3846

@@ -60,8 +68,9 @@ Client and gateway-local machine tags are skipped when the header is set, so a t
6068
## Implementation notes
6169

6270
- Malformed header values are ignored; full client → local → global chain applies.
63-
- When header is present (valid UUID), client and gateway-local machine tags are **not** consulted.
64-
- Tests: `request_machine_header_outranks_client_and_local_machine`, `request_machine_header_enables_deny_when_only_other_machine_bound`.
71+
- When header is present (valid UUID) **and** matches the OAuth client's registered machine (or caller is anonymous), client and gateway-local machine tags are **not** consulted.
72+
- When header is present but **disagrees** with the registered machine, treat it as stale and fall through to client → local → global (Jul 23, 2026 — see [`workspace-binding-popup-loop-fix.md`](./workspace-binding-popup-loop-fix.md)).
73+
- Tests: `request_machine_header_outranks_client_and_local_machine`, `request_machine_header_enables_deny_when_only_other_machine_bound`, `wrong_request_machine_header_falls_back_to_client_machine_binding`.
6574
- Validated Jun 30, 2026: 331 integration tests, clippy clean, desktop typecheck clean.
6675

6776
## Client setup
@@ -84,6 +93,7 @@ Copy the snippet from **Settings → Machine Identity** (viewer card or **Manage
8493
- [Workspaces](/docs/workspaces/) — machine-scoped bindings
8594
- [Clients](/docs/clients/) — multi-device tunnel setup
8695
- [workspace-machine-binding.md](./workspace-machine-binding.md) — machine catalog and binding model
96+
- [workspace-binding-popup-loop-fix.md](./workspace-binding-popup-loop-fix.md) — stale-header fallthrough when native Cursor carried the wrong machine id
8797

8898
```bash
8999
pnpm test:rust
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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)

docs/planning/workspace-machine-binding.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Machine catalog UUID is surfaced on all viewer identity surfaces via [`machine-i
7272

7373
When multiple physical devices reach **one** gateway via a tunnel (`gateway.public_url`), the gateway cannot infer which laptop/desktop made the request from `gateway.local_machine_id` alone — that always identifies the host running McpMux (e.g. Gondor), not the device running Cursor (e.g. Rohan).
7474

75-
**Fix:** each device's MCP client config sends `X-Mcpmux-Machine-Id: <machine-uuid>`. The resolver uses that header as the machine signal for binding lookup. When the header is present, client and gateway-local machine tags are skipped so a tunneled caller is not mistaken for the gateway host.
75+
**Fix:** each device's MCP client config sends `X-Mcpmux-Machine-Id: <machine-uuid>`. The resolver uses that header as the machine signal for binding lookup. When the header is present and matches the OAuth client's registered machine (or the caller is anonymous), client and gateway-local machine tags are skipped so a tunneled caller is not mistaken for the gateway host. If the header disagrees with the registered machine, it is treated as stale and the resolver falls through to client → local → global (Jul 23, 2026 — [`workspace-binding-popup-loop-fix.md`](./workspace-binding-popup-loop-fix.md)).
7676

7777
See [`per-device-machine-header.md`](./per-device-machine-header.md) for full design, resolver priority, and client setup. User-facing docs: [Remote Access](/docs/remote-access/), [Workspaces](/docs/workspaces/), [Clients](/docs/clients/).
7878

0 commit comments

Comments
 (0)