Skip to content

Subrouter cloud API under cmux.com/api/v1/sr - #9119

Closed
lawrencecchen wants to merge 2 commits into
mainfrom
feat-sr-cloud-api
Closed

lawrencecchen wants to merge 2 commits into
mainfrom
feat-sr-cloud-api

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Why

Subrouter's accounts live on one Mac mini. Today that host exhausted its kernel mbuf pool (5462/5462 16KB clusters, 14134192 requests for memory denied), stopped accepting TCP entirely, and took every client's routing down with it; recovery needed a KVM console. Getting credential custody off a single machine is the durable fix. This is the server half.

Shape

Four oRPC procedures on the existing router, so they surface at /api/v1/sr/* via the OpenAPIHandler already mounted there:

POST /sr/device/start begin device-code login
POST /sr/device/poll poll for approval
POST /sr/accounts/push bulk upload credentials
GET /sr/accounts list team accounts (identities only)

No hand-maintained spec: Zod schemas generate the OpenAPI document, and both checked-in specs are regenerated so account-me-orpc.test.ts's byte-identical assertion passes.

Security decisions

Team-scoped custody. Stack team membership is the authorization boundary, so uploading to a team is the same act as sharing with teammates. No second ACL to drift out of sync.

Sealed at rest. AES-256-GCM with the key in SR_VAULT_KEY, never the database, so key and ciphertext don't share a compromise boundary. A missing or wrong-length key throws rather than silently storing plaintext. Tampering fails on open via the GCM auth tag.

Device-code over localhost redirect, because the CLI routinely runs over SSH and in containers where nothing can reach 127.0.0.1. Only a digest of the device code is stored, approval is single-use, and an unknown code returns the same response as an expired one so polling can't probe for valid codes. User codes omit 0/O and 1/I/L since a human reads them aloud.

List never returns credential material — asserted by a test that greps the generated response schema.

Verified

Not just typechecked. Against a live dev server on a real Postgres:

  • migrations applied cleanly on boot, including this one
  • /api/openapi.json serves all four routes
  • GET /api/v1/sr/accounts unauthenticated → 401 UNAUTHORIZED
  • POST /sr/device/start → {"userCode":"6KAF-2V7J","deviceCode":"7LbV_...","expiresInSeconds":900}
  • poll with the issued code → {"status":"pending"}; poll with a bogus code → {"status":"expired"}

bun run typecheck clean. bun test 17 pass / 0 fail across the new and existing oRPC suites.

Not in this PR

The Go client in manaflow-ai/subrouter (hand-written on net/http, no OpenAPI codegen, so no binary bloat), the cmux.com/sr/device browser approval page that calls approveDeviceCode, and the sr login / sr push verbs. The approval helper is exported and ready for that page.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Retires the superseded Subrouter vault proposal. The hosted Subrouter and Stack authentication flows have shipped, CodeRouter now stores credentials with KMS encryption, and private/team account permissions and VM pools are enforced, so this PR keeps those implementations instead of adding an unused SR_VAULT_KEY credential store and incomplete device-code flow. The resulting tree is identical to main, so no runtime behavior changes ship.

Written for commit 9268482. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added device-code authentication with login initiation, approval polling, and expiration handling.
    • Added secure account credential uploads with support for multiple providers and upload/update counts.
    • Added account listing that displays provider, label, and last-updated information without exposing credentials.
    • Added encrypted credential storage and validation for supported account types.
  • Security

    • Credentials are encrypted at rest, and device codes are protected against replay.
  • Tests

    • Added coverage for encryption, device-code flows, validation, and API response privacy.

Subrouter's accounts live on one Mac mini. When that host exhausted its kernel
mbuf pool today, every client lost routing at once and recovery needed a KVM
console. Moving custody off a single machine is the fix; this is the server
half.

Routes are oRPC procedures on the existing router, so they surface at
/api/v1/sr/* through the OpenAPIHandler already mounted there and appear in
the generated OpenAPI document with no separate spec to maintain. Both
checked-in specs are regenerated accordingly.

Credentials are team-scoped: Stack team membership is the authorization
boundary, so uploading an account to a team is the same act as sharing it with
teammates, and there is no second ACL to drift. Payloads are sealed with
AES-256-GCM whose key lives in SR_VAULT_KEY rather than the database, so a
dump alone yields no usable OAuth refresh chain, and a missing or wrong-length
key fails loudly instead of silently storing plaintext.

Login is a device-code flow rather than a localhost redirect, because the CLI
routinely runs over SSH and in containers where nothing can reach 127.0.0.1.
Only a digest of the CLI-held device code is stored, approval is consumed
single-use, and an unknown code is reported identically to an expired one so
polling cannot probe for valid codes. User codes omit 0/O and 1/I/L since a
human reads them aloud.

Push takes a batch so the mini's seven accounts migrate in one request, and
re-uploading an existing account refreshes it in place rather than duplicating.
The list endpoint returns identities only and never credential material.
@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds the Subrouter cloud API with encrypted team credential storage, device-code authentication, account upload/list procedures, database schema, OpenAPI definitions, route registration, and tests.

Changes

Subrouter vault and authentication

Layer / File(s) Summary
Vault storage and encryption foundation
web/db/migrations/..., web/db/schema.ts, web/services/subrouter/vaultCrypto.ts, web/tests/sr-vault.test.ts
Adds SR vault and device-code tables, encrypted credential handling, hashed device codes, random code generation, and cryptographic tests.
Device-code authentication flow
web/orpc/server/sr/schemas.ts, web/orpc/server/sr/device.ts, web/openapi/openapi.json, Packages/Shared/CmuxAPIClient/Sources/CmuxAPIClient/openapi.json, web/tests/sr-vault.test.ts
Adds device start, poll, and approval flows with pending, approved, and expired states plus matching API contracts and tests.
Team account vault routes
web/orpc/server/sr/schemas.ts, web/orpc/server/sr/accounts.ts, web/orpc/server/router.ts, web/openapi/openapi.json, Packages/Shared/CmuxAPIClient/Sources/CmuxAPIClient/openapi.json, web/tests/sr-vault.test.ts
Adds authenticated credential upload and identity-only listing procedures, registers SR routes, and documents their request and response schemas.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant SR API
  participant Device Code Store
  participant Browser

  CLI->>SR API: Start device login
  SR API->>Device Code Store: Store hashed device code
  SR API-->>CLI: Return user code and device code
  Browser->>SR API: Approve user code
  SR API->>Device Code Store: Save approval metadata
  CLI->>SR API: Poll device code
  SR API->>Device Code Store: Read approval state
  SR API-->>CLI: Return login status
Loading

Important

Pre-merge checks failed

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

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Cmux Full Internationalization ❌ Error New SR route summaries/descriptions are hardcoded English in openapi.json and oRPC route metadata; no matching next-intl message updates for the 20 locales. Move user-facing route copy to locale-specific messages and update every web/messages/*.json locale (or remove exposed prose) before regenerating OpenAPI.
✅ Passed checks (24 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main addition: the Subrouter cloud API under /api/v1/sr.
Description check ✅ Passed The description is detailed and covers motivation, API shape, security, verification, and out-of-scope items, though some template headings are omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 Swift Actor Isolation ✅ Passed No Swift files changed in this PR; the diff is backend/OpenAPI/schema only, so no actor-isolation regressions to assess.
Cmux Swift Blocking Runtime ✅ Passed No Swift files changed in this PR, so the Swift blocking-runtime rule is not applicable.
Cmux Browser Automation Off-Main ✅ Passed PR only changes Subrouter API/db/OpenAPI files; no browser.* socket commands or browser-automation policy tests were touched.
Cmux Expensive Synchronous Load ✅ Passed PASS: The diff touches only web/db/OpenAPI/TypeScript files; no Swift code or interactive agent-history loads were added or moved.
Cmux Cache Substitution Correctness ✅ Passed PASS: the new SR persistence paths use direct Postgres reads/writes; I found no cache-backed substitution or unhandled cold/stale cache in the diff.
Cmux No Hacky Sleeps ✅ Passed PASS: The SR device flow exposes a poll interval but adds no setTimeout/sleep/backoff loops or wall-clock waits in runtime code; tests are deterministic.
Cmux Algorithmic Complexity ✅ Passed PASS: new batch/account and device flows use indexed DB lookups and tiny fixed-size loops; no nested full scans or repeated rescans in hot paths.
Cmux Swift Concurrency ✅ Passed PR diff changes only web/SQL/OpenAPI files; no Swift code is introduced or expanded.
Cmux Swift @Concurrent ✅ Passed PR changes touch JSON, SQL, TS, and tests only; no Swift files or Swift review-rule target are present.
Cmux Swift Package Boundaries ✅ Passed No Swift files changed in the PR; only web/TS/JSON/SQL/test files were modified, so the Swift package-boundary rule isn’t implicated.
Cmux Swiftpm Lockfiles ✅ Passed Diff only touches OpenAPI/DB/web code; no Package.swift, Package.resolved, .gitignore, workflow, or Xcode project changes appear.
Cmux Swift Logging ✅ Passed No Swift sources were changed in this commit; the touched files are JSON/TS/SQL/tests, and the existing Swift client files contain no logging calls.
Cmux User-Facing Error Privacy ✅ Passed New API errors are generic; no exposed message includes secrets, env vars, DB details, or raw upstream text. The SR_VAULT_KEY string stays in an internal helper.
Cmux Swiftui State Layout ✅ Passed No SwiftUI/AppKit files or patterns were touched; the diff only changes backend/OpenAPI/migration code, so the SwiftUI layout rule doesn’t apply.
Cmux Architecture Rethink ✅ Passed No Swift files or Swift architectural patterns are in the diff; the referenced rule applies only to Swift changes.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed HEAD diff touches only API/DB/TS/test files; no Swift window code changed, so the auxiliary-window close-shortcut rule doesn’t apply.
Cmux Source Artifacts ✅ Passed All changed paths are intentional source/docs/tests/migration files; the checked-in OpenAPI specs are repo source-of-truth, not scratch or build artifacts.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No changed Swift files under a production Sources path; the patch only touches web/JSON/schema/SQL files.
Cmux No Ambient Global State ✅ Passed No Swift files were changed; the rule applies to production Swift code, so this PR adds no Swift ambient global state.
✨ 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-sr-cloud-api

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.

@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 `@web/orpc/server/sr/accounts.ts`:
- Around line 58-96: The account persistence logic in the sr_accounts.push flow
should replace the SELECT-then-INSERT/UPDATE sequence with a single atomic
insert using onConflictDoUpdate() targeting the composite teamId, provider, and
accountLabel uniqueness constraint. Preserve the existing sealed credential
fields and updatedAt on conflicts, retain createdByUserId for new rows, and
update the uploaded/updated counters according to whether the operation inserts
or conflicts.
- Around line 18-26: Update the FORBIDDEN error message in requireTeam to remove
the “Stack” vendor name and use product-neutral wording while preserving the
existing guidance to create or select a team.

In `@web/orpc/server/sr/device.ts`:
- Around line 27-62: Add a per-IP or equivalent client-based rate/cap guard at
the start of srDeviceStartProcedure before the cleanup or insert database
writes, limiting unauthenticated device-code creation over the device-code TTL
window. Preserve successful issuance for clients under the cap, and only fail
open when the client identity cannot be determined or the guard is unavailable.
- Around line 80-110: Make the approved-code claim atomic in the handler by
replacing the separate approved-row read and delete with a delete-and-return
operation constrained to the matching device code hash, unexpired row, and
non-null approvedAt/teamId; import and use and and isNotNull as needed. Return
the approved response only when the atomic delete returns a row, while
preserving expired and pending responses for rows that cannot be claimed.

In `@web/orpc/server/sr/schemas.ts`:
- Around line 54-56: Update srDevicePollInputSchema to add an explicit maximum
length to deviceCode, using a generous bound above the approximately
43-character values generated by generateDeviceCode while preventing oversized
unauthenticated poll inputs from reaching hashing.

In `@web/services/subrouter/vaultCrypto.ts`:
- Around line 66-77: Update the minimum ciphertext length guard in open so
raw.length < 16 is rejected, allowing the valid 16-byte AES-GCM representation
of an empty plaintext while preserving tag and body extraction. Add a round-trip
test in the vault crypto tests covering seal/open with an empty-string payload.
🪄 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 Plus

Run ID: d72441ad-561d-4c40-a4a9-510671b49b04

📥 Commits

Reviewing files that changed from the base of the PR and between 246f6ee and cc464da.

📒 Files selected for processing (10)
  • Packages/Shared/CmuxAPIClient/Sources/CmuxAPIClient/openapi.json
  • web/db/migrations/20260728160000_sr_vault/migration.sql
  • web/db/schema.ts
  • web/openapi/openapi.json
  • web/orpc/server/router.ts
  • web/orpc/server/sr/accounts.ts
  • web/orpc/server/sr/device.ts
  • web/orpc/server/sr/schemas.ts
  • web/services/subrouter/vaultCrypto.ts
  • web/tests/sr-vault.test.ts

Comment thread web/orpc/server/sr/accounts.ts Outdated
Comment on lines +18 to +26
async function requireTeam(user: AuthedUser): Promise<string> {
const team = await resolveBillingTeam(user);
if (!team) {
throw new ORPCError("FORBIDDEN", {
message: "No Stack team resolved for this user; create or select a team first",
});
}
return team.id;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
git ls-files | rg '(^|/)accounts\.ts$|web/services/billing/teamResolution|web/i18n/routing|messages' | sed -n '1,120p'

echo
echo "== server snippet =="
if [ -f web/orpc/server/sr/accounts.ts ]; then
  cat -n web/orpc/server/sr/accounts.ts | sed -n '1,80p'
fi

echo
echo "== billing/teamResolution =="
if [ -f web/services/billing/teamResolution.ts ]; then
  cat -n web/services/billing/teamResolution.ts | sed -n '1,220p'
fi

echo
echo "== occurrences of Stack/team wording in tracked files =="
rg -n "\bStack\b|listTeams|selectedTeam|No stack|No stack |select a team|create or select a team" . --glob '!node_modules' --glob '!dist' --glob '!build' | sed -n '1,220p'

Repository: manaflow-ai/cmux

Length of output: 42063


🌐 Web query:

Stack Auth API listTeams selectedTeam documentation

💡 Result:

In Stack Auth, you can manage team membership and selection using the SDK's team-related functions and properties [1]. Retrieving Teams To list the teams a user belongs to, you can use the listTeams method [1]. Client-Side (React): Use the useTeams hook to list all teams a user is a member of [1]. const allTeams = user.useTeams; Server-Side: Use the listTeams function on the user object retrieved from the server app [1]. const allTeams = await user.listTeams; Selected Team The selectedTeam property on the user object represents the team currently active or selected by the user [2][3]. You can access it directly to determine the user's current context [2][3]. // Accessing the selected team const currentTeam = user.selectedTeam; To simplify team management for users, Stack Auth provides a pre-built component, SelectedTeamSwitcher, which allows users to view and switch between their available teams in the UI [4][2]. For further details on implementing team selection and switching, refer to the official Stack Auth documentation on team selection concepts [5] and components [4].

Citations:


Remove the Stack vendor name from the user-facing error.

resolveBillingTeam is backed by Stack Auth’s selectedTeam / listTeams API, but the displayed FORBIDDEN message should describe the product concept instead of leaking Stack Auth terminology.

✏️ Proposed rewording
     throw new ORPCError("FORBIDDEN", {
-      message: "No Stack team resolved for this user; create or select a team first",
+      message: "No team resolved for this user; create or select a team first",
     });
📝 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
async function requireTeam(user: AuthedUser): Promise<string> {
const team = await resolveBillingTeam(user);
if (!team) {
throw new ORPCError("FORBIDDEN", {
message: "No Stack team resolved for this user; create or select a team first",
});
}
return team.id;
}
async function requireTeam(user: AuthedUser): Promise<string> {
const team = await resolveBillingTeam(user);
if (!team) {
throw new ORPCError("FORBIDDEN", {
message: "No team resolved for this user; create or select a team first",
});
}
return team.id;
}
🤖 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/orpc/server/sr/accounts.ts` around lines 18 - 26, Update the FORBIDDEN
error message in requireTeam to remove the “Stack” vendor name and use
product-neutral wording while preserving the existing guidance to create or
select a team.

Source: Coding guidelines

Comment thread web/orpc/server/sr/accounts.ts Outdated
Comment on lines +58 to +96
for (const account of input.accounts) {
const sealed = seal(account.credential);
const existing = await db
.select({ id: srVaultEntries.id })
.from(srVaultEntries)
.where(
and(
eq(srVaultEntries.teamId, teamId),
eq(srVaultEntries.provider, account.provider),
eq(srVaultEntries.accountLabel, account.accountLabel),
),
)
.limit(1);

if (existing.length > 0) {
await db
.update(srVaultEntries)
.set({
ciphertext: sealed.ciphertext,
nonce: sealed.nonce,
keyVersion: sealed.keyVersion,
updatedAt: new Date(),
})
.where(eq(srVaultEntries.id, existing[0]!.id));
updated += 1;
continue;
}

await db.insert(srVaultEntries).values({
teamId,
provider: account.provider,
accountLabel: account.accountLabel,
ciphertext: sealed.ciphertext,
nonce: sealed.nonce,
keyVersion: sealed.keyVersion,
createdByUserId: context.user.id ?? "",
});
uploaded += 1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg '(^|/)accounts\.ts$|web/db/schema\.ts|package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock'

echo "== accounts.ts context =="
if [ -f web/orpc/server/sr/accounts.ts ]; then
  wc -l web/orpc/server/sr/accounts.ts
  cat -n web/orpc/server/sr/accounts.ts
fi

echo "== sr_vault_entries references =="
rg -n "sr_vault_entries|srVaultEntries|team_provider_account_unique|unique" web/db web/orpc -S || true

echo "== schema context around vault entries =="
if [ -f web/db/schema.ts ]; then
  rg -n "srVaultEntries|vault|credential|accountLabel" web/db/schema.ts -C 3
fi

echo "== drizzle version pins =="
for f in package.json web/package.json package-lock.json pnpm-lock.yaml yarn.lock; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n '"drizzle-orm"|drizzle-orm@|/drizzle-orm/|drizzle-kit' "$f" | head -50 || true
  fi
done

Repository: manaflow-ai/cmux

Length of output: 35744


🌐 Web query:

drizzle-orm 1.0 beta onConflictDoUpdate target array exclusions example

💡 Result:

In Drizzle ORM, the onConflictDoUpdate method supports using an array in the target property to handle composite primary keys or composite unique constraints [1][2][3]. When a conflict occurs on a composite constraint, you provide the columns as an array in the target field. You can then use the excluded keyword (often via sql.raw or sql template tags) in the set object to refer to the values proposed for insertion [1][4][3]. Example of an upsert with a composite target: import { sql } from 'drizzle-orm'; import { inventory } from './schema'; // Upserting with a composite target (warehouseId, productId) await db.insert(inventory).values({ warehouseId: 1, productId: 1, quantity: 100 }).onConflictDoUpdate({ target: [inventory.warehouseId, inventory.productId], set: { // Using 'excluded' to reference the proposed insertion value quantity: sqlexcluded.quantity + 100 }, }); If you need to dynamically reference column names in the excluded set to avoid hardcoding strings, use sql.raw combined with the column's .name property [1][5]: await db.insert(inventory).values(data).onConflictDoUpdate({ target: [inventory.warehouseId, inventory.productId], set: { quantity: sql.raw(excluded.${inventory.quantity.name}) }, }); Note that the target array must match a defined primary key or unique constraint in your table schema for the conflict detection to work correctly [6][7][8]. If your schema does not have a composite unique constraint matching the columns in your target array, the database will return an error stating that no unique or exclusion constraint matches the specification [7].

Citations:


Use an atomic upsert for sr_accounts.push.

The SELECT-then-INSERT/UPDATE path races with concurrent pushes on the same (teamId, provider, accountLabel) and can violate sr_vault_entries_team_provider_account_unique; it also costs 2 round-trips per account. Use .onConflictDoUpdate() on the composite unique target so inserts and updates happen in one pass.

🤖 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/orpc/server/sr/accounts.ts` around lines 58 - 96, The account persistence
logic in the sr_accounts.push flow should replace the SELECT-then-INSERT/UPDATE
sequence with a single atomic insert using onConflictDoUpdate() targeting the
composite teamId, provider, and accountLabel uniqueness constraint. Preserve the
existing sealed credential fields and updatedAt on conflicts, retain
createdByUserId for new rows, and update the uploaded/updated counters according
to whether the operation inserts or conflicts.

Comment thread web/orpc/server/sr/device.ts Outdated
Comment on lines +27 to +62
export const srDeviceStartProcedure = os
.route({
method: "POST",
path: "/sr/device/start",
operationId: "sr.device.start",
summary: "Begin a device-code login",
description:
"Issues a device code for the CLI and a short user code for the human to approve in a browser. Grants no access until approved.",
tags: ["Subrouter"],
successStatus: 200,
})
.output(srDeviceStartOutputSchema)
.handler(async () => {
const db = cloudDb();
// Sweep expired rows opportunistically so this table cannot grow unbounded
// without a separate cron.
await db.delete(srDeviceCodes).where(lt(srDeviceCodes.expiresAt, new Date()));

const deviceCode = generateDeviceCode();
const userCode = generateUserCode();
const expiresAt = new Date(Date.now() + DEVICE_CODE_TTL_SECONDS * 1000);

await db.insert(srDeviceCodes).values({
userCode,
deviceCodeHash: hashDeviceCode(deviceCode),
expiresAt,
});

return {
userCode,
deviceCode,
verificationUri: "https://cmux.com/sr/device",
expiresInSeconds: DEVICE_CODE_TTL_SECONDS,
intervalSeconds: POLL_INTERVAL_SECONDS,
};
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for any rate-limiting middleware applied to /sr/device/start or the SR router generally.
rg -n -i 'rate.?limit' web --type=ts -g '!**/node_modules/**'
rg -n 'sr/device/start' web --type=ts -g '!**/node_modules/**'

Repository: manaflow-ai/cmux

Length of output: 33454


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
fd -a 'device\.ts|route\.ts|.*middleware.*|.*auth.*|env\.ts' web/orpc web/orpc/server web/services web/app 2>/dev/null | sed 's#^\./##' | head -200

echo
echo "== sr device outline =="
ast-grep outline web/orpc/server/sr/device.ts --view compact 2>/dev/null || true

echo
echo "== sr device relevant lines =="
cat -n web/orpc/server/sr/device.ts

echo
echo "== orpc server files =="
fd -a '\.(ts|tsx)$' web/orpc | sed 's#^\./##' | head -200

echo
echo "== route imports/usages for sr device =="
rg -n 'sr/device|srDevice|device.start|/sr' web --type=ts -g '!**/node_modules/**' | head -300

Repository: manaflow-ai/cmux

Length of output: 14018


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== base route options =="
cat -n web/orpc/server/base.ts

echo
echo "== router definition =="
cat -n web/orpc/server/router.ts

echo
echo "== RPC route handling =="
ast-grep outline web/app/api/rpc/[[...rest]]/route.ts 2>/dev/null || true
cat -n web/app/api/rpc/[[...rest]]/route.ts

echo
echo "== openapi route handling =="
cat -n web/app/api/openapi.json/route.ts

echo
echo "== subrouter route handling =="
cat -n web/app/api/subrouter/accounts/route.ts
cat -n web/app/api/subrouter/accounts/[accountId]/route.ts

Repository: manaflow-ai/cmux

Length of output: 18266


Bound unauthenticated device-code creation.

/api/rpc creates a fresh ORPCContext for each request, but /sr/device/start intentionally keeps context.user null so it can be called before authentication. With a 900s TTL and only opportunistic deletion on the next start call, sustained public traffic can keep inserting rows into sr_device_codes. Add a per-IP/client global cap or similar guard before the insert; keep the cap before any database write/failure-open only if unavoidable.

🤖 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/orpc/server/sr/device.ts` around lines 27 - 62, Add a per-IP or
equivalent client-based rate/cap guard at the start of srDeviceStartProcedure
before the cleanup or insert database writes, limiting unauthenticated
device-code creation over the device-code TTL window. Preserve successful
issuance for clients under the cap, and only fail open when the client identity
cannot be determined or the guard is unavailable.

Comment thread web/orpc/server/sr/device.ts Outdated
Comment on lines +80 to +110
.handler(async ({ input }) => {
const db = cloudDb();
const rows = await db
.select()
.from(srDeviceCodes)
.where(eq(srDeviceCodes.deviceCodeHash, hashDeviceCode(input.deviceCode)))
.limit(1);

const row = rows[0];
if (!row) {
// An unknown code is indistinguishable from an expired one on purpose:
// neither confirms whether a code ever existed.
return { status: "expired" as const, teamId: null, userId: null };
}
if (row.expiresAt.getTime() <= Date.now()) {
await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id));
return { status: "expired" as const, teamId: null, userId: null };
}
if (!row.approvedAt || !row.teamId) {
return { status: "pending" as const, teamId: null, userId: null };
}

// Single-use: consuming the row means a captured device code cannot be
// redeemed twice.
await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id));
return {
status: "approved" as const,
teamId: row.teamId,
userId: row.userId,
};
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Race between reading approvedAt and deleting the row can defeat single-use.

The select at lines 82-86 and the delete at line 104 are separate round-trips. Two concurrent polls (e.g. a captured device code raced against the legitimate CLI, or a client retry overlapping an in-flight request) can both observe approvedAt/teamId set and both take the "approved" branch before either delete lands — both callers get back the same teamId/userId, contradicting the comment's stated single-use guarantee.

🔒 Proposed fix: make the claim atomic via delete-and-return
-    if (!row.approvedAt || !row.teamId) {
-      return { status: "pending" as const, teamId: null, userId: null };
-    }
-
-    // Single-use: consuming the row means a captured device code cannot be
-    // redeemed twice.
-    await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id));
-    return {
-      status: "approved" as const,
-      teamId: row.teamId,
-      userId: row.userId,
-    };
+    if (!row.approvedAt || !row.teamId) {
+      return { status: "pending" as const, teamId: null, userId: null };
+    }
+
+    // Atomic delete-if-approved: at most one concurrent poll can claim the
+    // row, so a captured device code cannot be redeemed twice even under a race.
+    const [claimed] = await db
+      .delete(srDeviceCodes)
+      .where(and(eq(srDeviceCodes.id, row.id), isNotNull(srDeviceCodes.approvedAt)))
+      .returning();
+    if (!claimed || !claimed.teamId) {
+      return { status: "pending" as const, teamId: null, userId: null };
+    }
+    return {
+      status: "approved" as const,
+      teamId: claimed.teamId,
+      userId: claimed.userId,
+    };

Requires importing and, isNotNull from drizzle-orm.

📝 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
.handler(async ({ input }) => {
const db = cloudDb();
const rows = await db
.select()
.from(srDeviceCodes)
.where(eq(srDeviceCodes.deviceCodeHash, hashDeviceCode(input.deviceCode)))
.limit(1);
const row = rows[0];
if (!row) {
// An unknown code is indistinguishable from an expired one on purpose:
// neither confirms whether a code ever existed.
return { status: "expired" as const, teamId: null, userId: null };
}
if (row.expiresAt.getTime() <= Date.now()) {
await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id));
return { status: "expired" as const, teamId: null, userId: null };
}
if (!row.approvedAt || !row.teamId) {
return { status: "pending" as const, teamId: null, userId: null };
}
// Single-use: consuming the row means a captured device code cannot be
// redeemed twice.
await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id));
return {
status: "approved" as const,
teamId: row.teamId,
userId: row.userId,
};
});
.handler(async ({ input }) => {
const db = cloudDb();
const rows = await db
.select()
.from(srDeviceCodes)
.where(eq(srDeviceCodes.deviceCodeHash, hashDeviceCode(input.deviceCode)))
.limit(1);
const row = rows[0];
if (!row) {
// An unknown code is indistinguishable from an expired one on purpose:
// neither confirms whether a code ever existed.
return { status: "expired" as const, teamId: null, userId: null };
}
if (row.expiresAt.getTime() <= Date.now()) {
await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id));
return { status: "expired" as const, teamId: null, userId: null };
}
if (!row.approvedAt || !row.teamId) {
return { status: "pending" as const, teamId: null, userId: null };
}
// Atomic delete-if-approved: at most one concurrent poll can claim the
// row, so a captured device code cannot be redeemed twice even under a race.
const [claimed] = await db
.delete(srDeviceCodes)
.where(and(eq(srDeviceCodes.id, row.id), isNotNull(srDeviceCodes.approvedAt)))
.returning();
if (!claimed || !claimed.teamId) {
return { status: "pending" as const, teamId: null, userId: null };
}
return {
status: "approved" as const,
teamId: claimed.teamId,
userId: claimed.userId,
};
});
🤖 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/orpc/server/sr/device.ts` around lines 80 - 110, Make the approved-code
claim atomic in the handler by replacing the separate approved-row read and
delete with a delete-and-return operation constrained to the matching device
code hash, unexpired row, and non-null approvedAt/teamId; import and use and and
isNotNull as needed. Return the approved response only when the atomic delete
returns a row, while preserving expired and pending responses for rows that
cannot be claimed.

Comment thread web/orpc/server/sr/schemas.ts Outdated
Comment on lines +54 to +56
export const srDevicePollInputSchema = z.object({
deviceCode: z.string().min(1),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound deviceCode input length.

Every other client-supplied string in this file has an explicit max() (accountLabel ≤ 320, credential ≤ 64,000), but deviceCode on this unauthenticated poll endpoint has none. Generated device codes are ~43 chars (generateDeviceCode in vaultCrypto.ts), so a generous cap still leaves headroom while preventing oversized request bodies from reaching the hashing step on every poll.

🛡️ Proposed fix
 export const srDevicePollInputSchema = z.object({
-  deviceCode: z.string().min(1),
+  deviceCode: z.string().min(1).max(256),
 });
📝 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
export const srDevicePollInputSchema = z.object({
deviceCode: z.string().min(1),
});
export const srDevicePollInputSchema = z.object({
deviceCode: z.string().min(1).max(256),
});
🤖 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/orpc/server/sr/schemas.ts` around lines 54 - 56, Update
srDevicePollInputSchema to add an explicit maximum length to deviceCode, using a
generous bound above the approximately 43-character values generated by
generateDeviceCode while preventing oversized unauthenticated poll inputs from
reaching hashing.

Comment thread web/services/subrouter/vaultCrypto.ts Outdated
Comment on lines +66 to +77
export function open(sealed: SealedSecret): string {
const key = resolveKey();
const raw = Buffer.from(sealed.ciphertext, "base64");
if (raw.length < 17) {
throw new Error("ciphertext too short to contain an auth tag");
}
const tag = raw.subarray(raw.length - 16);
const body = raw.subarray(0, raw.length - 16);
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.nonce, "base64"));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Off-by-one in the auth-tag length guard.

AES-GCM output length equals plaintext length, so a sealed empty-string payload produces exactly 16 raw bytes (0-byte body + 16-byte tag). The check raw.length < 17 incorrectly rejects that valid minimum-length case; it should be < 16. Not reachable today since the only caller enforces credential.min(1), but this is a shared crypto primitive and the boundary is wrong on its own terms.

🐛 Proposed fix
-  if (raw.length < 17) {
+  if (raw.length < 16) {
     throw new Error("ciphertext too short to contain an auth tag");
   }

Consider adding a test in web/tests/sr-vault.test.ts that round-trips an empty-string payload through seal/open to lock this boundary in.

📝 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
export function open(sealed: SealedSecret): string {
const key = resolveKey();
const raw = Buffer.from(sealed.ciphertext, "base64");
if (raw.length < 17) {
throw new Error("ciphertext too short to contain an auth tag");
}
const tag = raw.subarray(raw.length - 16);
const body = raw.subarray(0, raw.length - 16);
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.nonce, "base64"));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8");
}
export function open(sealed: SealedSecret): string {
const key = resolveKey();
const raw = Buffer.from(sealed.ciphertext, "base64");
if (raw.length < 16) {
throw new Error("ciphertext too short to contain an auth tag");
}
const tag = raw.subarray(raw.length - 16);
const body = raw.subarray(0, raw.length - 16);
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.nonce, "base64"));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8");
}
🤖 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/services/subrouter/vaultCrypto.ts` around lines 66 - 77, Update the
minimum ciphertext length guard in open so raw.length < 16 is rejected, allowing
the valid 16-byte AES-GCM representation of an empty plaintext while preserving
tag and body extraction. Add a round-trip test in the vault crypto tests
covering seal/open with an empty-string payload.

The hosted Subrouter account and Stack authentication flows landed in #9261. CodeRouter now stores credentials with KMS encryption (#9686) and enforces private/team account permissions and VM pools (#12771).

Keep those shipped implementations instead of adding an unused SR_VAULT_KEY credential store and incomplete device-code flow. The resulting tree is identical to main at a149b7e. Current-path focused tests: 63 passed; web complexity gate passed.
@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

cmux-reconcile: close-candidate

Proposed action: Close this PR without merging; preserve the branch.

Evidence checked September 18, 2026: GitHub currently reports 0 changed files, 0 additions, and 0 deletions, and the fetched PR diff is empty. The branch has no remaining patch to land. This does not claim the original product idea is unnecessary.

This is a reconciliation recommendation for a maintainer with write access, not an automatic closure or runtime test result. Recheck for new commits or reports before acting. Search "cmux-reconcile: close-candidate" in this repository for this cleanup batch.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Fleet instruction update for head 92684827a99f928651485903cac3411087599185: this PR is classified other. No macOS build tag is claimed. The current controller app recipe does not establish iOS/test readiness; that requires the appropriate validated recipe. Use cmux-ci for supported jobs, retain the returned ID and receipt, and wait on the same ID after any timeout. Do not use retired maclease allocation or post credentials. Exact-head tags will be posted only after the applicable build succeeds.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Closing as already on main: merging this branch into main at 8421357 produces main's own tree, so there's nothing left to land. The branch is kept; reopen if something here is still missing. Part of the backlog cleanup in manaflow-ai/cmuxterm-hq#563.

@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants