Skip to content

Device registry P1: auto-pair the phone on reload - #5626

Merged
lawrencecchen merged 10 commits into
mainfrom
feat-ios-device-registry-p1
Jun 8, 2026
Merged

lawrencecchen merged 10 commits into
mainfrom
feat-ios-device-registry-p1

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

Server-side device registry so the phone re-pairs to its Mac automatically on reload instead of re-scanning a QR. This is the central, team-scoped record of which Macs are connected and how to reach them.

Model

Two levels: a device (a physical Mac/host, keyed by a cmux-generated persisted UUID, not IOPlatformUUID) and a device_app_instance (one running cmux build/tag on that machine, holding its attach routes). Both carry a Stack team_id and a flexible labels jsonb. The registry is a best-effort rendezvous layer, not an authority on pairing. The phone keeps its local MobilePairedMacStore and falls back to it when the registry is unreachable, so pairing survives the cloud registry being down.

Web (web/)

  • db/schema.ts: devices + device_app_instances tables following the deviceTokens pattern. The device.id (cmux UUID) is documented as the seam the later key-pinning phase pins a per-device key to for revoke.
  • app/api/devices/route.ts: POST register (idempotent per device, and per (device, tag) for the instance), GET list, DELETE. Team-scoped via X-Cmux-Team-Id (or ?teamId=), defaulting to the Stack-selected team. Rejects a team the caller is not a member of with 403. Per-team cap behind a Postgres advisory lock, mirroring the device-tokens route.
  • Migration generated via drizzle-kit (v1 snapshot chain).
  • tests/devices-route.test.ts: register+list, route-refresh on re-register (the auto-pair path), non-member team 403, delete cascade. Passes against an isolated test Postgres.

Mac (Sources/Cloud/DeviceRegistryClient.swift)

Registers this Mac and its app instance's attach routes by observing MobileHostService.statusUpdates() and POSTing /api/devices whenever the route set changes (moved networks, new port). Auth mirrors PhonePushClient (Bearer + X-Stack-Refresh-Token + X-Cmux-Team-Id, AuthEnvironment.vmAPIBaseURL), best-effort and non-blocking. Gating falls out of the routes: MobileHostService advertises none until the user enabled pairing, so an empty set never registers (no separate opt-in flag). A pure shouldReRegister() skips redundant POSTs on connection-only status ticks. Wired in AppDelegate next to the existing PhonePushClient/MobileHostService configure.

iOS (Packages/CmuxMobileShell)

  • MobileDeviceIdentity: persists a cmux-generated device UUID (mirrors the Mac's MobileHostIdentity), plumbed for the key-pinning phase.
  • DeviceRegistryService reads /api/devices for a Mac's fresh routes, injected into MobileShellComposite as DeviceRegistryRefreshing. Built over the AuthCoordinator (tokens + resolvedTeamID) in CMUXMobileRootScene.
  • On reconnect, a detached non-blocking refresh fetches registry routes and, via the pure selectReconnectRoutes() (registry-fresh wins, falls back to local when the registry is empty or unreachable), writes fresher routes into MobilePairedMacStore. The next reconnect trigger then reaches a moved Mac. The connect itself still uses local routes, so the common case has zero added latency.

The Mac registers under MobileHostIdentity.deviceID() and the phone looks up by its stored mac.macDeviceID (the same value from the attach ticket); that id match is the auto-pair linchpin.

Deferred to the key-pinning phase

Per-device key pinning and revoke. The phone registering itself as a device row is also deferred (a phone row only matters once it anchors a pinned key); device.id is the clean seam.

Verification

  • Web: tsc --noEmit and eslint clean; devices-route.test.ts (and sibling DB tests, no regression) pass on an isolated test Postgres.
  • iOS: CmuxMobileShell package builds; 19 tests pass (7 new on the route-selection/parse/identity seams). cmuxFeature builds for the iOS simulator.
  • macOS: cmux app BUILD SUCCEEDED (Mac client + AppDelegate + test target); pbxproj test-wiring lint passes.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches authenticated cloud APIs and mobile reconnect path (route overwrite), with mitigations (local fallback, lifecycle guards, owner-only POST/DELETE); requires DB migration.

Overview
Adds a team-scoped device registry so phones can discover fresher Mac attach routes on reload without re-scanning a QR, while reconnect still uses local paired-Mac routes first and treats the cloud as a best-effort rendezvous.

Web: New devices / device_app_instances schema and migration; /api/devices with Stack auth, team scoping (X-Cmux-Team-Id), POST/GET/DELETE, per-team caps, route sanitization, and only the registering user may update or delete a device’s routes. CI runs devices-route.test.ts in the DB job.

Mac: DeviceRegistryClient POSTs to the registry when MobileHostService route ads change (deduped by team + tag + routes, including clearing routes when pairing turns off), wired from AppDelegate.

iOS: DeviceRegistryService + DeviceRegistryRefreshing read the registry; MobileShellComposite kicks off a non-blocking background refresh on stored-Mac reconnect, applies selectReconnectRoutes / shouldApplyRegistryRefresh so stale routes update without resurrecting forgotten pairings, and skips ambiguous multi-tag instances. CMUXMobileRootScene injects the client from auth.

Tests: Swift route-selection/parse/identity and Mac re-register policy tests; web DB tests for registry API behavior.

Reviewed by Cursor Bugbot for commit 17562bc. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds a team‑scoped device registry so the phone auto‑pairs to its Mac on reload without re‑scanning a QR. Now models device identity per team, avoids cross‑tag route mixing, and limits updates/deletes to the registering user.

  • New Features

    • Web: devices and device_app_instances tables; per‑team identity via (team_id, device_uuid); /api/devices with POST (idempotent), GET, DELETE; Stack auth; team scoping via X-Cmux-Team-Id or ?teamId=; cap instances per device; bound tag; store only structurally valid routes (drop scalars/arrays; enforce MAX_ROUTES).
    • Mac: Device registry client observes MobileHostService.statusUpdates() and registers on route changes; skips empty routes; publishes a one‑time empty update on nonempty→empty to clear stale routes; wired in AppDelegate.
    • iOS: Registry reader updates MobilePairedMacStore via selectReconnectRoutes(); MobileDeviceIdentity persists a cmux UUID; safe cross‑tag selection uses registry routes only when exactly one instance advertises any; per‑route failible decoding skips malformed/unknown kinds; scoped deviceID and route‑selection helpers onto DeviceRegistryService (call sites updated, no behavior change).
    • Tests/CI: Web tests cover caps, filtering, multi‑team rows, list/delete behavior, and ownership; iOS route‑selection/parse; Mac clear‑on‑off and dedup; CI runs devices-route.test.ts in the web-db-migrations job.
    • Migration: Apply DB migration 20260608055850_device_registry.
  • Bug Fixes

    • Web: Only the registering user can update a device’s routes (POST 403 device_not_owned); DELETE is also owner‑scoped (non‑owners are a no‑op).
    • Mac: Re‑registration dedup now keys on (teamId, tag, routes) so switching teams re‑registers even when routes are unchanged; marks the re‑register policy nonisolated to fix test actor isolation; resolves the team after auth bootstrap before registering to avoid wrong‑team registration at launch (team used in both the dedup key and X-Cmux-Team-Id).
    • iOS: Registry refresh re‑checks lifecycle before writing to avoid resurrecting or reactivating a removed/changed pairing (sign‑out, user switch, forgotten Mac, or active‑Mac switch).

Written for commit 17562bc. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Introduced device registry for syncing/discovering paired Macs, including background non-blocking refresh of reconnect routes and stable local device ID persistence.
    • Added client behavior to keep a device entry current and re-register only when scope changes.
  • Tests

    • Added end-to-end and unit tests covering registry parsing, route selection, re-registration policy, and API behaviors.
  • Chores

    • Added DB schema, migrations and server API endpoints (GET/POST/DELETE) for device registry; wired startup initialization.

lawrencecchen and others added 2 commits June 7, 2026 21:53
Server-side device registry so a phone can auto-pair on reload instead of
re-scanning a QR. Two-level model: `devices` (a physical Mac/host, keyed by a
cmux-generated persisted UUID) -> `device_app_instances` (one running cmux
build/tag, holding the attach routes). Both carry a Stack `team_id` and a
flexible `labels` jsonb. Registry is a best-effort rendezvous layer: a phone
keeps its local paired-Mac store and falls back to it when the registry is
down, so pairing survives the cloud dying.

- web/db/schema.ts: devices + device_app_instances tables, key-pinning seam
  documented (device id will later anchor a pinned key for revoke).
- web/app/api/devices/route.ts: POST register (idempotent per device / per
  (device, tag)), GET list, DELETE; team-scoped via X-Cmux-Team-Id and rejects
  teams the caller is not a member of; per-team cap with an advisory lock.
- migration generated via drizzle-kit (v1 snapshot chain).
- tests/devices-route.test.ts: register+list, route-refresh on re-register
  (the auto-pair path), non-member team 403, delete cascade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mac (Sources/Cloud/DeviceRegistryClient.swift): registers this Mac + its app
instance's attach routes in the team registry, observing
MobileHostService.statusUpdates() and POSTing /api/devices whenever the route
set changes (moved networks / new port). Mirrors PhonePushClient auth
(Bearer + X-Stack-Refresh-Token + X-Cmux-Team-Id, AuthEnvironment.vmAPIBaseURL),
best-effort and non-blocking. Gated implicitly on non-empty routes, so it only
registers once the user has enabled mobile pairing (no separate opt-in). A pure
shouldReRegister() skips redundant POSTs on connection-only status ticks; wired
in AppDelegate next to PhonePushClient/MobileHostService configure.

iOS (Packages/CmuxMobileShell): MobileDeviceIdentity persists a cmux-generated
device UUID (mirrors the Mac's MobileHostIdentity, not a hardware fingerprint).
DeviceRegistryService reads /api/devices for a Mac's fresh routes; injected into
MobileShellComposite as DeviceRegistryRefreshing. On reconnect, a detached
non-blocking refresh fetches registry routes and, via the pure
selectReconnectRoutes() (registry-fresh wins, falls back to local when the
registry is empty/unreachable), writes fresher routes into MobilePairedMacStore
so the next reconnect trigger reaches a moved Mac. The connect itself still uses
local routes with zero added latency on the common case. CMUXMobileRootScene
builds the service over the AuthCoordinator (tokens + resolvedTeamID).

Phone self-registration as a device row is deferred to the key-pinning phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 8, 2026 7:24am
cmux-staging Building Building Preview, Comment Jun 8, 2026 7:24am

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a device registry end-to-end: backend device/instance storage and API, macOS client publishing/upsert logic, iOS fetch/parsing service, reconnect background refresh, route-selection policies, identity persistence, and tests exercising parsing, dedupe, authorization, and migration-backed DB behavior.

Changes

Device Registry: Complete Flow

Layer / File(s) Summary
Route selection contracts, device identity, and policy tests
Packages/CmuxMobileShell/Sources/.../DeviceRegistryRefreshing.swift, Packages/CmuxMobileShell/Sources/.../MobileDeviceIdentity.swift, Packages/CmuxMobileShell/Tests/.../DeviceRegistryRouteSelectionTests.swift
Introduces DeviceRegistryRefreshing async protocol, DeviceRegistryRouteSelection.selectReconnectRoutes() and shouldApplyRegistryRefresh(), stable iOS device UUID via MobileDeviceIdentity.deviceID(), and tests covering selection, parsing robustness, and refresh-application policy.
iOS registry fetch service and parsing
Packages/CmuxMobileShell/Sources/.../DeviceRegistryService.swift
Adds DeviceRegistryService actor implementing freshRoutes(forMacDeviceID:) with injected async token closures, GET request construction, timeout handling, and fail-soft JSON decoding that returns routes only when exactly one instance yields a non-empty, decodable route set.
iOS shell reconnect wiring and background refresh
Packages/CmuxMobileShell/Sources/.../MobileShellComposite.swift, ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
Injects optional deviceRegistry into MobileShellComposite (factory in CMUXMobileRootScene), and starts a detached background refreshRoutesFromRegistry() during reconnect to fetch, select, validate, and upsert refreshed routes without blocking the immediate connect.
macOS registration client singleton and deduplication logic
Sources/Cloud/DeviceRegistryClient.swift
Adds @MainActor DeviceRegistryClient configured with AuthCoordinator, observes MobileHostService.shared.statusUpdates(), decides re-registration via shouldReRegister(previous:current:), and posts idempotent POST /api/devices with tokens and optional team header.
macOS client unit tests and startup wiring
cmuxTests/DeviceRegistryClientTests.swift, Sources/AppDelegate.swift
Adds unit tests exercising re-registration policy across route/team/state transitions and wires DeviceRegistryClient.shared in AppDelegate.configure().
Build system integration
cmux.xcodeproj/project.pbxproj
Registers DeviceRegistryClient.swift and its tests in the Xcode project, groups, and target source phases.
Backend database schema and migration
web/db/schema.ts, web/db/migrations/20260608055850_device_registry/migration.sql, web/db/migrations/.../snapshot.json
Adds devices and device_app_instances tables with JSONB routes/labels, timestamps, uniqueness and indexes, and a cascading foreign key; includes SQL migration and Drizzle snapshot.
Backend device registry API handlers
web/app/api/devices/route.ts
Implements POST (team-scoped register/upsert with advisory lock and per-team/per-device caps), GET (list team devices and instances), and DELETE (team-scoped device removal); includes bounded JSON parsing, validation, and team authorization.
Backend integration tests and CI
web/tests/devices-route.test.ts, .github/workflows/ci.yml
Adds DB-backed tests covering register/list/upsert, re-register behavior, per-device tag caps, payload validation, multi-team behavior, authorization, delete cascade; CI updated to run these tests post-migration.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#5513: Overlaps reconnect/activation flow updates; both modify Mac reconnect behavior and active/forget/switch semantics.
  • manaflow-ai/cmux#4309: Related work touching extension/sidebar and state hydration; may intersect UI/state plumbing used by shell flows.

Poem

🐰 I hopped through code with joy and cheer,
New registry paths both far and near,
Macs now post, the backend keeps,
iOS peeks while the user sleeps,
Routes refreshed, reconnects draw near.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error DeviceRegistryRefreshing protocol lacks nonisolated, implicitly @MainActor in Swift 6; deviceRegistryLog Logger needs nonisolated in actor context. Add nonisolated to protocol DeviceRegistryRefreshing and to private let deviceRegistryLog in DeviceRegistryService.swift.
Cmux Swift Blocking Runtime ❌ Error MobileShellComposite.swift introduces ContinuousClock().sleep(for: .seconds(6)) in production code, violating swift-blocking-runtime.md rule forbidding Task.sleep. Replace the 6-second sleep with timeoutAfter, deadline-based task cancellation, or signal-based completion instead of explicit sleep.
Cmux Swift Concurrency ❌ Error MobileShellComposite.refreshRoutesFromRegistry (line 1010) creates a fire-and-forget Task with meaningful lifecycle (network, store access, state mutation) that is not stored or cancellable. Store as property like other background tasks and cancel in deinit to ensure proper Task lifecycle management.
Cmux Swift Logging ❌ Error Sources/Cloud/DeviceRegistryClient.swift line 139 uses NSLog, which violates swift-logging.md rules prohibiting NSLog in production code. Replace NSLog with a Logger instance declared as nonisolated private let per the swift-logging rules.
Docstring Coverage ⚠️ Warning Docstring coverage is 34.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Device registry P1: auto-pair the phone on reload' directly reflects the main change: enabling automatic phone-to-Mac pairing on reload via a device registry.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux No Hacky Sleeps ✅ Passed No hacky sleeps found. Production TypeScript uses PostgreSQL advisory locks, database transactions with onConflict, and async/await for proper synchronization. Tests use deterministic scaffolding.
Cmux Algorithmic Complexity ✅ Passed All operations are linear-time: GET endpoint O(D+I) with D≤200, I≤5000; sorting ≤16 routes; parsing ≤25 instances. No nested scans, no per-target rescans.
Cmux Swift @Concurrent ✅ Passed Network operations correctly use actor isolation: DeviceRegistryService is an actor, MobileShellComposite uses Task, and DeviceRegistryClient is intentionally MainActor-bound.
Cmux Swift File And Package Boundaries ✅ Passed New files under 400 lines with clear responsibilities, iOS logic in SwiftPM package, DeviceRegistryClient follows PhonePushClient precedent with tested pure policy extraction.
Cmux User-Facing Error Privacy ✅ Passed All error messages are generic product-level codes (invalid_device_id, device_not_owned, too_many_devices, etc.) with no exposure of database schema, credentials, or internal implementation details.
Cmux Full Internationalization ✅ Passed PR adds no new user-facing UI text requiring localization. NSLog debug message and API error codes are allowed per rule (debug-only logs and technical protocol identifiers).
Cmux Swiftui State Layout ✅ Passed No SwiftUI state layout violations found. MobileShellComposite uses @Observable/@mainactor correctly. New services are non-SwiftUI actors/classes. No @Published/@StateObject patterns detected.
Cmux Architecture Rethink ✅ Passed Device registry uses clean async/actor patterns with single state owners, pure testable route-selection functions, standard async-sequence observers, no timing/locks, and comprehensive tests.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds device registry services/models with no new NSWindow/NSPanel/NSWindowController/Window declarations; AppDelegate's +1 line adds service configuration only.
Cmux Source Artifacts ✅ Passed All 16 files are intentional source files (Swift/TypeScript), tests, configs, or required migration artifacts; no local tool output, generated logs, temp folders, caches, or artifacts detected.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the model, implementation across all platforms, and verification approach.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-device-registry-p1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4efa7c20cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// routes) reconnects immediately with no network round-trip. If the Mac
// moved networks / changed port, the refreshed routes land in the store
// and the next reconnect trigger (network change or Retry) uses them.
refreshRoutesFromRegistry(for: mac, stackUserID: stackUserID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use registry routes for the launch reconnect attempt

When the locally stored route is stale but /api/devices has the fresh address (the Mac moved networks or rebound to a new port), this starts the registry lookup in the background and then immediately reconnects with mac.routes below. The background task only upserts the store and reloads the paired-Mac list; it does not retry the in-flight launch reconnect, so the reload still fails until the user presses Retry or a network-change trigger happens, which misses the auto-pair-on-reload case this registry path is meant to rescue.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Introduces a team-scoped device registry (server schema + /api/devices GET/POST/DELETE, Mac-side DeviceRegistryClient, and iOS-side DeviceRegistryService) so a re-launched phone can fetch the Mac's current attach routes from the cloud instead of re-scanning a QR code. The registry is explicitly best-effort: clients keep locally persisted routes and fall back to them when the cloud is unreachable.

  • Web: Two new Drizzle tables (devices, device_app_instances) with correct per-team unique indexes and cascade delete; POST is idempotent per (teamId, deviceUuid, tag) with ownership guard and per-team advisory lock; GET returns a bounded list; DELETE is owner-scoped.
  • Mac: DeviceRegistryClient observes MobileHostService.statusUpdates() and POSTs on route-set/team/tag change, deduped by full scope.
  • iOS: DeviceRegistryService actor fetches the team's device list on reconnect; selectReconnectRoutes and shouldApplyRegistryRefresh are pure helpers guarding correctness of the background write-back; the background refresh Task is fire-and-forget with no stored handle (noted below).

Confidence Score: 5/5

Safe to merge; the registry is deliberately best-effort and all critical write-back paths have correct lifecycle guards.

The ownership model (per-team advisory lock, POST/DELETE scoped to the registering user) is solid. The iOS background refresh guards against sign-out, account switch, and active-Mac changes before writing. All writes are idempotent. The two findings are minor: a misleading doc comment about UserDefaults surviving reinstall, and a fire-and-forget Task that can't be cancelled on rapid reconnects. Neither affects correctness or security.

DeviceRegistryService.swift (doc comment about UserDefaults and reinstall) and MobileShellComposite.swift (untracked refresh Task handle).

Important Files Changed

Filename Overview
web/app/api/devices/route.ts POST/GET/DELETE for team-scoped device registry; ownership guard on POST and DELETE, advisory lock per team, bounded caps.
Sources/Cloud/DeviceRegistryClient.swift @mainactor Mac-side registry client; observes MobileHostService route changes and POSTs on scope change; dedup keyed on (teamId, tag, routes).
Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift iOS actor-based registry reader; static pure helpers for route selection and lifecycle guards; failable per-element route decoding; deviceID persisted in UserDefaults with incorrect 'survives reinstall' documentation.
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Adds non-blocking background registry refresh on reconnect; lifecycle guards prevent resurrecting removed pairings. The refresh Task is fire-and-forget with no stored handle.
web/db/schema.ts Adds devices and device_app_instances tables with correct unique indexes, cascade delete, and bounded jsonb columns.
ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift Wires DeviceRegistryService at composition root using live coordinator tokens and teamID; correctly guarded behind non-empty apiBaseURL.

Sequence Diagram

sequenceDiagram
    participant Mac as Mac (DeviceRegistryClient)
    participant API as /api/devices
    participant DB as Postgres
    participant Phone as iOS (DeviceRegistryService)
    participant Store as MobilePairedMacStore

    Note over Mac: Route set changes (network move / port change)
    Mac->>API: "POST /api/devices {deviceId, tag, routes}"
    API->>DB: advisory lock(teamId)
    API->>DB: SELECT existing device row
    DB-->>API: existing row (or null)
    alt Different user owns row
        API-->>Mac: 403 device_not_owned
    else Cap exceeded
        API-->>Mac: 429 too_many_devices / too_many_instances
    else OK
        API->>DB: INSERT ON CONFLICT DO UPDATE (routes, lastSeenAt)
        DB-->>API: deviceRowId
        API-->>Mac: "200 {ok, deviceId, teamId, tag}"
        Mac->>Mac: "lastRegistration = current scope"
    end

    Note over Phone: App reload / reconnect triggered
    Phone->>Store: read activeMac (local routes)
    Phone->>Phone: connect immediately on local routes
    Phone-->>Phone: refreshRoutesFromRegistry (detached)
    Phone->>API: GET /api/devices (Bearer + teamId header)
    API->>DB: SELECT devices + device_app_instances WHERE teamId
    DB-->>API: device rows + instance rows
    API-->>Phone: "{teamId, devices:[{deviceId, instances:[{tag, routes}]}]}"
    Phone->>Phone: selectReconnectRoutes(local, registry)
    alt Registry has fresher routes AND lifecycle guards pass
        Phone->>Store: upsert(macDeviceID, routes, markActive:true)
        Phone->>Phone: loadPairedMacs()
        Note over Phone: Next reconnect uses updated routes
    else Registry empty / unavailable / same routes
        Note over Phone: Local routes unchanged (fallback)
    end
Loading

Reviews (8): Last reviewed commit: "Resolve registry team after auth bootstr..." | Re-trigger Greptile

Comment thread web/app/api/devices/route.ts Outdated
Comment on lines +176 to +187
.onConflictDoUpdate({
target: devices.id,
set: {
teamId: team.teamId,
userId: user.id,
platform,
displayName,
labels,
lastSeenAt: now,
updatedAt: now,
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Cross-team device ownership is not atomic under the per-team advisory lock

The advisory lock key is derived from team.teamId, so two transactions registering the same deviceId under different teams acquire different locks and can run concurrently. If teams A and B both race to register the same (freshly-minted) UUID before either transaction commits, both read existing = null, both pass the conflict check, and team A inserts first — then team B's ON CONFLICT DO UPDATE SET teamId = teamB silently reassigns ownership.

The fix is to exclude teamId from the conflict update set (it should never change once the row is created), relying on the pre-insert conflict check to enforce cross-team exclusivity. With teamId locked out of the update, the second team's INSERT cannot overwrite the winning owner.

Comment on lines +109 to +114
} else {
NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode)
}
}
} catch {
// best-effort; registry must never disrupt the Mac.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 NSLog bypasses the unified logging system. The rest of cmux uses os.Logger — this failure path should too. Add import os at the top of the file and declare a private let deviceRegistryLog = Logger(subsystem: "...", category: "DeviceRegistry") to match the iOS client's pattern.

Suggested change
} else {
NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode)
}
}
} catch {
// best-effort; registry must never disrupt the Mac.
} else {
deviceRegistryLog.error("register failed status=\(http.statusCode, privacy: .public)")
}
}
} catch {
// best-effort; registry must never disrupt the Mac.

Rule Used: Flag production Swift diagnostics that bypass unif... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift`:
- Around line 19-26: The deviceID(defaults:) function currently does a
non-atomic check-then-set and accepts any non-empty string; make it atomic and
self-healing by serializing the check-and-write and validating stored values as
real UUIDs: create a private serial queue or use objc_sync_enter/exit around the
body of deviceID(defaults:) (reference deviceID(defaults:) and deviceIDKey) so
concurrent callers cannot race, read the stored string, validate it with
UUID(uuidString:) (and normalize with .lowercased()), and if missing or invalid
generate a new UUID().uuidString.lowercased(), persist it to defaults under
deviceIDKey, and return that value. Ensure you always overwrite invalid values
instead of returning them.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1017-1023: The background refresh should not re-activate an old
Mac; change the pairedMacStore.upsert call (the one passing macDeviceID and
markActive: true) so it does not force-mark active during a background refresh.
Instead either pass markActive: false or first query the current active device
via pairedMacStore (e.g., getActiveMacDeviceID/currentActive) and only set
markActive: true when macDeviceID equals that current active or when the
operation is an explicit user activation. Update the upsert invocation
surrounding macDeviceID/displayName/routes to use this conditional logic so
stale background tasks cannot flip the active row.

In `@web/app/api/devices/route.ts`:
- Around line 61-84: The readBoundedJson function currently calls request.text()
and checks raw.length (UTF-16 code units) after the fact; change it to stream
the request body and enforce MAX_REQUEST_BYTES before buffering: if
Content-Length exists and exceeds MAX_REQUEST_BYTES return 413 immediately,
otherwise use request.body/getReader() to read Uint8Array chunks, summing bytes
read (track totalBytes) and aborting with 413 as soon as totalBytes >
MAX_REQUEST_BYTES, accumulate chunks into a byte buffer, then decode once
(TextDecoder) and JSON.parse the decoded string; remove reliance on
request.text() and raw.length and keep existing error handling paths (return 400
on parse errors) while referencing readBoundedJson and MAX_REQUEST_BYTES.
- Around line 106-325: Extract the device registry workflow (auth, team
resolution, request parsing/validation, DB transaction and payload assembly, and
error-to-HTTP mapping) out of the HTTP handlers into a dedicated Effect service
with clear operations (e.g. registerDevice, listDevices, unregisterDevice). Move
the logic currently in the POST/GET/DELETE functions (including calls to
verifyRequest, resolveTeam, readBoundedJson, cloudDb, the transaction that
inserts into devices and deviceAppInstances, the device listing logic building
devicesPayload, and the delete call) into these Effect functions, have them
return typed domain errors (device_team_conflict, too_many_devices,
invalid_request, invalid_device_id, invalid_platform, etc.), and keep route.ts
handlers thin — they should only translate the incoming Request to the service
input, run the Effect, and map the service's typed errors to the same JSON HTTP
responses (status codes and shapes) you already use. Ensure unique symbols to
change: registerDevice (new Effect for POST logic), listDevices (new Effect for
GET logic), unregisterDevice (new Effect for DELETE logic); keep
verifyRequest/resolveTeam/readBoundedJson usage inside the service or pass
already-validated inputs from the route if you prefer; preserve existing
behavior and response shapes when mapping errors.

In `@web/db/schema.ts`:
- Around line 228-252: The device_app_instances table duplicates teamId without
enforcing it matches the parent devices row; update the schema for
deviceAppInstances to enforce a composite foreign key (deviceId, teamId)
referencing devices (id, team_id) so mismatched child rows cannot be inserted,
or alternatively remove the teamId column and update callers (e.g.,
web/app/api/devices/route.ts) to join through devices for team scoping; locate
the deviceAppInstances pgTable definition and either add a composite FK
constraint on (deviceId, teamId) -> (devices.id, devices.team_id) or drop
deviceAppInstances.teamId and fix reads to derive team from devices.

In `@web/tests/devices-route.test.ts`:
- Around line 68-176: Add two dbTest cases that exercise the 409 and 429
branches: (1) a test that seeds the DB with an existing device row owned by a
different team (use sql to insert into devices/device_app_instances for DEVICE_A
with team_id = "team-not-mine"), then call POST(registerRequest({...},
"team-a")) and assert status 409 to hit device_team_conflict; (2) a test that
seeds the DB to push the team over the device limit (use sql to insert many
devices for "team-a" or manipulate the device count threshold) then call
POST(registerRequest({ deviceId: NEW_DEVICE, ... })) and assert status 429 to
exercise too_many_devices/advisory-lock path; use the existing helpers
(registerRequest, POST, authHeaders, sql) and model the assertions after the
existing tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fefb1eec-ea41-4f4f-9208-d1650c7852d7

📥 Commits

Reviewing files that changed from the base of the PR and between 4424644 and 4efa7c2.

📒 Files selected for processing (15)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift
  • Sources/AppDelegate.swift
  • Sources/Cloud/DeviceRegistryClient.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/DeviceRegistryClientTests.swift
  • ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
  • web/app/api/devices/route.ts
  • web/db/migrations/20260608044827_device_registry/migration.sql
  • web/db/migrations/20260608044827_device_registry/snapshot.json
  • web/db/schema.ts
  • web/tests/devices-route.test.ts

Comment on lines +19 to +26
public static func deviceID(defaults: UserDefaults = .standard) -> String {
if let existing = defaults.string(forKey: deviceIDKey),
!existing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return existing
}
let generated = UUID().uuidString.lowercased()
defaults.set(generated, forKey: deviceIDKey)
return generated

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make device ID initialization atomic and self-healing.

Line 20 through Line 26 performs a non-atomic check-then-set, so concurrent first lookups can return different UUIDs. It also accepts any non-empty persisted value, which can propagate invalid IDs across the registry boundary.

💡 Suggested fix
 public enum MobileDeviceIdentity {
     private static let deviceIDKey = "cmux.deviceRegistry.iosDeviceID"
+    private static let lock = NSLock()

     /// The persisted device UUID, generating and storing one on first use.
     /// - Parameter defaults: Persistence store (injected for tests).
     public static func deviceID(defaults: UserDefaults = .standard) -> String {
-        if let existing = defaults.string(forKey: deviceIDKey),
-           !existing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
-            return existing
+        lock.lock()
+        defer { lock.unlock() }
+
+        if let raw = defaults.string(forKey: deviceIDKey) {
+            let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+            if let uuid = UUID(uuidString: trimmed) {
+                let canonical = uuid.uuidString.lowercased()
+                if canonical != raw {
+                    defaults.set(canonical, forKey: deviceIDKey)
+                }
+                return canonical
+            }
         }
+
         let generated = UUID().uuidString.lowercased()
         defaults.set(generated, forKey: deviceIDKey)
         return generated
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static func deviceID(defaults: UserDefaults = .standard) -> String {
if let existing = defaults.string(forKey: deviceIDKey),
!existing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return existing
}
let generated = UUID().uuidString.lowercased()
defaults.set(generated, forKey: deviceIDKey)
return generated
public enum MobileDeviceIdentity {
private static let deviceIDKey = "cmux.deviceRegistry.iosDeviceID"
// NSLock exception: UserDefaults is shared mutable state accessed concurrently on first-use.
// Lock ensures atomic check-then-generate-and-persist to prevent duplicate device IDs.
private static let lock = NSLock()
/// The persisted device UUID, generating and storing one on first use.
/// - Parameter defaults: Persistence store (injected for tests).
public static func deviceID(defaults: UserDefaults = .standard) -> String {
lock.lock()
defer { lock.unlock() }
if let raw = defaults.string(forKey: deviceIDKey) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let uuid = UUID(uuidString: trimmed) {
let canonical = uuid.uuidString.lowercased()
if canonical != raw {
defaults.set(canonical, forKey: deviceIDKey)
}
return canonical
}
}
let generated = UUID().uuidString.lowercased()
defaults.set(generated, forKey: deviceIDKey)
return generated
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeviceIdentity.swift`
around lines 19 - 26, The deviceID(defaults:) function currently does a
non-atomic check-then-set and accepts any non-empty string; make it atomic and
self-healing by serializing the check-and-write and validating stored values as
real UUIDs: create a private serial queue or use objc_sync_enter/exit around the
body of deviceID(defaults:) (reference deviceID(defaults:) and deviceIDKey) so
concurrent callers cannot race, read the stored string, validate it with
UUID(uuidString:) (and normalize with .lowercased()), and if missing or invalid
generate a new UUID().uuidString.lowercased(), persist it to defaults under
deviceIDKey, and return that value. Ensure you always overwrite invalid values
instead of returning them.

Comment on lines +61 to +84
async function readBoundedJson(
request: Request,
): Promise<{ ok: true; value: Record<string, unknown> } | { ok: false; status: number }> {
const lengthHeader = request.headers.get("content-length");
if (lengthHeader && Number(lengthHeader) > MAX_REQUEST_BYTES) {
return { ok: false, status: 413 };
}
let raw: string;
try {
raw = await request.text();
} catch {
return { ok: false, status: 400 };
}
if (raw.length > MAX_REQUEST_BYTES) return { ok: false, status: 413 };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, status: 400 };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, status: 400 };
}
return { ok: true, value: parsed as Record<string, unknown> };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce the body limit before buffering the whole request.

Line 70 reads the entire body into memory before rejecting it, and Line 74 compares UTF-16 code units instead of bytes. If Content-Length is missing or inaccurate, an authenticated caller can still push a payload well above MAX_REQUEST_BYTES before this path fails.

Proposed fix
 async function readBoundedJson(
   request: Request,
 ): Promise<{ ok: true; value: Record<string, unknown> } | { ok: false; status: number }> {
   const lengthHeader = request.headers.get("content-length");
   if (lengthHeader && Number(lengthHeader) > MAX_REQUEST_BYTES) {
     return { ok: false, status: 413 };
   }
-  let raw: string;
+  const reader = request.body?.getReader();
+  if (!reader) {
+    return { ok: false, status: 400 };
+  }
+  const decoder = new TextDecoder();
+  let bytes = 0;
+  let raw = "";
   try {
-    raw = await request.text();
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      bytes += value.byteLength;
+      if (bytes > MAX_REQUEST_BYTES) {
+        return { ok: false, status: 413 };
+      }
+      raw += decoder.decode(value, { stream: true });
+    }
+    raw += decoder.decode();
   } catch {
     return { ok: false, status: 400 };
   }
-  if (raw.length > MAX_REQUEST_BYTES) return { ok: false, status: 413 };
   let parsed: unknown;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/devices/route.ts` around lines 61 - 84, The readBoundedJson
function currently calls request.text() and checks raw.length (UTF-16 code
units) after the fact; change it to stream the request body and enforce
MAX_REQUEST_BYTES before buffering: if Content-Length exists and exceeds
MAX_REQUEST_BYTES return 413 immediately, otherwise use request.body/getReader()
to read Uint8Array chunks, summing bytes read (track totalBytes) and aborting
with 413 as soon as totalBytes > MAX_REQUEST_BYTES, accumulate chunks into a
byte buffer, then decode once (TextDecoder) and JSON.parse the decoded string;
remove reliance on request.text() and raw.length and keep existing error
handling paths (return 400 on parse errors) while referencing readBoundedJson
and MAX_REQUEST_BYTES.

Comment on lines +106 to +325
export async function POST(request: Request): Promise<Response> {
const user = await verifyRequest(request, {
requestedTeamId: requestedVmTeamIdFromRequest(request),
allowCookie: false,
});
if (!user) return unauthorized();

const team = resolveTeam(request, user);
if (!team.ok) return team.response;

const body = await readBoundedJson(request);
if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status);

const deviceId = trimmedString(body.value.deviceId).toLowerCase();
const platform = trimmedString(body.value.platform).toLowerCase();
const displayName = trimmedString(body.value.displayName) || null;
const labels = recordOrEmpty(body.value.labels);
const tag = trimmedString(body.value.tag) || "default";
const routes = routesArray(body.value.routes);
const instanceLabels = recordOrEmpty(body.value.instanceLabels);

if (!UUID_RE.test(deviceId)) {
return jsonResponse({ error: "invalid_device_id" }, 400);
}
if (!ALLOWED_PLATFORMS.has(platform)) {
return jsonResponse({ error: "invalid_platform" }, 400);
}

const db = cloudDb();
const now = new Date();

const registered = await db.transaction(async (tx) => {
// Serialize concurrent registrations for the same team so the per-team cap
// is enforced without a race (mirrors the device-tokens advisory lock).
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${team.teamId}, 7))`);

const [existing] = await tx
.select({ id: devices.id, teamId: devices.teamId })
.from(devices)
.where(eq(devices.id, deviceId))
.limit(1);

// A device id is global (cmux-generated UUID). If it already exists under a
// different team, the caller cannot claim it (prevents cross-team takeover).
if (existing && existing.teamId !== team.teamId) {
return { error: "device_team_conflict" as const };
}

if (!existing) {
const [{ total }] = await tx
.select({ total: sql<number>`count(*)::int` })
.from(devices)
.where(eq(devices.teamId, team.teamId));
if (Number(total) >= MAX_DEVICES_PER_TEAM) {
return { error: "too_many_devices" as const };
}
}

await tx
.insert(devices)
.values({
id: deviceId,
teamId: team.teamId,
userId: user.id,
platform,
displayName,
labels,
lastSeenAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: devices.id,
set: {
teamId: team.teamId,
userId: user.id,
platform,
displayName,
labels,
lastSeenAt: now,
updatedAt: now,
},
});

await tx
.insert(deviceAppInstances)
.values({
deviceId,
teamId: team.teamId,
tag,
routes,
labels: instanceLabels,
lastSeenAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [deviceAppInstances.deviceId, deviceAppInstances.tag],
set: {
teamId: team.teamId,
routes,
labels: instanceLabels,
lastSeenAt: now,
updatedAt: now,
},
});

return { error: null };
});

if (registered.error === "device_team_conflict") {
return jsonResponse({ error: "device_team_conflict" }, 409);
}
if (registered.error === "too_many_devices") {
return jsonResponse({ error: "too_many_devices" }, 429);
}

return jsonResponse({ ok: true, deviceId, teamId: team.teamId, tag });
}

type DeviceListRow = {
id: string;
platform: string;
displayName: string | null;
labels: Record<string, unknown>;
lastSeenAt: Date;
};

/**
* List the team's registered devices and their app instances, so a phone can
* find the Mac it last paired with and refresh routes on reload.
*/
export async function GET(request: Request): Promise<Response> {
const user = await verifyRequest(request, {
requestedTeamId: requestedVmTeamIdFromRequest(request),
allowCookie: false,
});
if (!user) return unauthorized();

const team = resolveTeam(request, user);
if (!team.ok) return team.response;

const db = cloudDb();

const deviceRows = (await db
.select({
id: devices.id,
platform: devices.platform,
displayName: devices.displayName,
labels: devices.labels,
lastSeenAt: devices.lastSeenAt,
})
.from(devices)
.where(eq(devices.teamId, team.teamId))
.orderBy(desc(devices.lastSeenAt))) as DeviceListRow[];

const instanceRows = await db
.select({
deviceId: deviceAppInstances.deviceId,
tag: deviceAppInstances.tag,
routes: deviceAppInstances.routes,
labels: deviceAppInstances.labels,
lastSeenAt: deviceAppInstances.lastSeenAt,
})
.from(deviceAppInstances)
.where(eq(deviceAppInstances.teamId, team.teamId))
.orderBy(desc(deviceAppInstances.lastSeenAt));

const instancesByDevice = new Map<string, typeof instanceRows>();
for (const row of instanceRows) {
const list = instancesByDevice.get(row.deviceId) ?? [];
list.push(row);
instancesByDevice.set(row.deviceId, list);
}

const devicesPayload = deviceRows.map((device) => ({
deviceId: device.id,
platform: device.platform,
displayName: device.displayName,
labels: device.labels,
lastSeenAt: device.lastSeenAt.toISOString(),
instances: (instancesByDevice.get(device.id) ?? []).map((instance) => ({
tag: instance.tag,
routes: instance.routes,
labels: instance.labels,
lastSeenAt: instance.lastSeenAt.toISOString(),
})),
}));

return jsonResponse({ teamId: team.teamId, devices: devicesPayload });
}

/**
* Unregister a device (e.g. when the user forgets/decommissions a Mac). Removes
* the machine row and cascades its app instances. Team-scoped so a caller can
* only delete devices in a team they belong to.
*/
export async function DELETE(request: Request): Promise<Response> {
const user = await verifyRequest(request, {
requestedTeamId: requestedVmTeamIdFromRequest(request),
allowCookie: false,
});
if (!user) return unauthorized();

const team = resolveTeam(request, user);
if (!team.ok) return team.response;

const body = await readBoundedJson(request);
if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status);

const deviceId = trimmedString(body.value.deviceId).toLowerCase();
if (!UUID_RE.test(deviceId)) {
return jsonResponse({ error: "invalid_device_id" }, 400);
}

const db = cloudDb();
await db
.delete(devices)
.where(and(eq(devices.id, deviceId), eq(devices.teamId, team.teamId)));

