Skip to content

Remote access via Cloudflare relay + polished CLI auth UX#4

Merged
TraderSamwise merged 47 commits into
feat/rn-gui-basefrom
feat/remote-access
May 21, 2026
Merged

Remote access via Cloudflare relay + polished CLI auth UX#4
TraderSamwise merged 47 commits into
feat/rn-gui-basefrom
feat/remote-access

Conversation

@TraderSamwise

@TraderSamwise TraderSamwise commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on #3. Adds remote agent control: a Cloudflare Workers + Durable Objects relay brokers an authenticated WebSocket tunnel between a logged-in app session and the user's local daemon.

Architecture

  • relay/ — Cloudflare Worker. Verifies Clerk JWT on app connect; for daemons, accepts long-lived HS256 tokens it mints itself. Durable Object holds the WS pair and proxies app↔daemon traffic + per-project metadata-server requests via a /proxy/:host/:port/* route
  • Clerk for app auth (@clerk/clerk-expo); daemon Bearer-token validation via @clerk/backend when CLERK_SECRET_KEY is set
  • Daemon relay client (src/relay-client.ts) — dials the WS tunnel with the stored token, exposes a status stream
  • App relay transport (app/lib/relay-transport.ts) — wraps the same fetch/SSE surface as the local HTTP client so the UI works identically over local HTTP or remote WS

Polished CLI UX

  • aimux login — browser-based auth flow (localhost callback server, opens ${WEB_APP_URL}/cli-auth, no token pasting)
  • aimux logout / aimux whoami
  • aimux remote enable | disable | status — toggle the relay without restarting the daemon
  • aimux daemon status now reports relay state
  • App shows a colored "Remote" pill in the TopBar (connected / connecting / host offline / offline)

Why daemon tokens are HS256-signed by the relay (not Clerk JWTs)

Clerk JWTs are ~60s. The daemon is long-lived. After browser-based aimux login, the relay verifies the user's Clerk session and mints a 90d HS256 daemon token (relay/src/daemon-token.ts). The daemon then connects to the relay using that token — decoupled from Clerk's TTL.

Test plan

  • yarn verify passes 566/566 (both standalone and under pre-push)
  • End-to-end aimux login browser flow drives token issuance and stores credentials
  • App connects via relay transport and "Remote" indicator turns green
  • Deploy relay to Cloudflare + app to Vercel; verify production tunnel

Review base

Base branch is feat/rn-gui-base (PR #3). This PR's diff shows only the remote-access additions on top of the GUI rebuild — Copilot can review it independently.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added remote access functionality via relay service, enabling daemon control from the web and mobile apps
    • Implemented browser-based login flow for secure authentication to remote services
    • Added new CLI commands: aimux login, aimux logout, aimux whoami, and aimux remote for managing remote access
    • Introduced landing page and authentication screens in the mobile/web app
    • Added relay connection status indicator in the app UI
  • Documentation

    • Published comprehensive deployment guide covering relay setup, authentication, and environment configuration

Review Change Stack

TraderSamwise and others added 10 commits May 21, 2026 13:39
Auth gate now redirects unauthenticated users to /sign-in (LOCAL_MODE
bypasses). Daemon validates Bearer JWTs via @clerk/backend on all
routes except /health; skips validation when CLERK_SECRET_KEY is unset.
Sign-out navigates to /sign-in, auth screens use theme tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CF Worker + Durable Object per user. Daemon and web/mobile clients
connect via WebSocket, relay routes messages between them. Clerk JWT
verification on all WS upgrades. Heartbeat via DO alarm, daemon
replacement on reconnect, client fan-out for multi-device support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Daemon connects to the Cloudflare relay when AIMUX_RELAY_URL and
AIMUX_RELAY_TOKEN env vars are set. Relay forwards client requests
through the WS; daemon routes them via the same logic as direct HTTP.
Exponential backoff reconnect (1s–30s), clean shutdown on daemon stop.
Refactored daemon routing into public routeRequest() for reuse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
App-side RelayTransport class connects to the relay as a client,
wraps HTTP requests as relay messages, and tracks daemon online
status. API layer seamlessly switches between direct HTTP and relay
based on connection state. Daemon gains /proxy/:host/:port/* route
to forward metadata server requests through the tunnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unauthenticated users see a landing page with sign-up/sign-in CTAs
and feature highlights instead of jumping straight to sign-in.
Production wrangler config adds relay.aimux.com custom domain.
Deployment guide covers Clerk, relay, web app, daemon, and native.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relay mints long-lived (90d) HS256 daemon tokens via POST /cli/issue-token
after verifying a Clerk session JWT. /daemon/connect now accepts both
relay-signed daemon tokens and Clerk JWTs, dispatching by token shape.
CLI gains ~/.aimux/auth.json credential storage (0600) with a
remoteEnabled flag for runtime toggling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`aimux login` spins up a localhost callback server, opens the browser to
the web app's /cli-auth page, and captures a minted daemon token — no
token pasting. The cli-auth page issues the token from the relay using
the user's Clerk session and redirects to the callback. Adds `aimux
logout` and `aimux whoami`. Verified end-to-end against local relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Daemon now reads relay config from stored credentials (env-var override
retained) and connects only when remote access is enabled. RelayClient
tracks connection status (connecting/connected/reconnecting/auth_failed)
and surfaces it via /relay/status. New `aimux remote enable|disable|
status` commands toggle the relay at runtime without restarting the
daemon; `aimux daemon status` now shows relay state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
RelayTransport now connects on mount when EXPO_PUBLIC_AIMUX_RELAY_URL is
set, syncs status into a Jotai atom, and registers with the API layer so
requests route through the tunnel. New RelayIndicator pill in the TopBar
shows live connection state (Remote / Connecting / Host offline),
hidden entirely for local-only deployments. Verified end-to-end:
browser → relay → daemon project list over the WS tunnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

This comment was marked as outdated.

test added 9 commits May 21, 2026 15:31
# Conflicts:
#	app/components/AuthMenu.tsx
base64UrlDecodeToString restored - and _ but never re-added the = padding
that base64UrlEncode strips. Workers' atob is strict about length being a
multiple of 4 — JWT payloads with non-trivial sizes routinely produced
3-mod-4 segments and atob would throw, breaking verifyDaemonToken and
isDaemonToken on the affected tokens.

Re-pad before atob.
The Durable Object was broadcasting every daemon "response" frame to all
connected client sockets. With multiple clients on the same user
(desktop + mobile, or two browser tabs) that leaked each client's
response bodies to the other and confused request/response correlation.

Track an in-flight requestId → client-socket map on each inbound request,
route the matching response to that one socket on the way back, and
clear entries on client close or after a TTL so a dropped response
can't pin memory forever.
/proxy previously accepted any host/port from the remote client, turning
the daemon into an SSRF gadget for whatever network the user's machine
could reach. Restrict targets to a small allowlist (127.0.0.1, ::1,
localhost) and reject everything else with 403.

Add a bounded outbound timeout (10s) on the proxied requestJson call;
return 504 when it trips so the relay client can fail fast instead of
holding a daemon-side handler open.

Drop the HTTP-level Clerk auth wrapper from handle(): the daemon binds
to 127.0.0.1 only, and remote app requests come in over the relay (which
verifies Clerk/HS256 before forwarding) and call routeRequest()
directly in-process. The HTTP wrapper only ever saw local CLI calls and
broke `aimux remote enable/disable/status` whenever CLERK_SECRET_KEY was
set.
cli-auth accepted any ?callback= URL and redirected to it with the newly
minted daemon token in the query string. A signed-in user clicking a
crafted link could send their long-lived token to an attacker-controlled
host.

Validate the callback up front: scheme must be http, hostname must be
127.0.0.1 / localhost / [::1], path must be /callback. If it doesn't
match, refuse outright — don't even attempt to mint a token, don't
redirect anywhere — and show the user why.
Both docs/deployment.md and .env.example still described
AIMUX_RELAY_TOKEN as a Clerk session JWT pasted into the user's
environment. In the current architecture the daemon token is produced
by \`aimux login\`, signed HS256 by the relay, and stored at
~/.aimux/auth.json with ~90d TTL. Operators reading the old text would
be misled into trying short-lived Clerk JWTs.

Describe the login flow and call out that AIMUX_RELAY_TOKEN is only
useful as a manual override of the stored credential.
Adds a small routing-level test suite covering the cases the recent
hardening touched:

- /relay/status returns status: off when no relay is configured
- /proxy rejects non-loopback hosts with 403 and never reaches
  requestJson
- /proxy forwards loopback requests with timeoutMs set
- /proxy returns 504 when the proxied target times out

Uses the existing requestJson mock and the AimuxDaemon class directly,
matching the pattern in the rest of the file.

This comment was marked as outdated.

test added 4 commits May 21, 2026 18:13
URL.hostname strips the brackets from IPv6 literals, so a callback like
http://[::1]:port/callback parses to hostname "::1", not "[::1]". The
previous allowlist would refuse the only valid IPv6 loopback shape.
The pending-request TTL only ran when the daemon sent a response. If the
daemon hung or stopped responding mid-request, entries accumulated and
the waiting client sat idle until its own transport timeout. Run the
sweep from alarm() too, and emit a 504 response to the waiting client
when an entry expires so it can fail fast.

Keep the alarm armed as long as pendingRequests is non-empty so a
single in-flight request after the last socket left still gets cleaned
up on schedule.
POST /cli/issue-token already 500s if the secret is missing, but the
daemon-token verification path on /daemon/connect would have called
verifyDaemonToken with an empty key and either thrown (surfacing as a
misleading 401 "Invalid token") or accepted whatever crypto the empty
secret produced. Treat unset secret as a deployment misconfiguration —
return 500 with a clear message, distinct from a genuinely invalid
token.
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ca83f60c-2028-46aa-adfb-c21d2663de8c

📥 Commits

Reviewing files that changed from the base of the PR and between aa64792 and c8a8524.

⛔ Files ignored due to path filters (1)
  • relay/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (19)
  • app/app/(main)/_layout.tsx
  • app/app/cli-auth.tsx
  • app/app/sign-in.tsx
  • app/app/sign-up.tsx
  • app/components/TopBar.tsx
  • app/lib/api.ts
  • app/lib/relay-transport.ts
  • docs/deployment.md
  • relay/package.json
  • relay/src/auth.ts
  • relay/src/index.ts
  • relay/src/relay-object.ts
  • relay/src/types.ts
  • src/credentials.ts
  • src/daemon.ts
  • src/login-flow.ts
  • src/main.ts
  • src/multiplexer/session-launch.test.ts
  • src/relay-client.ts
✅ Files skipped from review due to trivial changes (4)
  • relay/package.json
  • app/app/sign-in.tsx
  • app/app/sign-up.tsx
  • docs/deployment.md

📝 Walkthrough

Walkthrough

This PR implements a complete relay system enabling remote WebSocket access to a local daemon. It adds a Cloudflare Worker relay service with per-user Durable Object request routing, integrates a browser-based CLI login flow with token issuance, and extends the daemon with relay connectivity, proxy forwarding, and remote access commands.

Changes

Relay Remote Access Implementation

Layer / File(s) Summary
Relay server message contracts and authentication
relay/src/types.ts, relay/src/auth.ts, relay/src/daemon-token.ts
TypeScript interfaces for relay environment, request/response/control messages, WebSocket token verification via Clerk, and HS256 HMAC daemon token minting/verification.
Cloudflare Worker request routing and token issuance
relay/src/index.ts
Main fetch handler implementing CORS, health endpoint, /cli/issue-token daemon token issuance with Bearer Clerk JWT verification, and WebSocket routing to relay Durable Object.
Relay Durable Object WebSocket message broker
relay/src/relay-object.ts
Durable Object managing WebSocket connections from one daemon and multiple clients, routing request/response messages by ID, handling connection lifecycle, and sweeping expired pending requests with timeout responses.
Relay Cloudflare Worker build and deployment configuration
relay/package.json, relay/tsconfig.json, relay/wrangler.toml
Package.json, TypeScript configuration, and Wrangler deployment configuration for the relay Cloudflare Worker and Durable Object.
Environment configuration and relay state atoms
.env.example, .gitignore, app/.env.example, app/lib/env.ts, app/lib/envRuntime.ts, app/stores/relay.ts
Environment variables for root, app, and relay projects, and Jotai atoms for tracking relay configuration and connection status in the web app UI.
Web app WebSocket relay transport client
app/lib/relay-transport.ts
RelayTransport class implementing WebSocket connection to relay with request/response ID correlation, per-request timeouts, daemon online/offline status tracking, and exponential backoff reconnection.
MainLayout relay transport lifecycle management
app/app/(main)/_layout.tsx
useEffect in MainLayout that conditionally initializes RelayTransport based on AIMUX_RELAY_URL, subscribes to status updates, registers transport with API layer, and manages cleanup.
API layer relay request proxying
app/lib/api.ts
Helper functions routing daemon health, project, and per-service requests through relay when WebSocket connected, with fallback to direct HTTP for offline operation.
Relay connection status UI indicator
app/components/RelayIndicator.tsx, app/components/TopBar.tsx
RelayIndicator component displaying relay connection status and TopBar integration to show relay status when configured.
Authentication gating logic and landing page
app/app/_layout.tsx, app/app/index.tsx, app/app/landing.tsx
Root auth gating with LOCAL_MODE support, landing page marketing screen with features, and public route navigation (sign-in, sign-up, landing, cli-auth).
CLI authentication bridge screen and token issuance
app/app/cli-auth.tsx
CliAuthScreen web component validating localhost callback URLs, requesting daemon tokens from relay, handling LOCAL_MODE errors, and redirecting with token or error.
Authentication screen UI styling updates
app/app/sign-in.tsx, app/app/sign-up.tsx, app/components/AuthMenu.tsx
Sign-in and sign-up styling refinements (font-mono headers, error color to text-destructive) and sign-out navigation flow fix.
Credential persistence and file paths
src/credentials.ts, src/paths.ts
AimuxCredentials interface and functions to load, save, clear, and toggle remote-enabled state for persisted relay credentials at ~/.aimux/auth.json.
Browser-based CLI login callback and credential capture
src/login-flow.ts
runLoginFlow implementation starting localhost callback server, opening browser to web app cli-auth route, parsing callback, and persisting credentials on success.
Daemon relay WebSocket client
src/relay-client.ts
RelayClient class maintaining WebSocket connection to relay with automatic reconnection, JSON message routing to daemon, and connection lifecycle handling.
Daemon HTTP routing, relay lifecycle, and allowlisted proxy
src/daemon.ts
Daemon routeRequest method routing /health, /relay/status/enable/disable, project endpoints, and /proxy/:host/:port/* with loopback allowlist and timeout handling.
Daemon routing and proxy behavior tests
src/daemon.test.ts
Test suite validating relay status endpoint, proxy allowlist rejection, loopback forwarding with timeout, and timeout error responses.
CLI login, logout, whoami, and remote access commands
src/main.ts
New CLI commands: top-level login/logout/whoami for credential management, and aimux remote subcommands (status, enable, disable) integrating daemon relay endpoints.
Test environment setup helpers
src/multiplexer/session-launch.test.ts
gitInit test helper sanitizing GIT_* environment variables during repository initialization.
Deployment guide and architecture documentation
docs/deployment.md
Comprehensive deployment documentation covering Clerk setup, Cloudflare relay deployment, web/native app deployment, local daemon login, architecture, and security.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 A relay bridge now spans the night,
Where daemon and browser meet in flight,
Token by token, request by request,
Remote access put to the test,
With WebSockets humming, credentials at rest!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Remote access via Cloudflare relay + polished CLI auth UX' accurately reflects the main changes: adding remote access functionality via a Cloudflare relay and improving the CLI authentication user experience. It is concise, specific, and clearly summarizes the primary purpose of the changeset.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/remote-access

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

Copilot AI 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.

Pull request overview

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

Comment thread src/login-flow.ts Outdated
Comment on lines +20 to +28
function openBrowser(url: string): void {
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
try {
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
child.unref();
} catch {
// Browser open is best-effort; the URL is also printed for manual open.
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 64141dd — added child.on("error", () => {}) listener so async ENOENT from spawn doesn't crash with an unhandled error event.

Comment thread docs/deployment.md
Comment on lines +27 to +41
# Set the Clerk secret key (verifies app-side session JWTs)
wrangler secret put CLERK_SECRET_KEY
# Paste your sk_live_... key

# Set the daemon-token signing key (HS256 secret used to mint + verify
# long-lived daemon tokens from `aimux login`). Use a strong random
# value — anything that compromises this lets an attacker forge tokens.
# e.g. `openssl rand -base64 48` or `head -c 48 /dev/urandom | base64`
wrangler secret put RELAY_TOKEN_SECRET

# Deploy (dev)
wrangler deploy

# Deploy (production with custom domain)
wrangler deploy --env production

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 64141dd — added wrangler secret put CLI_TOKEN_ALLOWED_ORIGINS to the deployment steps with a note on the expected format (comma-separated origins).

@TraderSamwise

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

test and others added 2 commits May 21, 2026 22:18
- login-flow.ts: add error listener to spawned child process so async
  ENOENT doesn't crash with unhandled error
- docs/deployment.md: add CLI_TOKEN_ALLOWED_ORIGINS to wrangler secrets
  setup — required for /cli/issue-token CORS to work

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <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.

Actionable comments posted: 13

🤖 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 `@app/app/`(main)/_layout.tsx:
- Around line 36-49: During the cleanup for the RelayTransport effect,
explicitly set the relayStatusAtom to the disconnected state because unsub()
removes the status listener before transport.disconnect() can emit
"disconnected"; update the cleanup returned by the effect that uses
RelayTransport (references: RelayTransport, relayStatusAtom, unsub,
transport.disconnect, setApiRelay) to call unsub(), then transport.disconnect(),
then store.set(relayStatusAtom, "disconnected") (and finally setApiRelay(null))
so the UI reflects the final disconnected state immediately.

In `@app/components/TopBar.tsx`:
- Around line 13-15: TopBar currently always renders a <View className="mr-3">
wrapper around RelayIndicator which reserves horizontal space even when
RelayIndicator returns null; change the rendering so the wrapper is only emitted
when RelayIndicator actually renders (e.g., conditionally render the View by
checking RelayIndicator's presence or move the "mr-3" spacing into
RelayIndicator itself), targeting the RelayIndicator usage in TopBar.tsx so the
gap before AuthMenu disappears for local-only setups.

In `@app/lib/api.ts`:
- Around line 56-75: Several API methods still call localhost directly and/or
check wsConnected instead of using the relay path; update them to use the relay
routing functions and to gate routing on isConnected. For each remote-only flow
listed (getAgentHistory, getAgentOutput, sendAgentInput, getPlan, spawnAgent,
stopAgent, listThreads, and any other daemon/service wrappers around lines
referenced) replace the direct-localhost calls with callDaemonViaRelay or
callServiceViaRelay as appropriate, and change the guard from wsConnected (or
the old predicate) to isConnected so the code falls back correctly when the
relay is not available; ensure you pass the same method/path/body semantics into
callDaemonViaRelay and construct proxyPath for callServiceViaRelay using the
existing ServiceEndpoint shape.

In `@docs/deployment.md`:
- Line 63: Two fenced code blocks in the deployment docs are missing language
identifiers; add appropriate language tags to the blocks containing the
environment variables and the ASCII diagram (e.g., change the env block around
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_..." to use ```dotenv and change the
diagram block starting with "User's machine                    Cloud            
User's phone/laptop" to use ```text) so markdownlint passes and readability
improves.

In `@relay/src/auth.ts`:
- Around line 8-9: verifyWsToken currently returns payload.sub but verifyToken
from `@clerk/backend` returns { data, errors } instead of throwing; update
verifyWsToken to inspect the result (the object returned by verifyToken) and: if
result.errors is present, throw an error including the errors; if
result.data?.sub is missing or not a string, throw an error indicating an
invalid/missing subject; only return result.data.sub when it exists and is a
string. Reference verifyWsToken and verifyToken to locate and fix the logic.

In `@relay/src/index.ts`:
- Around line 131-169: The current flow reads token only from the query string,
which exposes long‑lived daemon tokens; update the auth extraction so that for
daemon connections you first accept and validate a bearer token from
Authorization (and/or Sec-WebSocket-Protocol for WS handshakes) before falling
back to the query param only for browser short‑lived tokens; keep the existing
isDaemonToken, verifyDaemonToken, verifyWsToken, and secret checks
(RELAY_TOKEN_SECRET/CLERK_SECRET_KEY) but change the initial token acquisition
to prefer request.headers.get("authorization") (and parse Sec‑WebSocket‑Protocol
when present) for daemon validation, then only use url.searchParams.get("token")
for non‑daemon/browser cases, and continue deleting the token from doUrl before
proxying.

In `@relay/src/relay-object.ts`:
- Around line 81-87: The current logic stores in-flight requests keyed only by
parsed.id in pendingRequests, which allows different sockets to collide; change
this so pendingRequests is scoped per socket or uses a composite key (e.g.,
socketId + parsed.id) or reject duplicate parsed.ids from the same socket;
specifically update the code around the parsed.type === "request" branch where
pendingRequests.set(parsed.id, ...) (and related lookup/cleanup) to either (1)
generate a relayRequestId and store mapping relayRequestId -> { client: ws,
originalId: parsed.id, expiresAt: ... } and forward the relayRequestId to the
daemon, or (2) use a composite key like `${ws.id}:${parsed.id}` when setting and
resolving entries; ensure the daemonWs forwarding and response handling logic
uses the same key mapping and that PENDING_REQUEST_TTL_MS cleanup still applies.
- Around line 28-37: Before overwriting this.daemonWs in the role === "daemon"
branch, capture the old socket and immediately fail any pending requests
associated with that socket so they don't linger until TTL expiry; specifically,
in the block that currently calls this.send(...) and oldWs.close(...), call the
same failure path used by webSocketClose/removeSocket (or introduce a helper
like failPendingRequestsForSocket(socket)) to iterate pendingRequests and emit
errors for requests sent to the displaced daemon socket, then proceed to close
and replace this.daemonWs and broadcast; alternatively, update
removeSocket/webSocketClose to detect and fail requests for sockets no longer
referenced as this.daemonWs by using the socket identity rather than relying on
equality with this.daemonWs.
- Around line 10-14: Rehydrate in-memory socket state on Durable Object wake by
calling this.ctx.getWebSockets() (e.g., in the RelayObject constructor or at
start of fetch) and rebuild daemonWs, clientSockets, and per-client pending
request mappings from socket tags/metadata instead of relying solely on
ephemeral fields; change pendingRequests from a single Map keyed by parsed.id to
a Map<WebSocket, Map<string, {client: WebSocket; expiresAt: number}>> (or a
composite key that includes a stable client identifier) so request ids like "rN"
cannot collide across tabs, update code paths that add/remove pending entries
(e.g., in fetch(), removeSocket(), and webSocketClose()) to use the per-socket
map, and when replacing daemonWs ensure you first call removeSocket(oldDaemonWs)
or drain/forward its pending entries to avoid leaving in-flight requests
stranded before setting this.daemonWs = newWs.

In `@src/daemon.ts`:
- Around line 601-608: The handle() method is dropping URL query params by
passing only url.pathname into routeRequest(); update handle to preserve the
full path+query when forwarding by passing url.pathname + url.search (or
url.pathname + (url.search || "")) to routeRequest (keep using the same readJson
logic and IncomingMessage/ServerResponse flow), so routeRequest receives the
original request path including query string instead of losing GET params.
- Around line 283-287: The code currently sets enabled from creds first so
environment overrides (AIMUX_RELAY_URL/AIMUX_RELAY_TOKEN) are ignored; update
the enabled calculation so if either AIMUX_RELAY_URL or AIMUX_RELAY_TOKEN is
present then enabled = true, otherwise fall back to creds.remoteEnabled when
creds exists (and false if neither exists). Change the enabled assignment near
loadCredentials()/creds/relayUrl/relayToken to check process.env.AIMUX_RELAY_URL
|| process.env.AIMUX_RELAY_TOKEN first, then use creds?.remoteEnabled as the
fallback.

In `@src/login-flow.ts`:
- Around line 88-96: The saveCredentials call in the login callback can throw
and crash the CLI; wrap the saveCredentials(...) invocation in a try/catch
inside the same callback (the code that currently calls resolve({ userId })),
and on error log the failure (using the existing logger or console) and call
reject(err) (or reject(new Error(...))) so the login Promise fails gracefully
instead of letting the write exception bubble and terminate the process; keep
resolve({ userId }) on success.

In `@src/main.ts`:
- Around line 1406-1451: Change the "remote status" and "remote disable" flows
so they do not call ensureDaemonRunning() (which can spawn the daemon); instead
use a non-starting check and fall back to local credential state. For the status
command, remove await ensureDaemonRunning() and call requestDaemonJson only if a
new helper like isDaemonRunning() returns true (or call a non-starting variant
of requestDaemonJson), otherwise report relay info from loadCredentials() as the
existing catch does. For the disable command, avoid calling
ensureDaemonRunning(): if the daemon is running call
requestDaemonJson("/relay/disable", { method: "POST" }), but if it is not
running update the local credential state (e.g., set creds.remoteEnabled = false
and persist) and print the disabled message without starting the daemon; update
references to ensureDaemonRunning, requestDaemonJson, loadCredentials, and the
remote disable/status command handlers accordingly.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aa97295c-5ec9-41ca-ba54-5967e897d032

📥 Commits

Reviewing files that changed from the base of the PR and between 7f86118 and 398128e.

⛔ Files ignored due to path filters (1)
  • relay/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (35)
  • .env.example
  • .gitignore
  • app/.env.example
  • app/app/(main)/_layout.tsx
  • app/app/_layout.tsx
  • app/app/cli-auth.tsx
  • app/app/index.tsx
  • app/app/landing.tsx
  • app/app/sign-in.tsx
  • app/app/sign-up.tsx
  • app/components/AuthMenu.tsx
  • app/components/RelayIndicator.tsx
  • app/components/TopBar.tsx
  • app/lib/api.ts
  • app/lib/env.ts
  • app/lib/envContract.js
  • app/lib/envRuntime.ts
  • app/lib/relay-transport.ts
  • app/stores/relay.ts
  • docs/deployment.md
  • relay/package.json
  • relay/src/auth.ts
  • relay/src/daemon-token.ts
  • relay/src/index.ts
  • relay/src/relay-object.ts
  • relay/src/types.ts
  • relay/tsconfig.json
  • relay/wrangler.toml
  • src/credentials.ts
  • src/daemon.test.ts
  • src/daemon.ts
  • src/login-flow.ts
  • src/main.ts
  • src/paths.ts
  • src/relay-client.ts

Comment thread app/app/(main)/_layout.tsx
Comment thread app/components/TopBar.tsx Outdated
Comment thread app/lib/api.ts
Comment thread docs/deployment.md Outdated
Comment thread relay/src/auth.ts Outdated
Comment thread relay/src/relay-object.ts
Comment thread src/daemon.ts
Comment thread src/daemon.ts Outdated
Comment thread src/login-flow.ts Outdated
Comment thread src/main.ts Outdated
@TraderSamwise

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@TraderSamwise

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

Copilot AI 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.

Pull request overview

Copilot reviewed 35 out of 37 changed files in this pull request and generated 3 comments.

Comment thread src/daemon.ts Outdated
Comment on lines +606 to +608
const url = new URL(req.url ?? "/", `http://${DAEMON_HOST}:${DAEMON_PORT}`);
const body = req.method === "POST" ? await readJson(req) : undefined;
const result = await this.routeRequest(req.method ?? "GET", url.pathname, body);
Comment thread app/lib/api.ts
Comment on lines +67 to +75
async function callServiceViaRelay<T>(
endpoint: ServiceEndpoint,
method: string,
path: string,
body?: unknown,
): Promise<T> {
const proxyPath = `/proxy/${endpoint.host}/${endpoint.port}${path}`;
return callDaemonViaRelay<T>(method, proxyPath, body);
}
Comment on lines 754 to 758
const repoRoot = mkdtempSync(join(tmpdir(), "aimux-session-migrate-"));
const targetRoot = mkdtempSync(join(tmpdir(), "aimux-session-migrate-target-"));
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
gitInit(repoRoot);
execFileSync("git", ["init"], { cwd: targetRoot, stdio: "ignore" });
await initPaths(repoRoot);

@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

♻️ Duplicate comments (6)
relay/src/auth.ts (1)

8-9: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Handle verifyToken return value correctly (returns { data, errors } not payload).

@clerk/backend's verifyToken returns Promise<JwtReturnType<JwtPayload, TokenVerificationError>>, which resolves to { data, errors } rather than a direct payload. The current code treats the result as a payload object, causing payload.sub to be undefined and allowing invalid tokens through.

🔒 Proposed fix
 export async function verifyWsToken(token: string, env: Env): Promise<string> {
   if (!env.CLERK_SECRET_KEY) {
     throw new Error("CLERK_SECRET_KEY not configured");
   }
-  const payload = await verifyToken(token, { secretKey: env.CLERK_SECRET_KEY });
-  return payload.sub;
+  const result = await verifyToken(token, { secretKey: env.CLERK_SECRET_KEY });
+  if (result.errors) {
+    throw new Error(`Token verification failed: ${result.errors[0]?.message || 'Unknown error'}`);
+  }
+  if (!result.data?.sub || typeof result.data.sub !== 'string') {
+    throw new Error("Invalid token: missing or invalid sub claim");
+  }
+  return result.data.sub;
 }
🤖 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 `@relay/src/auth.ts` around lines 8 - 9, The code incorrectly treats
verifyToken(...) as returning a payload; update the call to destructure its
result (const { data, errors } = await verifyToken(token, { secretKey:
env.CLERK_SECRET_KEY })) then validate: if errors exist or data is falsy, throw
or return an authentication failure; otherwise return data.sub. Apply this in
the function that calls verifyToken in relay/src/auth.ts, and include a clear
runtime check to avoid undefined sub values.
docs/deployment.md (1)

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

Add language identifiers to fenced code blocks.

These blocks are missing language tags and will keep MD040 warnings active.

Suggested patch
-```
+```dotenv
 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
 EXPO_PUBLIC_AIMUX_RELAY_URL=wss://relay.aimux.com

- +text
User's machine Cloud User's phone/laptop
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ aimux │───WS tunnel───│ Cloudflare │───WS tunnel───│ aimux app │
@@
Daemon: HS256 minted by relay App: Clerk session JWT

Also applies to: 121-133

🤖 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 `@docs/deployment.md` around lines 69 - 72, Update the fenced code blocks in
the documentation to include language identifiers so MD040 warnings are
resolved: change the first environment variables block to use a dotenv tag
(e.g., replace ``` with ```dotenv) and the ASCII diagram block to use a text tag
(e.g., replace ``` with ```text); apply the same change for the second
occurrence noted around the other block (lines referenced in the review) so all
untagged triple-backtick blocks in the docs/deployment.md snippet have explicit
language identifiers.
relay/src/index.ts (1)

131-134: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid URL query auth for daemon WebSocket tokens.

Daemon tokens are long-lived credentials; keeping them in ?token= risks leakage via logs/traces. Prefer Authorization/Sec-WebSocket-Protocol for /daemon/connect, with query fallback only where required.

Suggested patch
-    const token = url.searchParams.get("token");
+    const authHeader = request.headers.get("authorization");
+    const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
+    const wsProtocol = request.headers.get("sec-websocket-protocol") ?? "";
+    const protoToken =
+      wsProtocol
+        .split(",")
+        .map((p) => p.trim())
+        .find((p) => p.startsWith("bearer."))?.slice("bearer.".length) ?? null;
+
+    const token =
+      url.pathname === "/daemon/connect"
+        ? (bearer ?? protoToken ?? url.searchParams.get("token"))
+        : url.searchParams.get("token");
🤖 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 `@relay/src/index.ts` around lines 131 - 134, The current handler extracts
daemon credentials from the URL query (const token =
url.searchParams.get("token")) which can leak long-lived tokens; change the auth
flow in the /daemon/connect handling to first read the token from the
Authorization header (Bearer) and then from Sec-WebSocket-Protocol before
falling back to url.searchParams.get("token") only if absolutely necessary,
update the auth-check branch that returns the corsResponse to use the new token
source, and ensure you do not log or include the raw token in any error
messages; look for the token variable and the corsResponse return path to
modify.
relay/src/relay-object.ts (3)

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

Fail displaced-daemon in-flight requests immediately.

When replacing daemon socket, pending requests for the old daemon can linger until TTL instead of failing fast.

Suggested patch
     if (role === "daemon") {
       if (this.daemonWs) {
+        for (const [id, entry] of this.pendingRequests) {
+          try {
+            entry.client.send(
+              JSON.stringify({
+                id,
+                type: "response",
+                status: 502,
+                body: { ok: false, error: "Daemon connection replaced" },
+              }),
+            );
+          } catch {}
+        }
+        this.pendingRequests.clear();
         try {
           this.send(this.daemonWs, { type: "error", message: "Replaced by new daemon connection" });
           this.daemonWs.close(1000, "Replaced");
         } catch {}
       }

Also applies to: 156-177

🤖 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 `@relay/src/relay-object.ts` around lines 28 - 37, When replacing the daemon
WebSocket (daemonWs) you must immediately fail any in-flight requests that were
targeted at the old daemon instead of letting them linger; locate the code in
the block that handles role === "daemon" where daemonWs is replaced, and after
sending the "Replaced by new daemon connection" message and closing the old
socket, iterate your in-flight request tracking structure (e.g.,
pendingDaemonRequests, requestsMap, or similar) and invoke the corresponding
error/resolution callbacks with a clear error (e.g., "daemon replaced") and
clear any TTL/timeouts for each entry; ensure the new daemonWs is set and the
new connected message is sent (send(server, { type: "connected", role: "daemon"
})) and that broadcastToClients({ type: "daemon_status", online: true }) remains
unchanged.

70-87: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Scope in-flight request keys per socket (or relay-generated id).

Using a single global key parsed.id allows collisions between concurrent clients and can route responses to the wrong requester.

🤖 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 `@relay/src/relay-object.ts` around lines 70 - 87, The current logic uses
parsed.id as a global key causing collisions; instead generate and use a
relay-scoped id when forwarding requests and store pendingRequests under that id
tied to the originating socket. In the branch where !isDaemon and parsed.type
=== "request" (symbols: parsed, ws, daemonWs, PENDING_REQUEST_TTL_MS,
pendingRequests), create a unique relayId (e.g., incrementing counter or UUID),
set parsed.id = relayId before sending to the daemon, and store
pendingRequests.set(relayId, { client: ws, expiresAt: Date.now() +
PENDING_REQUEST_TTL_MS }). In the isDaemon && parsed.type === "response" branch
(symbols: isDaemon, parsed, sweepExpiredPending, pendingRequests), look up
pendingRequests by the relayId in parsed.id, forward the response to the stored
client, and delete the pendingRequests entry; keep sweepExpiredPending and TTL
logic unchanged.

10-14: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rehydrate socket state after Durable Object wake.

daemonWs/clientSockets are never rebuilt from live sockets, so post-hibernation routing can degrade to false “daemon not connected” until reconnect.

Suggested direction
 export class RelayObject extends DurableObject<Env> {
   private daemonWs: WebSocket | null = null;
   private clientSockets = new Set<WebSocket>();
   private pendingRequests = new Map<string, { client: WebSocket; expiresAt: number }>();
+
+  constructor(ctx: DurableObjectState, env: Env) {
+    super(ctx, env);
+    for (const ws of this.ctx.getWebSockets()) {
+      const tags = this.ctx.getTags(ws);
+      if (tags.includes("daemon")) this.daemonWs = ws;
+      else this.clientSockets.add(ws);
+    }
+  }

Also applies to: 15-47

🤖 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 `@relay/src/relay-object.ts` around lines 10 - 14, RelayObject currently loses
in-memory socket references after hibernation; persist minimal socket metadata
when opening/closing sockets (e.g., socket ids, role: daemon|client, pending
request ids with expiresAt) and, on Durable Object wake (in the RelayObject
constructor or on first fetch), read that metadata from this.state.storage and
rehydrate daemonWs, clientSockets, and pendingRequests by resolving persisted
socket ids to actual live WebSocket objects via a module-level registry or weak
map that tracks live WebSocket instances; ensure socket open/close handlers
update both the in-memory sets/maps (daemonWs, clientSockets, pendingRequests)
and the persisted metadata so routing remains correct after wake.
🧹 Nitpick comments (1)
.gitignore (1)

11-11: 💤 Low value

Remove duplicate .env entry.

Line 11 duplicates the .env entry already present at line 6. While harmless, removing it improves maintainability.

🤖 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 @.gitignore at line 11, Remove the duplicate ".env" entry from the .gitignore
file: keep a single ".env" line (the one already present) and delete the
redundant second ".env" line to avoid duplication and improve maintainability.
🤖 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 `@app/app/cli-auth.tsx`:
- Around line 88-91: The POST to `${base}/cli/issue-token` uses a plain fetch
that can hang; wrap it with an AbortController and a timeout to abort the
request after a short duration (e.g. 10s) and clear the timer on completion. In
the function containing the fetch (the block where `const res = await
fetch(`${base}/cli/issue-token`, { method: "POST", headers: { Authorization:
\`Bearer ${token}\` }, });` is declared) create an AbortController, pass its
signal to fetch, start a setTimeout that calls controller.abort(), and on
success or error clear the timeout; catch the abort (check for
DOMException/AbortError) and transition the component state out of `issuing`
into an error/timeout state so the UI won't remain stuck. Ensure the controller
is cleaned up to avoid leaks.
- Around line 70-73: When LOCAL_MODE is true the function currently sets
setError and setPhase and returns without notifying the CLI's localhost
callback, causing CLI commands to hang; update the LOCAL_MODE branch so that if
the parsed callback URL (e.g., callbackUrl or localhost callback variable used
elsewhere in this file) is present you POST a notification to it (await a
fetch/POST of JSON like {status:"error", message: "This deployment runs in local
mode — no remote login needed."}) before returning, then call setError and
setPhase as now; reference LOCAL_MODE, setError, setPhase and the
callbackUrl/localhost callback variable to find where to send the notification.

In `@relay/src/index.ts`:
- Around line 136-158: Enforce endpoint-specific token types: when handling the
connect route, check the request path/route and if the endpoint is
'/daemon/connect' require isDaemonToken(token), ensure env.RELAY_TOKEN_SECRET is
set, and call verifyDaemonToken(token, env.RELAY_TOKEN_SECRET!) to set userId;
if the endpoint is '/client/connect' require !isDaemonToken(token), ensure
env.CLERK_SECRET_KEY is set, and call verifyWsToken(token, env) to set userId;
if the token shape does not match the endpoint role return a 401 (or appropriate
error) rather than falling back to the other verifier; update the pre-checks
that currently gate secrets (env.RELAY_TOKEN_SECRET / env.CLERK_SECRET_KEY) to
be conditional on the endpoint so missing secrets produce 500 only for the
endpoint that needs them.

In `@relay/src/types.ts`:
- Around line 22-27: The RelayResponse interface requires a body but some HTTP
responses may be empty; update the interface RelayResponse to make body optional
(change body: unknown to body?: unknown) and then audit references to
RelayResponse (e.g., any functions expecting response.body) to handle undefined
safely (add guards or defaults where needed).

In `@src/credentials.ts`:
- Around line 38-40: The current empty catch around chmodSync(path, 0o600)
silently ignores failures; change it to surface and handle the error by catching
the exception, logging or including the original error and the target path, and
then rethrowing or returning a failure so callers know permission hardening
failed; specifically update the try/catch around chmodSync to catch (err) and
either throw a new Error(`chmod ${path} failed: ${err}`) or call the existing
logger with the error and propagate the failure to the caller.

In `@src/multiplexer/session-launch.test.ts`:
- Line 757: Replace the raw execFileSync("git", ["init"], { cwd: targetRoot,
stdio: "ignore" }) call with the existing gitInit helper to ensure both repos
are initialized with the same sanitized environment; specifically, locate the
initialization that uses targetRoot and call gitInit(targetRoot) (matching the
earlier gitInit(repoRoot) usage) and remove the direct execFileSync invocation
so both repositories use the helper.

---

Duplicate comments:
In `@docs/deployment.md`:
- Around line 69-72: Update the fenced code blocks in the documentation to
include language identifiers so MD040 warnings are resolved: change the first
environment variables block to use a dotenv tag (e.g., replace ``` with
```dotenv) and the ASCII diagram block to use a text tag (e.g., replace ``` with
```text); apply the same change for the second occurrence noted around the other
block (lines referenced in the review) so all untagged triple-backtick blocks in
the docs/deployment.md snippet have explicit language identifiers.

In `@relay/src/auth.ts`:
- Around line 8-9: The code incorrectly treats verifyToken(...) as returning a
payload; update the call to destructure its result (const { data, errors } =
await verifyToken(token, { secretKey: env.CLERK_SECRET_KEY })) then validate: if
errors exist or data is falsy, throw or return an authentication failure;
otherwise return data.sub. Apply this in the function that calls verifyToken in
relay/src/auth.ts, and include a clear runtime check to avoid undefined sub
values.

In `@relay/src/index.ts`:
- Around line 131-134: The current handler extracts daemon credentials from the
URL query (const token = url.searchParams.get("token")) which can leak
long-lived tokens; change the auth flow in the /daemon/connect handling to first
read the token from the Authorization header (Bearer) and then from
Sec-WebSocket-Protocol before falling back to url.searchParams.get("token") only
if absolutely necessary, update the auth-check branch that returns the
corsResponse to use the new token source, and ensure you do not log or include
the raw token in any error messages; look for the token variable and the
corsResponse return path to modify.

In `@relay/src/relay-object.ts`:
- Around line 28-37: When replacing the daemon WebSocket (daemonWs) you must
immediately fail any in-flight requests that were targeted at the old daemon
instead of letting them linger; locate the code in the block that handles role
=== "daemon" where daemonWs is replaced, and after sending the "Replaced by new
daemon connection" message and closing the old socket, iterate your in-flight
request tracking structure (e.g., pendingDaemonRequests, requestsMap, or
similar) and invoke the corresponding error/resolution callbacks with a clear
error (e.g., "daemon replaced") and clear any TTL/timeouts for each entry;
ensure the new daemonWs is set and the new connected message is sent
(send(server, { type: "connected", role: "daemon" })) and that
broadcastToClients({ type: "daemon_status", online: true }) remains unchanged.
- Around line 70-87: The current logic uses parsed.id as a global key causing
collisions; instead generate and use a relay-scoped id when forwarding requests
and store pendingRequests under that id tied to the originating socket. In the
branch where !isDaemon and parsed.type === "request" (symbols: parsed, ws,
daemonWs, PENDING_REQUEST_TTL_MS, pendingRequests), create a unique relayId
(e.g., incrementing counter or UUID), set parsed.id = relayId before sending to
the daemon, and store pendingRequests.set(relayId, { client: ws, expiresAt:
Date.now() + PENDING_REQUEST_TTL_MS }). In the isDaemon && parsed.type ===
"response" branch (symbols: isDaemon, parsed, sweepExpiredPending,
pendingRequests), look up pendingRequests by the relayId in parsed.id, forward
the response to the stored client, and delete the pendingRequests entry; keep
sweepExpiredPending and TTL logic unchanged.
- Around line 10-14: RelayObject currently loses in-memory socket references
after hibernation; persist minimal socket metadata when opening/closing sockets
(e.g., socket ids, role: daemon|client, pending request ids with expiresAt) and,
on Durable Object wake (in the RelayObject constructor or on first fetch), read
that metadata from this.state.storage and rehydrate daemonWs, clientSockets, and
pendingRequests by resolving persisted socket ids to actual live WebSocket
objects via a module-level registry or weak map that tracks live WebSocket
instances; ensure socket open/close handlers update both the in-memory sets/maps
(daemonWs, clientSockets, pendingRequests) and the persisted metadata so routing
remains correct after wake.

---

Nitpick comments:
In @.gitignore:
- Line 11: Remove the duplicate ".env" entry from the .gitignore file: keep a
single ".env" line (the one already present) and delete the redundant second
".env" line to avoid duplication and improve maintainability.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 24abed91-6e42-47bb-818b-ef20c01d9d06

📥 Commits

Reviewing files that changed from the base of the PR and between f679c88 and aa64792.

⛔ Files ignored due to path filters (1)
  • relay/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (36)
  • .env.example
  • .gitignore
  • app/.env.example
  • app/app/(main)/_layout.tsx
  • app/app/_layout.tsx
  • app/app/cli-auth.tsx
  • app/app/index.tsx
  • app/app/landing.tsx
  • app/app/sign-in.tsx
  • app/app/sign-up.tsx
  • app/components/AuthMenu.tsx
  • app/components/RelayIndicator.tsx
  • app/components/TopBar.tsx
  • app/lib/api.ts
  • app/lib/env.ts
  • app/lib/envContract.js
  • app/lib/envRuntime.ts
  • app/lib/relay-transport.ts
  • app/stores/relay.ts
  • docs/deployment.md
  • relay/package.json
  • relay/src/auth.ts
  • relay/src/daemon-token.ts
  • relay/src/index.ts
  • relay/src/relay-object.ts
  • relay/src/types.ts
  • relay/tsconfig.json
  • relay/wrangler.toml
  • src/credentials.ts
  • src/daemon.test.ts
  • src/daemon.ts
  • src/login-flow.ts
  • src/main.ts
  • src/multiplexer/session-launch.test.ts
  • src/paths.ts
  • src/relay-client.ts

Comment thread app/app/cli-auth.tsx
Comment thread app/app/cli-auth.tsx Outdated
Comment thread relay/src/index.ts
Comment thread relay/src/types.ts
Comment thread src/credentials.ts Outdated
Comment thread src/multiplexer/session-launch.test.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 (6)
relay/src/relay-object.ts (3)

81-87: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Request ID collision across client sessions.

pendingRequests is keyed by parsed.id alone. If two app sessions for the same user generate the same incrementing ID (r1, r2, etc.), the later request overwrites the earlier entry, causing misrouted or lost responses.

This matches the prior review feedback that was not addressed.

🤖 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 `@relay/src/relay-object.ts` around lines 81 - 87, pendingRequests is keyed
only by parsed.id which causes collisions across different client sessions;
change the key used in this.pendingRequests.set (and the corresponding lookup
when handling responses) to a composite key that includes a
client/session-specific identifier (for example combine parsed.id with a unique
per-connection id on the ws object or generate a UUID per request), so in the
branch where parsed.type === "request" and when responses are matched you use
the same compositeKey instead of parsed.id alone; update any code that reads
pendingRequests (response handling logic) to compute the same composite key for
lookup and preserve existing metadata like expiresAt and client.

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

Pending requests for displaced daemon not failed on replacement.

When a new daemon connects, the old daemon's socket is closed but pendingRequests entries targeting it are not failed. They'll wait until TTL expiry (60s) instead of failing immediately with a 502.

This matches the prior review feedback that was not addressed.

🤖 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 `@relay/src/relay-object.ts` around lines 28 - 37, When replacing the daemon
connection in the block that handles role === "daemon" (where daemonWs is
swapped), immediately fail any pendingRequests that target the old socket
instead of leaving them to TTL expiry: iterate the pendingRequests map/array,
find entries whose target/ws reference equals the old daemonWs, remove them from
pendingRequests, send each requester a failure response (HTTP 502 style message)
via send (or the same response path used for timeouts), and clean up any
associated timers/resolve/reject handlers; do this before assigning
this.daemonWs = server and broadcasting the new daemon status so no request
waits the 60s TTL.

10-14: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Durable Object state not rehydrated after hibernation wake.

After DO hibernation, daemonWs, clientSockets, and pendingRequests reset to initial values while WebSocket connections may still be alive via this.ctx.getWebSockets(). This causes existing clients to receive "Daemon not connected" errors until the daemon reconnects.

This matches the prior review feedback that was not addressed.

🤖 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 `@relay/src/relay-object.ts` around lines 10 - 14, The Durable Object loses
in-memory handles after hibernation because daemonWs, clientSockets and
pendingRequests are only in-memory; fix by persisting minimal metadata and
rehydrating from this.ctx.getWebSockets() and state storage on
wake/initialization: when accepting a WebSocket, store a small role/id record in
DurableObject state (e.g., mark sockets as "daemon" vs "client" and persist
pendingRequests entries with expiresAt), and in RelayObject initialization
(constructor or first fetch) call this.ctx.getWebSockets() to iterate current
live sockets and reassign the daemonWs reference and refill clientSockets Set
from those live socket objects, then reload pendingRequests from
this.state.storage and prune expired entries so the DO correctly reconnects
clients to the existing daemon socket after hibernation.
src/main.ts (2)

1406-1416: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

remote status spawns daemon unnecessarily for a read-only query.

The ensureDaemonRunning() call at line 1410 will start a daemon if none is running, which is surprising for a status check. The existing catch block already falls back to credential state, so the daemon spawn should be conditional.

This matches the prior review feedback that was not fully addressed.

Suggested fix
   .action(async (opts: { json?: boolean }) => {
     const creds = loadCredentials();
     let relay: unknown = { status: "off" };
-    try {
-      await ensureDaemonRunning();
-      const result = await requestDaemonJson("/relay/status");
-      relay = result.relay;
-    } catch {
-      // Daemon not running — fall back to credential state.
+    if (loadDaemonInfo()) {
+      try {
+        const result = await requestDaemonJson("/relay/status");
+        relay = result.relay;
+      } catch {
+        // Daemon not reachable — fall back to credential state.
+      }
     }
🤖 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 `@src/main.ts` around lines 1406 - 1416, The handler currently calls
ensureDaemonRunning() which can start the daemon for a read-only status query;
instead, remove or avoid calling ensureDaemonRunning() unconditionally and try
the non-spawning request first: call requestDaemonJson("/relay/status") inside
the try and only fall back to credential state on failure (or only call
ensureDaemonRunning() if you have an explicit start flag). Update the .action
handler to attempt requestDaemonJson(...) directly (using ensureDaemonRunning
only when intentionally starting the daemon) and keep the existing catch block
to use credential state when the daemon is not available.

1445-1452: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

remote disable spawns daemon unnecessarily.

Calling ensureDaemonRunning() before /relay/disable will start a daemon just to disable remote access on it. If the daemon isn't running, updating the local credential file (setting remoteEnabled: false) achieves the same goal without side effects.

This matches the prior review feedback that was not fully addressed.

Suggested fix
+import { clearCredentials, loadCredentials, setRemoteEnabled } from "./credentials.js";
 remoteCmd
   .command("disable")
   .description("Disable remote access and disconnect from the relay")
   .action(async () => {
-    await ensureDaemonRunning();
-    await requestDaemonJson("/relay/disable", { method: "POST" });
+    if (loadDaemonInfo()) {
+      try {
+        await requestDaemonJson("/relay/disable", { method: "POST" });
+      } catch {
+        // Daemon not reachable — update local state only.
+      }
+    }
+    setRemoteEnabled(false);
     console.log("✓ Remote access disabled. Daemon disconnected from relay.");
   });
🤖 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 `@src/main.ts` around lines 1445 - 1452, The current disable command always
calls ensureDaemonRunning() which can start the daemon unnecessarily; change
remoteCmd.command("disable") so it first checks whether the daemon is active
(use the existing isDaemonRunning() or equivalent status check) and only call
requestDaemonJson("/relay/disable", { method: "POST" }) if the daemon is
running; if the daemon is not running, do not call ensureDaemonRunning() or
requestDaemonJson—instead directly update the local credential/config file to
set remoteEnabled: false (use the project's existing credential write/update
helper) and print the same success message. Ensure you remove or guard the
ensureDaemonRunning() call in this command handler and keep console output
consistent.
src/login-flow.ts (1)

87-95: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard credential persistence failures in the callback handler.

saveCredentials() at line 87 can throw on file system errors, which would crash the process instead of rejecting the login promise gracefully.

This matches the prior review feedback that was not addressed.

Proposed fix
-      saveCredentials({
-        version: 1,
-        relayUrl,
-        token,
-        userId,
-        createdAt: new Date().toISOString(),
-        remoteEnabled: true,
-      });
-      resolve({ userId });
+      try {
+        saveCredentials({
+          version: 1,
+          relayUrl,
+          token,
+          userId,
+          createdAt: new Date().toISOString(),
+          remoteEnabled: true,
+        });
+        resolve({ userId });
+      } catch (err) {
+        reject(err instanceof Error ? err : new Error(String(err)));
+      }
🤖 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 `@src/login-flow.ts` around lines 87 - 95, saveCredentials(...) can throw and
will crash the process; wrap the call in a try/catch inside the login promise
callback and on failure log the error and call the promise's reject handler
instead of letting the exception propagate. Specifically, around
saveCredentials({ version: 1, relayUrl, token, userId, ... }) catch any error,
use processLogger.error(...) (or the available logger) to record details, then
call reject(err) (or otherwise reject the enclosing login promise) so the
handler returns a rejected promise rather than crashing; leave the existing
resolve({ userId }) only in the success path.
🧹 Nitpick comments (2)
.gitignore (1)

11-11: 💤 Low value

Duplicate .env entry.

The .env pattern is already present on line 6. Consider removing this duplicate to keep the file clean.

🤖 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 @.gitignore at line 11, Remove the duplicate `.env` entry in the .gitignore
file so the pattern appears only once; locate the second `.env` line and delete
it to keep the file clean and avoid redundant entries.
relay/tsconfig.json (1)

13-14: ⚡ Quick win

Conflicting TypeScript compiler options.

Setting both "declaration": true and "noEmit": true is contradictory. When noEmit is enabled, TypeScript skips all output including declaration files, so declaration has no effect. If the goal is type-checking only, remove "declaration": true for clarity.

🔧 Suggested fix
     "outDir": "dist",
     "rootDir": "src",
-    "declaration": true,
     "noEmit": 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 `@relay/tsconfig.json` around lines 13 - 14, The tsconfig contains conflicting
compiler options: "declaration": true and "noEmit": true; update the
configuration so they are not contradictory by either removing "declaration" if
you only want type-checking (keep "noEmit": true) or disabling "noEmit" if you
need declaration output (keep "declaration": true). Locate the entries
"declaration" and "noEmit" in the tsconfig JSON and make the appropriate change
so only the intended behavior remains.
🤖 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 `@relay/package.json`:
- Around line 11-18: The `@clerk/backend` dependency is pinned to a vulnerable
version "3.0.1" which falls into HIGH-severity ranges; update the dependency
entry for "`@clerk/backend`" to at least ">=3.2.14" (e.g., "3.2.14" or a caret
range "^3.2.14") in package.json to include both the Authz bypass and SSRF
patches, then run your package manager install and retest; also re-run the
vulnerability scan for "`@cloudflare/workers-types`", "typescript", and "wrangler"
to confirm no further advisories are present.

---

Duplicate comments:
In `@relay/src/relay-object.ts`:
- Around line 81-87: pendingRequests is keyed only by parsed.id which causes
collisions across different client sessions; change the key used in
this.pendingRequests.set (and the corresponding lookup when handling responses)
to a composite key that includes a client/session-specific identifier (for
example combine parsed.id with a unique per-connection id on the ws object or
generate a UUID per request), so in the branch where parsed.type === "request"
and when responses are matched you use the same compositeKey instead of
parsed.id alone; update any code that reads pendingRequests (response handling
logic) to compute the same composite key for lookup and preserve existing
metadata like expiresAt and client.
- Around line 28-37: When replacing the daemon connection in the block that
handles role === "daemon" (where daemonWs is swapped), immediately fail any
pendingRequests that target the old socket instead of leaving them to TTL
expiry: iterate the pendingRequests map/array, find entries whose target/ws
reference equals the old daemonWs, remove them from pendingRequests, send each
requester a failure response (HTTP 502 style message) via send (or the same
response path used for timeouts), and clean up any associated
timers/resolve/reject handlers; do this before assigning this.daemonWs = server
and broadcasting the new daemon status so no request waits the 60s TTL.
- Around line 10-14: The Durable Object loses in-memory handles after
hibernation because daemonWs, clientSockets and pendingRequests are only
in-memory; fix by persisting minimal metadata and rehydrating from
this.ctx.getWebSockets() and state storage on wake/initialization: when
accepting a WebSocket, store a small role/id record in DurableObject state
(e.g., mark sockets as "daemon" vs "client" and persist pendingRequests entries
with expiresAt), and in RelayObject initialization (constructor or first fetch)
call this.ctx.getWebSockets() to iterate current live sockets and reassign the
daemonWs reference and refill clientSockets Set from those live socket objects,
then reload pendingRequests from this.state.storage and prune expired entries so
the DO correctly reconnects clients to the existing daemon socket after
hibernation.

In `@src/login-flow.ts`:
- Around line 87-95: saveCredentials(...) can throw and will crash the process;
wrap the call in a try/catch inside the login promise callback and on failure
log the error and call the promise's reject handler instead of letting the
exception propagate. Specifically, around saveCredentials({ version: 1,
relayUrl, token, userId, ... }) catch any error, use processLogger.error(...)
(or the available logger) to record details, then call reject(err) (or otherwise
reject the enclosing login promise) so the handler returns a rejected promise
rather than crashing; leave the existing resolve({ userId }) only in the success
path.

In `@src/main.ts`:
- Around line 1406-1416: The handler currently calls ensureDaemonRunning() which
can start the daemon for a read-only status query; instead, remove or avoid
calling ensureDaemonRunning() unconditionally and try the non-spawning request
first: call requestDaemonJson("/relay/status") inside the try and only fall back
to credential state on failure (or only call ensureDaemonRunning() if you have
an explicit start flag). Update the .action handler to attempt
requestDaemonJson(...) directly (using ensureDaemonRunning only when
intentionally starting the daemon) and keep the existing catch block to use
credential state when the daemon is not available.
- Around line 1445-1452: The current disable command always calls
ensureDaemonRunning() which can start the daemon unnecessarily; change
remoteCmd.command("disable") so it first checks whether the daemon is active
(use the existing isDaemonRunning() or equivalent status check) and only call
requestDaemonJson("/relay/disable", { method: "POST" }) if the daemon is
running; if the daemon is not running, do not call ensureDaemonRunning() or
requestDaemonJson—instead directly update the local credential/config file to
set remoteEnabled: false (use the project's existing credential write/update
helper) and print the same success message. Ensure you remove or guard the
ensureDaemonRunning() call in this command handler and keep console output
consistent.

---

Nitpick comments:
In @.gitignore:
- Line 11: Remove the duplicate `.env` entry in the .gitignore file so the
pattern appears only once; locate the second `.env` line and delete it to keep
the file clean and avoid redundant entries.

In `@relay/tsconfig.json`:
- Around line 13-14: The tsconfig contains conflicting compiler options:
"declaration": true and "noEmit": true; update the configuration so they are not
contradictory by either removing "declaration" if you only want type-checking
(keep "noEmit": true) or disabling "noEmit" if you need declaration output (keep
"declaration": true). Locate the entries "declaration" and "noEmit" in the
tsconfig JSON and make the appropriate change so only the intended behavior
remains.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd5365dd-1269-4ce2-b318-bcb7f51e9621

📥 Commits

Reviewing files that changed from the base of the PR and between f679c88 and aa64792.

⛔ Files ignored due to path filters (1)
  • relay/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (36)
  • .env.example
  • .gitignore
  • app/.env.example
  • app/app/(main)/_layout.tsx
  • app/app/_layout.tsx
  • app/app/cli-auth.tsx
  • app/app/index.tsx
  • app/app/landing.tsx
  • app/app/sign-in.tsx
  • app/app/sign-up.tsx
  • app/components/AuthMenu.tsx
  • app/components/RelayIndicator.tsx
  • app/components/TopBar.tsx
  • app/lib/api.ts
  • app/lib/env.ts
  • app/lib/envContract.js
  • app/lib/envRuntime.ts
  • app/lib/relay-transport.ts
  • app/stores/relay.ts
  • docs/deployment.md
  • relay/package.json
  • relay/src/auth.ts
  • relay/src/daemon-token.ts
  • relay/src/index.ts
  • relay/src/relay-object.ts
  • relay/src/types.ts
  • relay/tsconfig.json
  • relay/wrangler.toml
  • src/credentials.ts
  • src/daemon.test.ts
  • src/daemon.ts
  • src/login-flow.ts
  • src/main.ts
  • src/multiplexer/session-launch.test.ts
  • src/paths.ts
  • src/relay-client.ts

Comment thread relay/package.json
@TraderSamwise

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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