return jsonResponse({ ok: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the file with context around the shown lines
sed -n '1,240p' web/app/api/devices/route.ts
echo "----"
sed -n '240,420p' web/app/api/devices/route.ts

echo "---- Searching for Effect usage in this file ----"
rg -n "Effect\.|from 'effect'|`@effect`|Effect" web/app/api/devices/route.ts || true

echo "---- Searching for existing devices service modules ----"
fd -t f "devices.*\.(ts|tsx)" web | head -n 50
rg -n "devices" web/services web/app/api | head -n 200

Repository: manaflow-ai/cmux

Length of output: 13343


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- Searching for Effect usage in web/app/api and web/services ----"
rg -n "from ['\"]effect['\"]|Effect\." web/app/api web/services || true

echo "---- Inspecting web/app/api/device-tokens/route.ts (closest sibling) ----"
sed -n '1,240p' web/app/api/device-tokens/route.ts

echo "---- Inspecting web/app/api/notifications/push/route.ts (calls forwarding) ----"
sed -n '1,260p' web/app/api/notifications/push/route.ts

echo "---- Looking for an existing Effect service pattern under web/services ----"
fd -t f web/services | head -n 200
rg -n "runEffect|mapError|Effect\.gen|Effect\.runPromise|tryCatch|typed errors" web/services || true

Repository: manaflow-ai/cmux

Length of output: 19721


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- Listing web/services files (top-level, limited) ----"
fd . web/services -t f | head -n 200

echo "---- Find any devices-registry or deviceAppInstances usage outside the route ----"
rg -n "deviceAppInstances|MAX_DEVICES_PER_TEAM|routesArray\\(|invalid_device_id" web --glob='*.ts' || true

echo "---- Inspect routeHandler / effect boundary patterns ----"
rg -n "with[A-Za-z]*ApiRoute|routeHandler|Effect\\.runPromise|mapError|typed errors" web/services web/app/api --glob='*.ts' || true

# Print the routeHandler implementation if it exists
fd -t f "routeHandler*.*" web/services | head -n 20 | cat
fd -t f "routeHandler*.*" web/services | head -n 1 | xargs -I{} sh -lc 'echo "---- {} ----"; sed -n "1,220p" "{}"'

Repository: manaflow-ai/cmux

Length of output: 6223


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- Inspecting route helper used by vm routes ----"
sed -n '1,220p' web/services/vms/routeHelpers.ts

echo "---- Inspecting a vm API route that calls a service/workflow ----"
sed -n '1,140p' web/app/api/vm/route.ts

echo "---- Inspecting vm/[id]/attach-endpoint route (example boundary) ----"
sed -n '1,220p' web/app/api/vm/[id]/attach-endpoint/route.ts

echo "---- Searching for Effect.runPromise usage in web/app/api routes ----"
rg -n "Effect\.runPromise|Effect\.run" web/app/api --glob='*.ts' || true

echo "---- Inspecting vms/workflows error/typed-mapping pattern ----"
sed -n '1,220p' web/services/vms/workflows.ts

Repository: manaflow-ai/cmux

Length of output: 22942


Extract the device registry workflow from web/app/api/devices/route.ts into an Effect service

POST/GET/DELETE currently inline auth+team resolution, request validation, the DB transaction/building payloads, and HTTP error mapping—so route.ts becomes the workflow boundary. Repo guidance for {web/app/api/**, web/routes/**}/*.ts is to keep handlers thin and run a single backend Effect program at the boundary, mapping typed errors to HTTP responses and treating unexpected defects separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/devices/route.ts` around lines 106 - 325, Extract the device
registry workflow (auth, team resolution, request parsing/validation, DB
transaction and payload assembly, and error-to-HTTP mapping) out of the HTTP
handlers into a dedicated Effect service with clear operations (e.g.
registerDevice, listDevices, unregisterDevice). Move the logic currently in the
POST/GET/DELETE functions (including calls to verifyRequest, resolveTeam,
readBoundedJson, cloudDb, the transaction that inserts into devices and
deviceAppInstances, the device listing logic building devicesPayload, and the
delete call) into these Effect functions, have them return typed domain errors
(device_team_conflict, too_many_devices, invalid_request, invalid_device_id,
invalid_platform, etc.), and keep route.ts handlers thin — they should only
translate the incoming Request to the service input, run the Effect, and map the
service's typed errors to the same JSON HTTP responses (status codes and shapes)
you already use. Ensure unique symbols to change: registerDevice (new Effect for
POST logic), listDevices (new Effect for GET logic), unregisterDevice (new
Effect for DELETE logic); keep verifyRequest/resolveTeam/readBoundedJson usage
inside the service or pass already-validated inputs from the route if you
prefer; preserve existing behavior and response shapes when mapping errors.

Sources: Coding guidelines, Learnings

Comment thread web/db/schema.ts
Comment on lines +68 to +176
describe("device registry route", () => {
dbTest("registers a Mac and its app instance, then lists it for the team", async () => {
if (!sql) throw new Error("test database not initialized");

const register = await POST(
registerRequest({
deviceId: DEVICE_A,
platform: "mac",
displayName: "Lawrence's Mac",
tag: "stable",
routes: [{ id: "r1", kind: "tailscale", priority: 0, endpoint: { host: "100.1.2.3", port: 51001 } }],
}),
);
expect(register.status).toBe(200);

const listResponse = await GET(
new Request("https://cmux.test/api/devices", { method: "GET", headers: authHeaders() }),
);
expect(listResponse.status).toBe(200);
const list = (await listResponse.json()) as {
teamId: string;
devices: Array<{
deviceId: string;
displayName: string | null;
platform: string;
instances: Array<{ tag: string; routes: unknown[] }>;
}>;
};
expect(list.teamId).toBe("team-a");
expect(list.devices).toHaveLength(1);
expect(list.devices[0].deviceId).toBe(DEVICE_A);
expect(list.devices[0].displayName).toBe("Lawrence's Mac");
expect(list.devices[0].instances).toHaveLength(1);
expect(list.devices[0].instances[0].tag).toBe("stable");
expect(list.devices[0].instances[0].routes).toHaveLength(1);
});

dbTest("re-registering the same (device, tag) refreshes routes in place (auto-pair path)", async () => {
if (!sql) throw new Error("test database not initialized");

await POST(
registerRequest({
deviceId: DEVICE_A,
platform: "mac",
tag: "stable",
routes: [{ id: "old", kind: "tailscale", priority: 0, endpoint: { host: "100.0.0.1", port: 1 } }],
}),
);
// Mac moved networks / restarted on a new port: re-register with fresh routes.
await POST(
registerRequest({
deviceId: DEVICE_A,
platform: "mac",
tag: "stable",
routes: [{ id: "new", kind: "tailscale", priority: 0, endpoint: { host: "100.9.9.9", port: 51999 } }],
}),
);

const [{ total }] = await sql<{ total: number }[]>`
select count(*)::int as total from device_app_instances where device_id = ${DEVICE_A}
`;
expect(total).toBe(1);

const list = (await (
await GET(new Request("https://cmux.test/api/devices", { method: "GET", headers: authHeaders() }))
).json()) as { devices: Array<{ instances: Array<{ routes: Array<{ endpoint: { host: string } }> }> }> };
expect(list.devices[0].instances[0].routes[0].endpoint.host).toBe("100.9.9.9");
});

dbTest("rejects a team the caller is not a member of", async () => {
if (!sql) throw new Error("test database not initialized");

const response = await POST(
registerRequest(
{ deviceId: DEVICE_A, platform: "mac", routes: [] },
"team-not-mine",
),
);
expect(response.status).toBe(403);

const [{ total }] = await sql<{ total: number }[]>`select count(*)::int as total from devices`;
expect(total).toBe(0);
});

dbTest("delete removes the device and cascades its instances", async () => {
if (!sql) throw new Error("test database not initialized");

await POST(registerRequest({ deviceId: DEVICE_A, platform: "mac", tag: "stable", routes: [] }));
await POST(registerRequest({ deviceId: DEVICE_B, platform: "mac", tag: "stable", routes: [] }));

const del = await DELETE(
new Request("https://cmux.test/api/devices", {
method: "DELETE",
headers: authHeaders(),
body: JSON.stringify({ deviceId: DEVICE_A }),
}),
);
expect(del.status).toBe(200);

const [{ devicesTotal }] = await sql<{ devicesTotal: number }[]>`
select count(*)::int as "devicesTotal" from devices
`;
expect(devicesTotal).toBe(1);
const [{ instancesTotal }] = await sql<{ instancesTotal: number }[]>`
select count(*)::int as "instancesTotal" from device_app_instances where device_id = ${DEVICE_A}
`;
expect(instancesTotal).toBe(0);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add coverage for the two POST safety branches that matter most here.

This suite never exercises device_team_conflict or too_many_devices, so the cross-team takeover guard and the advisory-lock/cap path can regress without a failing test. Please add DB-backed cases for the 409 and 429 branches.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/tests/devices-route.test.ts` around lines 68 - 176, Add two dbTest cases
that exercise the 409 and 429 branches: (1) a test that seeds the DB with an
existing device row owned by a different team (use sql to insert into
devices/device_app_instances for DEVICE_A with team_id = "team-not-mine"), then
call POST(registerRequest({...}, "team-a")) and assert status 409 to hit
device_team_conflict; (2) a test that seeds the DB to push the team over the
device limit (use sql to insert many devices for "team-a" or manipulate the
device count threshold) then call POST(registerRequest({ deviceId: NEW_DEVICE,
... })) and assert status 429 to exercise too_many_devices/advisory-lock path;
use the existing helpers (registerRequest, POST, authHeaders, sql) and model the
assertions after the existing tests.

lawrencecchen and others added 2 commits June 7, 2026 22:31
…lish

Addresses autoreview findings on the new registry path:

- Cap app instances per device (MAX_INSTANCES_PER_DEVICE) in the same advisory-
  lock transaction, mirroring the per-team device cap. `tag` is client-supplied
  and the instance key is (deviceId, tag), so without this one device could
  create unbounded rows by varying the tag. Re-registering an existing tag stays
  an update. Also bound tag length.

- Server stores only structurally valid route entries (plain objects, bounded by
  MAX_ROUTES); scalars/arrays are dropped. Semantic CmxAttachRoute validation
  stays with the typed clients so the server is forward-compatible with new
  route kinds.

- iOS parser decodes each route failably and per-element, so one malformed or
  unknown-kind route from any instance (even another Mac's) is skipped instead
  of nil-ing the whole /api/devices response and disabling refresh for every Mac.

- Mac DeviceRegistryClient.shouldReRegister now fires once on the nonempty->empty
  transition (pairing turned off), publishing the empty route set so the registry
  stops advertising stale routes; the phone already skips empty-route instances.
  Initial-empty and repeated-empty ticks stay no-ops.

Tests: web instance-cap + route-filtering cases; iOS malformed-sibling and
malformed-within-target skip cases; Mac clear-publishes-once and still-empty
no-op cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
So the device registry route's behavioral coverage (register/list, team-scope
403, instance cap, route filtering, delete cascade) gates in CI, not just
locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
let routes = instance.routes.compactMap(\.value)
if !routes.isEmpty { return routes }
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale routes from older tags

Medium Severity

When resolving registry routes for a Mac, the client walks instances in recency order and returns the first with any decodable routes. If the newest instance has empty routes (e.g. pairing turned off) but an older (deviceId, tag) row still has routes, those stale endpoints are returned and can overwrite local routes via selectReconnectRoutes, undermining the Mac’s empty-route publish.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90fc5e4. Configure here.

Comment thread web/app/api/devices/route.ts Outdated
lastSeenAt: now,
updatedAt: now,
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cross-team device registration race

Medium Severity

POST serializes registrations with a per-teamId advisory lock, but device ownership is keyed globally by deviceId. Concurrent first-time registrations for the same deviceId from two teams can both miss an existing row and the onConflictDoUpdate path can overwrite team_id, moving a device between teams without returning device_team_conflict.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90fc5e4. Configure here.

Comment thread Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a86cb466a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/app/api/devices/route.ts Outdated
Comment on lines +195 to +198
.onConflictDoUpdate({
target: devices.id,
set: {
teamId: team.teamId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the existing team on insert conflicts

When two different teams register the same deviceId concurrently, the per-team advisory lock does not serialize those transactions: both can observe no existing row, then the loser of the insert race hits this conflict handler and updates teamId to its own team. That bypasses the cross-team takeover check above and can move an already-created device row between teams; the conflict path needs to avoid changing ownership or re-check the existing team_id atomically.

Useful? React with 👍 / 👎.

Comment on lines +1013 to +1030
local: localRoutes,
registry: registryRoutes
) else { return }
do {
try await pairedMacStore.upsert(
macDeviceID: macDeviceID,
displayName: displayName,
routes: updated,
markActive: true,
stackUserID: stackUserID
)
} catch {
mobileShellLog.debug("registry route refresh upsert failed: \(String(describing: error), privacy: .public)")
return
}
await self?.loadPairedMacs()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Background refresh silently overrides the user's active Mac

refreshRoutesFromRegistry runs in a detached Task and calls pairedMacStore.upsert(markActive: true, ...). markActive: true deactivates every other Mac for the user (via UPDATE paired_macs SET is_active = 0 WHERE stack_user_id IS ?) and then sets this Mac as active. If the user switches to a different Mac between when the reconnect starts and when the network response lands (e.g., a slow registry round-trip), the background upsert silently reverts the switch: the user's newly selected Mac is deactivated and the original Mac is re-activated.

The goal of this path is route freshness only — markActive: false updates the stored routes without touching the active-Mac selection, which is the correct invariant here.

…n on iOS

Addresses round-2 autoreview lifecycle findings:

- Mac DeviceRegistryClient deduped on routes alone, so an account/team switch
  with unchanged routes never registered the Mac in the newly selected team.
  Dedup now keys on a (teamID, tag, routes) scope, resolved before the skip
  decision, so a team switch re-registers even when routes are identical.
  Leaving the old team's row behind is acceptable by design (best-effort,
  stale-tolerant registry); registering in the new team is the fix.

- iOS registry refresh ran a detached upsert(markActive: true) after the network
  await without re-checking lifecycle, so signing out, forgetting the Mac, or
  switching the active Mac while freshRoutes was in flight could resurrect or
  reactivate the removed pairing (and expose it to the next user on a shared
  device). It now re-reads the active Mac and applies a pure
  shouldApplyRegistryRefresh guard (still signed in, same user, same active Mac)
  before writing, mirroring the existing user-switch guard in loadPairedMacs.

Tests: Mac team-switch-with-unchanged-routes fires; iOS shouldApplyRegistryRefresh
sign-out / user-switch / forgotten / active-mac-switched all reject.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/Cloud/DeviceRegistryClient.swift (1)

118-118: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use Logger instead of NSLog for production diagnostics.

The project's Swift logging guidelines prohibit NSLog in production app/runtime code. Use os.Logger instead for this diagnostic logging.

📝 Suggested fix

Add a Logger constant at file scope:

import os

private let deviceRegistryLog = Logger(subsystem: "com.cmuxterm.app", category: "DeviceRegistry")

Then replace the NSLog call:

                } else {
-                    NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode)
+                    deviceRegistryLog.debug("register failed status=\(http.statusCode, privacy: .public)")
                }

As per coding guidelines .github/review-bot-rules/swift-logging.md, production Swift code must use Apple's unified logging system (Logger) instead of NSLog. The exception for NSLog applies only to Sources/Providers/ fetcher files, not Sources/Cloud/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Cloud/DeviceRegistryClient.swift` at line 118, Replace the NSLog call
with os.Logger: add `import os` and a file‑scope Logger constant (e.g. `private
let deviceRegistryLog = Logger(subsystem: "com.cmuxterm.app", category:
"DeviceRegistry")`) in DeviceRegistryClient.swift, then change the
`NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode)` line
to use `deviceRegistryLog.error` with string interpolation to include
`http.statusCode` (e.g. a single error log call that includes the status code).

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Sources/Cloud/DeviceRegistryClient.swift`:
- Line 118: Replace the NSLog call with os.Logger: add `import os` and a
file‑scope Logger constant (e.g. `private let deviceRegistryLog =
Logger(subsystem: "com.cmuxterm.app", category: "DeviceRegistry")`) in
DeviceRegistryClient.swift, then change the `NSLog("cmux.deviceRegistry register
failed status=%d", http.statusCode)` line to use `deviceRegistryLog.error` with
string interpolation to include `http.statusCode` (e.g. a single error log call
that includes the status code).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 926226da-b044-4ea8-9747-108ccfebbec2

📥 Commits

Reviewing files that changed from the base of the PR and between 4efa7c2 and 9a86cb4.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift
  • Sources/Cloud/DeviceRegistryClient.swift
  • cmuxTests/DeviceRegistryClientTests.swift
  • web/app/api/devices/route.ts
  • web/tests/devices-route.test.ts

Comment thread web/app/api/devices/route.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

1030-1044: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This stale-refresh guard is still racy across the awaited write.

The lifecycle check happens before try await pairedMacStore.upsert(...), so signOut(), forgetMac(...), or disconnectAndForgetActiveMac() can run while this task is suspended and the old refresh will still recreate/reactivate the row with markActive: true. This needs to be enforced inside the store mutation itself (for example, a conditional update keyed on the expected active Mac / user / generation), not as a preflight check on the caller side.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1030 - 1044, The preflight lifecycle check using
DeviceRegistryRouteSelection.shouldApplyRegistryRefresh is racy because the task
can suspend before try await pairedMacStore.upsert(...), so move the guard into
the mutation by making the store perform a conditional upsert: change or
overload pairedMacStore.upsert to accept expectedActiveMacID/expectedStackUserID
(or an expected generation token) and have the store apply the write only if
those expectations still match the current row; if the condition fails, return a
clear no-op/error so callers know the update was skipped instead of
reactivating/stale-marking the MAC as active.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Cloud/DeviceRegistryClient.swift`:
- Around line 128-135: Replace the NSLog call in the HTTP response handling
block with the project’s unified Logger: locate the response handling inside
DeviceRegistryClient (the block that checks if let http = response as?
HTTPURLResponse and updates lastRegistration = registration) and replace
NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode) with a
Logger-based call (e.g., using a shared or instance Logger) that logs the same
message and http.statusCode; ensure you import OSLog/Logging if needed and use
the existing Logger instance or add one to the DeviceRegistryClient so logging
follows the repo’s unified logging path.

---

Duplicate comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1030-1044: The preflight lifecycle check using
DeviceRegistryRouteSelection.shouldApplyRegistryRefresh is racy because the task
can suspend before try await pairedMacStore.upsert(...), so move the guard into
the mutation by making the store perform a conditional upsert: change or
overload pairedMacStore.upsert to accept expectedActiveMacID/expectedStackUserID
(or an expected generation token) and have the store apply the write only if
those expectations still match the current row; if the condition fails, return a
clear no-op/error so callers know the update was skipped instead of
reactivating/stale-marking the MAC as active.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5cb5682-2b71-409c-9889-da4dd899ed58

📥 Commits

Reviewing files that changed from the base of the PR and between 9a86cb4 and 536a857.

📒 Files selected for processing (5)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift
  • Sources/Cloud/DeviceRegistryClient.swift
  • cmuxTests/DeviceRegistryClientTests.swift

Comment on lines +128 to +135
if let http = response as? HTTPURLResponse {
if (200...299).contains(http.statusCode) {
// Only remember the scope once the server accepted it, so a
// transient failure retries on the next status tick.
lastRegistration = registration
} else {
NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace this NSLog with unified logging.

New production Swift runtime code should not introduce NSLog; route this through Logger instead so it stays on the repo’s supported logging path.

Suggested fix
+import OSLog
+
 `@MainActor`
 final class DeviceRegistryClient {
     static let shared = DeviceRegistryClient()
+    private let logger = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.mac",
+        category: "device-registry"
+    )
 
     private let session: URLSession = .shared
@@
                 if (200...299).contains(http.statusCode) {
                     // Only remember the scope once the server accepted it, so a
                     // transient failure retries on the next status tick.
                     lastRegistration = registration
                 } else {
-                    NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode)
+                    logger.error("device registry register failed status=\(http.statusCode, privacy: .public)")
                 }

Based on learnings, production Swift runtime code must not use NSLog; use Apple’s unified logging system (Logger) instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Cloud/DeviceRegistryClient.swift` around lines 128 - 135, Replace the
NSLog call in the HTTP response handling block with the project’s unified
Logger: locate the response handling inside DeviceRegistryClient (the block that
checks if let http = response as? HTTPURLResponse and updates lastRegistration =
registration) and replace NSLog("cmux.deviceRegistry register failed status=%d",
http.statusCode) with a Logger-based call (e.g., using a shared or instance
Logger) that logs the same message and http.statusCode; ensure you import
OSLog/Logging if needed and use the existing Logger instance or add one to the
DeviceRegistryClient so logging follows the repo’s unified logging path.

Source: Learnings

Addresses round-3 autoreview findings on the registry's correctness for
supported multi-team and multi-build cmux workflows:

- Device identity is now modeled per team. `devices` gains a surrogate primary
  key and a `device_uuid` column (the cmux-generated UUID, still the global
  device identity) with a unique `(team_id, device_uuid)` index, so a Mac that
  belongs to two teams registers a row in each. This unblocks the Mac's
  team-switch re-registration, which the old global device PK rejected with
  `device_team_conflict`. That guard is removed: per-team rows make cross-team
  takeover structurally impossible (team B's row can't touch team A's). GET
  returns `device_uuid` as `deviceId` so the phone's macDeviceID match is
  unchanged; instance cap and FK are unchanged (the instances FK already
  references the surrogate id). DELETE keys on `(team_id, device_uuid)`.
  Migration regenerated as a single clean migration.

- iOS route selection no longer silently substitutes across tagged app
  instances. A Mac may run stable + a debug build, each its own
  `(deviceId, tag)` instance; the phone has no tag to match in P1, so
  `routes(forMacDeviceID:)` now returns routes only when exactly one instance is
  advertising any, and otherwise returns nil to fall back to local routes rather
  than risk connecting to the wrong build's workspaces. Tag-aware matching is a
  follow-up (alongside key pinning).

Tests: web multi-team registration (same UUID in team A and B -> two rows, each
team sees only its own); iOS multiple-non-empty-instances -> nil and
single-non-empty-among-empty -> used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cab19faca4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +77 to +79
for await status in MobileHostService.shared.statusUpdates() {
if Task.isCancelled { break }
await self?.registerIfRoutesChanged(routes: status.routes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-register when the auth team changes

Because this observer only calls registerIfRoutesChanged from MobileHostService.statusUpdates(), a Mac that already has stable advertised routes will not POST them after the user signs in or switches teams unless the listener/connection state also changes. I checked that statusUpdates() is driven by mobile-host status notifications, while AuthCoordinator.selectedTeamID only persists the selection, so in that scenario the new team never gets a device row and a phone scoped to that team cannot auto-pair until the host happens to emit another status tick.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/app/api/devices/route.ts (1)

286-316: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider a SQL JOIN to fetch devices with instances in one query.

The current implementation fetches devices and instances separately then joins in memory. While this is bounded by the per-team caps (max ~5,000 rows) and functionally correct, a single SQL LEFT JOIN query would eliminate the round-trip, reduce memory allocation, and simplify the grouping logic.

♻️ Proposed SQL JOIN approach
  const db = cloudDb();

- const deviceRows = (await db
+ const joined = await db
    .select({
+     deviceId: devices.id,
+     deviceUuid: devices.deviceUuid,
+     platform: devices.platform,
+     displayName: devices.displayName,
+     deviceLabels: devices.labels,
+     deviceLastSeenAt: devices.lastSeenAt,
+     instanceTag: deviceAppInstances.tag,
+     instanceRoutes: deviceAppInstances.routes,
+     instanceLabels: deviceAppInstances.labels,
+     instanceLastSeenAt: deviceAppInstances.lastSeenAt,
-     id: devices.id,
-     deviceUuid: devices.deviceUuid,
-     platform: devices.platform,
-     displayName: devices.displayName,
-     labels: devices.labels,
-     lastSeenAt: devices.lastSeenAt,
    })
    .from(devices)
+   .leftJoin(deviceAppInstances, eq(deviceAppInstances.deviceId, devices.id))
    .where(eq(devices.teamId, team.teamId))
-   .orderBy(desc(devices.lastSeenAt))) as DeviceListRow[];
+   .orderBy(desc(devices.lastSeenAt), desc(deviceAppInstances.lastSeenAt));

- const instanceRows = await db
-   .select({
-     deviceId: deviceAppInstances.deviceId,
-     tag: deviceAppInstances.tag,
-     routes: deviceAppInstances.routes,
-     labels: deviceAppInstances.labels,
-     lastSeenAt: deviceAppInstances.lastSeenAt,
-   })
-   .from(deviceAppInstances)
-   .where(eq(deviceAppInstances.teamId, team.teamId))
-   .orderBy(desc(deviceAppInstances.lastSeenAt));
-
- const instancesByDevice = new Map<string, typeof instanceRows>();
- for (const row of instanceRows) {
-   const list = instancesByDevice.get(row.deviceId) ?? [];
-   list.push(row);
-   instancesByDevice.set(row.deviceId, list);
- }
+ // Group rows by device in a single pass
+ const deviceMap = new Map<string, { device: DeviceListRow; instances: Array<...> }>();
+ for (const row of joined) {
+   if (!deviceMap.has(row.deviceId)) {
+     deviceMap.set(row.deviceId, {
+       device: { id: row.deviceId, deviceUuid: row.deviceUuid, ... },
+       instances: [],
+     });
+   }
+   if (row.instanceTag) {
+     deviceMap.get(row.deviceId)!.instances.push({ tag: row.instanceTag, ... });
+   }
+ }

- const devicesPayload = deviceRows.map((device) => ({
+ const devicesPayload = Array.from(deviceMap.values()).map(({ device, instances }) => ({
    deviceId: device.deviceUuid,
    platform: device.platform,
    displayName: device.displayName,
-   labels: device.labels,
-   lastSeenAt: device.lastSeenAt.toISOString(),
-   instances: (instancesByDevice.get(device.id) ?? []).map((instance) => ({
-     tag: instance.tag,
-     routes: instance.routes,
-     labels: instance.labels,
-     lastSeenAt: instance.lastSeenAt.toISOString(),
-   })),
+   labels: device.deviceLabels,
+   lastSeenAt: device.deviceLastSeenAt.toISOString(),
+   instances,
  }));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/devices/route.ts` around lines 286 - 316, Fetch devices and their
instances in one DB query instead of two: replace the separate db.select(...)
calls for deviceRows and instanceRows with a single query using a LEFT JOIN
between devices and deviceAppInstances (use devices and deviceAppInstances in
the SELECT and join ON devices.deviceUuid/deviceId or
devices.id/deviceAppInstances.deviceId as appropriate) ordering by
devices.lastSeenAt or deviceAppInstances.lastSeenAt, then iterate the joined
result to build the same structure previously produced by instancesByDevice;
update the code that referenced deviceRows and instancesByDevice to consume the
combined result set (use the same field names like deviceId, deviceUuid,
platform, displayName, labels, tag, routes, lastSeenAt) so you remove the extra
in-memory grouping loop and the duplicate round-trip to db.
web/tests/devices-route.test.ts (1)

69-265: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add coverage for the per-team device cap enforcement.

The test suite exercises the per-device instance cap (lines 138-169) but not the per-team device cap. Line 172-179 of web/app/api/devices/route.ts enforces MAX_DEVICES_PER_TEAM and returns HTTP 429 when exceeded, but no test verifies this path.

🧪 Proposed test case
dbTest("rejects registration when the team device cap is exceeded", async () => {
  if (!sql) throw new Error("test database not initialized");

  // Seed the DB with 200 devices (the cap) for team-a.
  const deviceIds = Array.from({ length: 200 }, (_, i) => 
    `${(i + 1).toString().padStart(8, '0')}-0000-4000-8000-000000000000`
  );
  for (const deviceId of deviceIds) {
    await sql`
      insert into devices (team_id, device_uuid, user_id, platform) 
      values ('team-a', ${deviceId}, 'registry-user-1', 'mac')
    `;
  }

  // Attempt to register a 201st device.
  const response = await POST(
    registerRequest({
      deviceId: "99999999-9999-4999-8999-999999999999",
      platform: "mac",
      routes: [],
    })
  );

  expect(response.status).toBe(429);
  const body = await response.json();
  expect(body.error).toBe("too_many_devices");
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/tests/devices-route.test.ts` around lines 69 - 265, Add a new dbTest that
seeds the database with MAX_DEVICES_PER_TEAM devices for "team-a" and then
attempts one more registration to assert the 429 "too_many_devices" response:
use the existing helpers registerRequest and POST to perform the attempted
register, use the test sql helper to insert rows directly into the devices table
(insert into devices (team_id, device_uuid, user_id, platform) values ... ) to
create MAX_DEVICES_PER_TEAM entries, and assert response.status === 429 and
response.json().error === "too_many_devices"; reference MAX_DEVICES_PER_TEAM
(from web/app/api/devices/route.ts), and the test helpers registerRequest, POST,
and sql so the new dbTest mirrors the style of the existing tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@web/app/api/devices/route.ts`:
- Around line 286-316: Fetch devices and their instances in one DB query instead
of two: replace the separate db.select(...) calls for deviceRows and
instanceRows with a single query using a LEFT JOIN between devices and
deviceAppInstances (use devices and deviceAppInstances in the SELECT and join ON
devices.deviceUuid/deviceId or devices.id/deviceAppInstances.deviceId as
appropriate) ordering by devices.lastSeenAt or deviceAppInstances.lastSeenAt,
then iterate the joined result to build the same structure previously produced
by instancesByDevice; update the code that referenced deviceRows and
instancesByDevice to consume the combined result set (use the same field names
like deviceId, deviceUuid, platform, displayName, labels, tag, routes,
lastSeenAt) so you remove the extra in-memory grouping loop and the duplicate
round-trip to db.

In `@web/tests/devices-route.test.ts`:
- Around line 69-265: Add a new dbTest that seeds the database with
MAX_DEVICES_PER_TEAM devices for "team-a" and then attempts one more
registration to assert the 429 "too_many_devices" response: use the existing
helpers registerRequest and POST to perform the attempted register, use the test
sql helper to insert rows directly into the devices table (insert into devices
(team_id, device_uuid, user_id, platform) values ... ) to create
MAX_DEVICES_PER_TEAM entries, and assert response.status === 429 and
response.json().error === "too_many_devices"; reference MAX_DEVICES_PER_TEAM
(from web/app/api/devices/route.ts), and the test helpers registerRequest, POST,
and sql so the new dbTest mirrors the style of the existing tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5e2bd8d0-8e48-4bb9-a7e9-e7c8381439eb

📥 Commits

Reviewing files that changed from the base of the PR and between 536a857 and cab19fa.

📒 Files selected for processing (7)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift
  • web/app/api/devices/route.ts
  • web/db/migrations/20260608055850_device_registry/migration.sql
  • web/db/migrations/20260608055850_device_registry/snapshot.json
  • web/db/schema.ts
  • web/tests/devices-route.test.ts

Round-4 autoreview: GET exposes device UUIDs to every team member, and POST
upserted by UUID for the team, so a co-member could overwrite another member's
device routes (redirecting that user's phone reconnect at the attacker's host).
The POST path now rejects (403 device_not_owned) when an existing device row was
registered by a different user, so route population stays owned by the
registering user, matching the pre-registry trust boundary. Cryptographic
proof-of-possession is the deferred key-pinning phase.

Consciously accepted (not fixed) from the same review, with rationale:
- Initial-empty / post-kill off-state: lastRegistration is in-memory, so a clear
  during process death can leave the user's own last routes advertised. With the
  ownership guard those are the user's own routes, so the worst case is one
  failed connect then local fallback (no security impact). Persisting
  registration state to publish an off-state after death is follow-up.
- Team switch with unchanged routes only re-registers on the next host status
  tick (registration is route-driven). Documented as a known limitation;
  teamID stays in the dedup key so it is correct once triggered. An explicit
  auth/team-change trigger is a follow-up.

Test: a second same-team user POSTing another user's device UUID is rejected and
the routes are unchanged; the owner can still update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread web/app/api/devices/route.ts

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b61ace60e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// never another team's row for the same physical Mac.
const db = cloudDb();
await db
.delete(devices)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict device deletion to the registering user

When two users are members of the same team, any member can take a deviceId returned by GET /api/devices and delete another member's registered Mac because this deletion only filters by team and device UUID, unlike the POST path's owner check. That cascades the other user's app instances and removes their registry routes, so their phone loses the auto-pair refresh until the Mac re-registers; add the same userId ownership predicate or an explicit admin authorization check here.

Useful? React with 👍 / 👎.

Comment on lines +357 to +383
export async function DELETE(request: Request): Promise<Response> {
const user = await verifyRequest(request, {
requestedTeamId: requestedVmTeamIdFromRequest(request),
allowCookie: false,
});
if (!user) return unauthorized();

const team = resolveTeam(request, user);
if (!team.ok) return team.response;

const body = await readBoundedJson(request);
if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status);

const deviceUuid = trimmedString(body.value.deviceId).toLowerCase();
if (!UUID_RE.test(deviceUuid)) {
return jsonResponse({ error: "invalid_device_id" }, 400);
}

// Delete only this team's row for the device (the (teamId, deviceUuid) row),
// never another team's row for the same physical Mac.
const db = cloudDb();
await db
.delete(devices)
.where(and(eq(devices.deviceUuid, deviceUuid), eq(devices.teamId, team.teamId)));

return jsonResponse({ ok: true });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 DELETE skips the ownership check that POST enforces

POST explicitly rejects requests where existingDevice.userId !== user.id with a clear rationale: "GET exposes device UUIDs to every team member, so without this a co-member could POST another member's device UUID and overwrite its attach routes." The same reasoning applies to DELETE — but it's absent here.

A malicious team member can: (1) call GET /api/devices to learn the victim's deviceUuid, (2) DELETE the row, (3) immediately POST to register the same deviceUuid under their own userId. After step 3, the legitimate Mac gets a 403 device_not_owned on every subsequent registration attempt and can no longer update its own routes in the registry. The phone's refresh then consistently fetches the attacker's routes for the victim's device ID.

Add the same ownership guard used in POST: fetch the row's userId and return 403 if it doesn't match user.id.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/app/api/devices/route.ts (1)

357-380: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

DELETE bypasses the new ownership guard.

GET exposes deviceId to every team member, and this handler deletes any (teamId, deviceUuid) row without checking devices.userId. A co-member can therefore delete another user's device row and then re-register the same UUID under their own userId, which defeats the new device_not_owned protection entirely. Apply the same owner check here (or an explicit admin-only policy) before deleting.

Suggested fix
 export async function DELETE(request: Request): Promise<Response> {
   const user = await verifyRequest(request, {
     requestedTeamId: requestedVmTeamIdFromRequest(request),
     allowCookie: false,
   });
   if (!user) return unauthorized();
@@
   const deviceUuid = trimmedString(body.value.deviceId).toLowerCase();
   if (!UUID_RE.test(deviceUuid)) {
     return jsonResponse({ error: "invalid_device_id" }, 400);
   }
 
-  // Delete only this team's row for the device (the (teamId, deviceUuid) row),
-  // never another team's row for the same physical Mac.
   const db = cloudDb();
+  const [existingDevice] = await db
+    .select({ userId: devices.userId })
+    .from(devices)
+    .where(and(eq(devices.deviceUuid, deviceUuid), eq(devices.teamId, team.teamId)))
+    .limit(1);
+
+  if (existingDevice && existingDevice.userId !== user.id) {
+    return jsonResponse({ error: "device_not_owned" }, 403);
+  }
+
   await db
     .delete(devices)
     .where(and(eq(devices.deviceUuid, deviceUuid), eq(devices.teamId, team.teamId)));
 
   return jsonResponse({ ok: true });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/api/devices/route.ts` around lines 357 - 380, The DELETE handler
currently deletes the (teamId, deviceUuid) row without checking ownership;
update the DELETE function to enforce the same ownership guard as GET by first
checking devices.userId against the authenticated user (from verifyRequest) or
an explicit admin allowlist before proceeding: query the devices table for a row
matching devices.deviceUuid and devices.teamId, verify devices.userId ===
user.userId (or that user has admin rights), return a 403/json error if not
authorized, and only then perform the delete (or alternatively include
eq(devices.userId, user.userId) in the delete WHERE clause) so deletion cannot
be performed by co-members.
Sources/Cloud/DeviceRegistryClient.swift (1)

74-84: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Register on team changes, not just host-status ticks.

A mid-session team switch with unchanged routes will not POST into the new team until MobileHostService emits another status update. That leaves this Mac registered only under the old team, so the new team cannot rediscover it until some unrelated route/connection event happens. Wire the same registration path to auth/team-selection changes and push the current statusSnapshot().routes immediately when the team changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Cloud/DeviceRegistryClient.swift` around lines 74 - 84, The current
startObserving() only listens to MobileHostService.shared.statusUpdates() so a
mid-session team change with unchanged routes never triggers
registerIfRoutesChanged(routes:), leaving the device registered to the old team;
subscribe to the auth/team-selection change events in the same Task (or a
sibling Task on `@MainActor`) and, when a team-change is observed, immediately
call registerIfRoutesChanged(routes:
MobileHostService.shared.statusSnapshot().routes) (respecting Task cancellation
and using await on self?) so the registration path is invoked on team switches
as well as route updates; ensure the new listener is cancelled along with
observeTask and uses the same weak self pattern to avoid retain cycles.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Sources/Cloud/DeviceRegistryClient.swift`:
- Around line 74-84: The current startObserving() only listens to
MobileHostService.shared.statusUpdates() so a mid-session team change with
unchanged routes never triggers registerIfRoutesChanged(routes:), leaving the
device registered to the old team; subscribe to the auth/team-selection change
events in the same Task (or a sibling Task on `@MainActor`) and, when a
team-change is observed, immediately call registerIfRoutesChanged(routes:
MobileHostService.shared.statusSnapshot().routes) (respecting Task cancellation
and using await on self?) so the registration path is invoked on team switches
as well as route updates; ensure the new listener is cancelled along with
observeTask and uses the same weak self pattern to avoid retain cycles.

In `@web/app/api/devices/route.ts`:
- Around line 357-380: The DELETE handler currently deletes the (teamId,
deviceUuid) row without checking ownership; update the DELETE function to
enforce the same ownership guard as GET by first checking devices.userId against
the authenticated user (from verifyRequest) or an explicit admin allowlist
before proceeding: query the devices table for a row matching devices.deviceUuid
and devices.teamId, verify devices.userId === user.userId (or that user has
admin rights), return a 403/json error if not authorized, and only then perform
the delete (or alternatively include eq(devices.userId, user.userId) in the
delete WHERE clause) so deletion cannot be performed by co-members.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 79c9e198-ff58-4f67-8c6d-8f98c52d2353

📥 Commits

Reviewing files that changed from the base of the PR and between cab19fa and b61ace6.

📒 Files selected for processing (3)
  • Sources/Cloud/DeviceRegistryClient.swift
  • web/app/api/devices/route.ts
  • web/tests/devices-route.test.ts

Round-5 autoreview:

- DeviceRegistryClient.shouldReRegister is pure but inherited @mainactor from the
  enclosing class, so the non-@mainactor cmuxTests suite calling it synchronously
  would fail to compile the test bundle (only CI compiles it). Marked the policy
  method `nonisolated`.

- DELETE only scoped by (team, deviceUuid), so a co-member who learns a device
  UUID via GET could delete another user's Mac and break their reconnect. Now
  also scopes by userId, mirroring the POST ownership guard. The delete stays an
  idempotent no-op (200) when the row is not the caller's.

Test: a second same-team user's DELETE of another user's device is a no-op and
the row survives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nventions)

package-conventions-lint flags standalone caseless namespace enums. Folded the
two standalone enums into DeviceRegistryService as static members:
- MobileDeviceIdentity.deviceID(defaults:) -> DeviceRegistryService.deviceID(defaults:)
- DeviceRegistryRouteSelection.{selectReconnectRoutes,shouldApplyRegistryRefresh}
  -> static methods on DeviceRegistryService

DeviceRegistryRefreshing keeps only the protocol. Call sites in
MobileShellComposite, CMUXMobileRootScene, and the tests updated. Behavior and
test coverage unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ration

DeviceRegistryClient read auth.resolvedTeamID before awaiting currentTokens().
resolvedTeamID derives from availableTeams, which is empty until launch auth
bootstrap completes, so on launch it could resolve nil even with a persisted
selected team and publish the Mac into the Stack-default team. The team is now
resolved after the currentTokens() await (which gates on signed-in and waits for
bootstrap) and used for both the dedup key and the X-Cmux-Team-Id header. After
bootstrap currentTokens() returns the cached token, so awaiting it per status
tick is cheap.

The residual team-handling limitation (a mid-session team switch with unchanged
routes only re-registers on the next host-status tick, since registration is
route-driven) is documented in startObserving; an explicit auth/team-change
trigger is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 17562bc. Configure here.

) -> [CmxAttachRoute]? {
guard let registry, !registry.isEmpty else { return nil }
guard registry != local else { return nil }
return registry

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale registry overwrites local routes

Medium Severity

When a device row belongs to another team member, the Mac cannot POST updated routes, but GET still returns that row to everyone on the team. selectReconnectRoutes treats any differing registry routes as fresher and the shell persists them, so phones can replace good locally paired routes with outdated registry endpoints after an account switch or stale registration.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 17562bc. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17562bc629

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +67 to +71
// Treat "never registered" as an empty-routes baseline in the same scope
// so an initial empty set (pairing off at launch) is a no-op, but a later
// clear, or any team/tag change, still fires.
let baseline = previous ?? Registration(teamID: current.teamID, tag: current.tag, routes: [])
return baseline != current

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Publish the cold-start off state

When mobile pairing is disabled on startup, treating previous == nil as already-empty skips the POST that would clear any registry row left from a prior successful registration. If the user turns pairing off while the registry POST is unavailable, or the app exits before the empty-route transition is accepted, the next launch with empty routes will hit this no-op path and leave the old non-empty routes in /api/devices, so phones keep discovering stale routes until pairing is enabled again. Persist the last advertised state or send one empty-route update when the server state is unknown.

Useful? React with 👍 / 👎.

@lawrencecchen
lawrencecchen merged commit ee22255 into main Jun 8, 2026
44 of 50 checks passed
@lawrencecchen
lawrencecchen deleted the feat-ios-device-registry-p1 branch June 8, 2026 09:23
lawrencecchen added a commit that referenced this pull request Jun 10, 2026
…evice registry (#5648)

* iOS: hierarchical device tree (device → tags → workspaces) over the device registry

Render the merged #5626 device registry as a hierarchical tree: each registered
device (Mac/host) expands to its cmux app instances (tags), and a tag expands to
that build's workspaces; tapping a workspace opens it via the existing path.

Surfaces the registry list to the UI (DeviceRegistryRefreshing.listDevices),
adds a RegistryDevice/RegistryAppInstance value model, store.registryDevices +
loadRegistryDevices + connectToRegistryInstance (connect-on-tap a non-connected
tag via its routes), and a DeviceTreeView reachable from Settings. Keeps the
flat workspace list and the multi-Mac switcher as the fallback paths.

Online state: the connected device shows live macConnectionStatus; others show
registry last-seen (best-effort, no per-host ping yet; the attach ticket carries
no tag, so per-tag liveness is a TODO). Expansion persists via @AppStorage.
Localized en+ja. Pure decode + expansion-codec tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: fix account-switch race + multi-tag wrong-tag workspaces (autoreview P1s)

P1-1: loadRegistryDevices now captures the requesting user id and discards a
result that lands after a sign-out + different-user sign-in, so a slow registry
load can't leak a previous user's team devices into the new user's tree (mirrors
loadPairedMacs's user guard).

P1-2: attribute live workspaces to the ONE instance whose route matches the live
connection (instanceMatchesActiveRoute), not every tag on the connected device.
A multi-tag Mac now shows workspaces only under the connected build; the other
tags offer Connect instead of mirroring the wrong build's workspaces, so a
workspace can no longer be opened under the wrong tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: roll back failed registry connect + paired-Mac fallback (autoreview)

P1: connectToRegistryInstance now captures the previously-active Mac and, when
the destructive connect fails to land on the target route, reconnects it (mirrors
switchToMac). Tapping a stale/offline registry tag no longer drops a healthy live
session; the user is left where they were.

P2: add store.deviceTreeDevices, which honors the documented best-effort fallback:
the registry list when loaded, otherwise the locally paired Macs synthesized into
the same device→instance shape. The tree now sources from it and loads paired
Macs first, so the Devices sheet stays usable (and connectable) during a registry
outage instead of showing the empty state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: roll back to the active Mac even on a same-device tag switch (autoreview P1)

The previous rollback excluded the tapped device id (copied from switchToMac),
which is wrong here: a Mac runs multiple tagged builds, so tapping another tag on
the currently-connected device must still be able to reconnect that device's
active route when the new tag is stale/offline. Capture the active paired Mac
regardless of device id so a same-device tag-switch failure restores the live
session instead of stranding the user disconnected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: clear team-scoped registry data on auth rejection (autoreview P1)

listDevices() now returns a 3-way outcome (ok / authRejected / transientFailure)
instead of an optional, so the store can distinguish a transient blip (keep the
tree) from a 401/403 auth/scope rejection (clear it). The registry is team-scoped,
so a token/scope change must not leave a previous scope's team-device names/tags/
routes visible; on authRejected the store clears registryDevices and the tree
falls back to local paired Macs. Transient failures (5xx, network, malformed
body) keep the current tree to avoid blip-blanking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: present from workspace list (single sheet) + recognize manual-ticket active device (autoreview P2)

P2 (sheet stack): move the device tree to a top-level sheet on the workspace list
(a Devices toolbar button) instead of nesting it under the Settings sheet, so
selecting a workspace dismisses straight back to the workspace shell and reveals
the opened workspace rather than leaving Settings covering it. Removes the
duplicate Settings 'Devices' entry.

P2 (manual-ticket active): connectedMacDeviceID now falls back to the active
paired Mac's real device id when the live ticket is a synthetic manual one (host
without mobile.attach_ticket.create). The registry connect path persists the real
device as active, so the tree now marks it connected and shows its live workspaces
instead of hiding them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: refresh MobileShellComposite file length budget for device-tree growth

* Guard authRejected registry blanking on the requesting user still being current

Mirrors the .ok path's account-switch guard: a stale 401 from a signed-out
session that lands after a different user signed in no longer blanks the
new user's device tree. Addresses the Greptile P1 on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: refresh file-length budget for the authRejected guard growth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 10, 2026
* iOS: hierarchical device tree (device → tags → workspaces) over the device registry

Render the merged #5626 device registry as a hierarchical tree: each registered
device (Mac/host) expands to its cmux app instances (tags), and a tag expands to
that build's workspaces; tapping a workspace opens it via the existing path.

Surfaces the registry list to the UI (DeviceRegistryRefreshing.listDevices),
adds a RegistryDevice/RegistryAppInstance value model, store.registryDevices +
loadRegistryDevices + connectToRegistryInstance (connect-on-tap a non-connected
tag via its routes), and a DeviceTreeView reachable from Settings. Keeps the
flat workspace list and the multi-Mac switcher as the fallback paths.

Online state: the connected device shows live macConnectionStatus; others show
registry last-seen (best-effort, no per-host ping yet; the attach ticket carries
no tag, so per-tag liveness is a TODO). Expansion persists via @AppStorage.
Localized en+ja. Pure decode + expansion-codec tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: fix account-switch race + multi-tag wrong-tag workspaces (autoreview P1s)

P1-1: loadRegistryDevices now captures the requesting user id and discards a
result that lands after a sign-out + different-user sign-in, so a slow registry
load can't leak a previous user's team devices into the new user's tree (mirrors
loadPairedMacs's user guard).

P1-2: attribute live workspaces to the ONE instance whose route matches the live
connection (instanceMatchesActiveRoute), not every tag on the connected device.
A multi-tag Mac now shows workspaces only under the connected build; the other
tags offer Connect instead of mirroring the wrong build's workspaces, so a
workspace can no longer be opened under the wrong tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: roll back failed registry connect + paired-Mac fallback (autoreview)

P1: connectToRegistryInstance now captures the previously-active Mac and, when
the destructive connect fails to land on the target route, reconnects it (mirrors
switchToMac). Tapping a stale/offline registry tag no longer drops a healthy live
session; the user is left where they were.

P2: add store.deviceTreeDevices, which honors the documented best-effort fallback:
the registry list when loaded, otherwise the locally paired Macs synthesized into
the same device→instance shape. The tree now sources from it and loads paired
Macs first, so the Devices sheet stays usable (and connectable) during a registry
outage instead of showing the empty state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: roll back to the active Mac even on a same-device tag switch (autoreview P1)

The previous rollback excluded the tapped device id (copied from switchToMac),
which is wrong here: a Mac runs multiple tagged builds, so tapping another tag on
the currently-connected device must still be able to reconnect that device's
active route when the new tag is stale/offline. Capture the active paired Mac
regardless of device id so a same-device tag-switch failure restores the live
session instead of stranding the user disconnected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: clear team-scoped registry data on auth rejection (autoreview P1)

listDevices() now returns a 3-way outcome (ok / authRejected / transientFailure)
instead of an optional, so the store can distinguish a transient blip (keep the
tree) from a 401/403 auth/scope rejection (clear it). The registry is team-scoped,
so a token/scope change must not leave a previous scope's team-device names/tags/
routes visible; on authRejected the store clears registryDevices and the tree
falls back to local paired Macs. Transient failures (5xx, network, malformed
body) keep the current tree to avoid blip-blanking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: present from workspace list (single sheet) + recognize manual-ticket active device (autoreview P2)

P2 (sheet stack): move the device tree to a top-level sheet on the workspace list
(a Devices toolbar button) instead of nesting it under the Settings sheet, so
selecting a workspace dismisses straight back to the workspace shell and reveals
the opened workspace rather than leaving Settings covering it. Removes the
duplicate Settings 'Devices' entry.

P2 (manual-ticket active): connectedMacDeviceID now falls back to the active
paired Mac's real device id when the live ticket is a synthetic manual one (host
without mobile.attach_ticket.create). The registry connect path persists the real
device as active, so the tree now marks it connected and shows its live workspaces
instead of hiding them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* iOS: mobile browser panes P1 (WKWebView surface)

Add a phone-local WKWebView browser pane as a sibling of the terminal
surface in the iOS companion app. New `CmuxMobileBrowser` package owns
the browser surface state, URL resolver, store, and the WKWebView host;
`CmuxMobileShellUI` presents it from the same workspace toolbar menu that
creates terminals ("New Browser"), and a close action returns to the
terminal.

Browser state lives in a dedicated workspace-keyed `BrowserSurfaceStore`
injected from the app root, not in `MobileShellComposite`, because a
browser has no Mac-side counterpart and must survive workspace.updated
re-syncs (unlike terminals).

P1 ships: address bar, navigate, back/forward/reload/stop, page title,
determinate loading progress, default persistent WKWebsiteDataStore.
P2 (cookie/localStorage sync with the Mac) and P3 (passkeys) are
deferred; see plans/feat-ios-mobile-browser/DESIGN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Make BrowserURLResolver an uninstantiable struct per package conventions

* chore: refresh MobileShellComposite file length budget for device-tree growth

* Guard authRejected registry blanking on the requesting user still being current

Mirrors the .ok path's account-switch guard: a stale 401 from a signed-out
session that lands after a different user signed in no longer blanks the
new user's device tree. Addresses the Greptile P1 on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: refresh file-length budget for the authRejected guard growth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 10, 2026
…flow.ai on tailnet, else email) (#5653)

* iOS: hierarchical device tree (device → tags → workspaces) over the device registry

Render the merged #5626 device registry as a hierarchical tree: each registered
device (Mac/host) expands to its cmux app instances (tags), and a tag expands to
that build's workspaces; tapping a workspace opens it via the existing path.

Surfaces the registry list to the UI (DeviceRegistryRefreshing.listDevices),
adds a RegistryDevice/RegistryAppInstance value model, store.registryDevices +
loadRegistryDevices + connectToRegistryInstance (connect-on-tap a non-connected
tag via its routes), and a DeviceTreeView reachable from Settings. Keeps the
flat workspace list and the multi-Mac switcher as the fallback paths.

Online state: the connected device shows live macConnectionStatus; others show
registry last-seen (best-effort, no per-host ping yet; the attach ticket carries
no tag, so per-tag liveness is a TODO). Expansion persists via @AppStorage.
Localized en+ja. Pure decode + expansion-codec tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: fix account-switch race + multi-tag wrong-tag workspaces (autoreview P1s)

P1-1: loadRegistryDevices now captures the requesting user id and discards a
result that lands after a sign-out + different-user sign-in, so a slow registry
load can't leak a previous user's team devices into the new user's tree (mirrors
loadPairedMacs's user guard).

P1-2: attribute live workspaces to the ONE instance whose route matches the live
connection (instanceMatchesActiveRoute), not every tag on the connected device.
A multi-tag Mac now shows workspaces only under the connected build; the other
tags offer Connect instead of mirroring the wrong build's workspaces, so a
workspace can no longer be opened under the wrong tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: roll back failed registry connect + paired-Mac fallback (autoreview)

P1: connectToRegistryInstance now captures the previously-active Mac and, when
the destructive connect fails to land on the target route, reconnects it (mirrors
switchToMac). Tapping a stale/offline registry tag no longer drops a healthy live
session; the user is left where they were.

P2: add store.deviceTreeDevices, which honors the documented best-effort fallback:
the registry list when loaded, otherwise the locally paired Macs synthesized into
the same device→instance shape. The tree now sources from it and loads paired
Macs first, so the Devices sheet stays usable (and connectable) during a registry
outage instead of showing the empty state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: roll back to the active Mac even on a same-device tag switch (autoreview P1)

The previous rollback excluded the tapped device id (copied from switchToMac),
which is wrong here: a Mac runs multiple tagged builds, so tapping another tag on
the currently-connected device must still be able to reconnect that device's
active route when the new tag is stale/offline. Capture the active paired Mac
regardless of device id so a same-device tag-switch failure restores the live
session instead of stranding the user disconnected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: clear team-scoped registry data on auth rejection (autoreview P1)

listDevices() now returns a 3-way outcome (ok / authRejected / transientFailure)
instead of an optional, so the store can distinguish a transient blip (keep the
tree) from a 401/403 auth/scope rejection (clear it). The registry is team-scoped,
so a token/scope change must not leave a previous scope's team-device names/tags/
routes visible; on authRejected the store clears registryDevices and the tree
falls back to local paired Macs. Transient failures (5xx, network, malformed
body) keep the current tree to avoid blip-blanking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Device tree: present from workspace list (single sheet) + recognize manual-ticket active device (autoreview P2)

P2 (sheet stack): move the device tree to a top-level sheet on the workspace list
(a Devices toolbar button) instead of nesting it under the Settings sheet, so
selecting a workspace dismisses straight back to the workspace shell and reveals
the opened workspace rather than leaving Settings covering it. Removes the
duplicate Settings 'Devices' entry.

P2 (manual-ticket active): connectedMacDeviceID now falls back to the active
paired Mac's real device id when the live ticket is a synthetic manual one (host
without mobile.attach_ticket.create). The registry connect path persists the real
device as active, so the tree now marks it connected and shows its live workspaces
instead of hiding them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* iOS: Release-safe Send Feedback (privileged direct-to-agent for @manaflow.ai on tailnet, else email)

Adds a Release-safe "Send Feedback" affordance to the iOS terminal menu that
routes two ways and always stamps build type + version + device:

- Privileged direct-to-agent: when the signed-in email ends with @manaflow.ai
  AND there is an active mobile-host connection to a paired Mac (the on-tailnet
  proxy, since that transport runs over Tailscale). Reuses the existing
  dogfood.feedback.submit RPC + bundle so the Mac watcher under
  ~/.cache/cmux-dogfood-feedback/ still catches it. The phone delivery, the Mac
  sink, and the dogfood.v1 capability are un-#if-DEBUG-gated together so the
  path works on Release (beta/prod) for the team; the sink keeps its
  same-account Stack-auth gate.
- Email otherwise: POSTs to the existing web /api/feedback route (Resend),
  prefilled + editable reply-to. The route now takes a buildType field and
  stamps it into the subject.

Build type is derived once from #if DEBUG (dev) + bundle id (dev.cmux.app.beta
=> beta, else prod). Pure routing + stamp formatting live in CmuxMobileShellModel
with Swift Testing coverage; the email multipart builder is unit tested too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Gate privileged feedback route on the Mac's dogfood.v1 capability

Autoreview P2: a newer phone connected to an older Mac that does not expose
dogfood.feedback.submit would still take the agent route and fail with
method_not_found instead of emailing. Record supportsDogfoodFeedback from the
host status capabilities (mirroring supportsWorkspaceActions) and require it in
the pure routing decision, so version skew falls back to the email inbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* iOS: mobile browser panes P1 (WKWebView surface)

Add a phone-local WKWebView browser pane as a sibling of the terminal
surface in the iOS companion app. New `CmuxMobileBrowser` package owns
the browser surface state, URL resolver, store, and the WKWebView host;
`CmuxMobileShellUI` presents it from the same workspace toolbar menu that
creates terminals ("New Browser"), and a close action returns to the
terminal.

Browser state lives in a dedicated workspace-keyed `BrowserSurfaceStore`
injected from the app root, not in `MobileShellComposite`, because a
browser has no Mac-side counterpart and must survive workspace.updated
re-syncs (unlike terminals).

P1 ships: address bar, navigate, back/forward/reload/stop, page title,
determinate loading progress, default persistent WKWebsiteDataStore.
P2 (cookie/localStorage sync with the Mac) and P3 (passkeys) are
deferred; see plans/feat-ios-mobile-browser/DESIGN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Enforce feedback privilege at the Mac trust boundary; fix Release agent copy

Autoreview P1: the un-gated dogfood.feedback.submit sink accepted any
same-account caller, with the only @manaflow.ai check on the phone. A crafted
RPC could bypass the privileged gate. Now the Mac handler rejects with
"unauthorized" unless this Mac's authenticated Stack email is @manaflow.ai
(same-account means the caller is the Mac's own account). Adds
currentAuthenticatedLocalUserEmail() and a local privileged-domain check mirroring
isManaflowEmail; the phone keeps its route gate as defense-in-depth.

Autoreview P2: the Release store has no DiagnosticLog (it is DEBUG-only in the
app composition), so the structured event blob is empty in Release while the UI
promised "events". The agent-path copy no longer promises the event trace; the
bundle still carries the note, visible terminal, mobile debug log, and stamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Autoreview style: split MobileFeedbackEmailError to its own file; DocC on client submit

Addresses Aziz file-organization + documentation findings: one major type per
file, and DocC on the concrete email-client submit. The nested
FeedbackSubmissionOutcome and the testable static multipartBody are intentional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fall back to email when the privileged agent feedback path fails

Defense-in-depth: if the agent sink rejects or the RPC can't be delivered, route
the report to the email inbox instead of dead-ending with .failed. The composer
prefills the signed-in email, so a @manaflow.ai user always has a valid reply-to
for the fallback. Extracts a shared submitFeedbackEmail helper so the direct
email route and the fallback deliver identically.

Confirmed CMUXAuthIdentityStore JSON round-trips the full CMUXAuthUser (incl.
primaryEmail), so the Mac privilege gate reads a populated email on the restored
path too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Make BrowserURLResolver an uninstantiable struct per package conventions

* Scope feedback routing and stamp factory onto their owning types per package conventions

* chore: refresh file length budgets for send-feedback growth

* chore: refresh MobileShellComposite file length budget for device-tree growth

* Guard authRejected registry blanking on the requesting user still being current

Mirrors the .ok path's account-switch guard: a stale 401 from a signed-out
session that lands after a different user signed in no longer blanks the
new user's device tree. Addresses the Greptile P1 on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reset isSubmittingFeedback when reopening the feedback composer

Dismissing the sheet mid-send left the flag stuck true, so a reopened
composer rendered Send disabled until the prior task timed out (up to
30s on the email path). Addresses the Greptile P1 on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: refresh file-length budget for the authRejected guard growth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 17562bc6 Deployed Jun 8, 2026 by vercel[bot]
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