Make dev servers shareable from other devices - #4489
Conversation
Browser dev is now single-origin: Vite proxies /api, /ws, /oauth, and /.well-known to the backend, and dev/dev:web no longer bake VITE_HTTP_URL/VITE_WS_URL into the bundle. Absolute localhost URLs in the bundle sent any remote browser to its own machine, which no amount of external path mapping could fix. - `dev --share` publishes the web port on the tailnet and logs a pairing URL already built against that origin - `dev:pair` mints a pairing URL for a running server, resolving port and state dir from server-runtime.json - Ports derive from the worktree path, so each worktree is stable across restarts; the availability probe is loopback-only so `tailscale serve` can't push the port - Web session cookies are port-scoped like desktop's, ending the cross-instance "Invalid session token signature" loop - Dev startup pairing tokens last 24h instead of 5m; user-issued links keep the 5m default Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis change adds deterministic worktree-based dev ports, tailnet sharing, live-server pairing URL recovery, single-origin browser proxying, port-scoped session cookies, and updated development documentation and tests. ChangesDev server sharing and pairing
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant DevRunner as dev-runner
participant Tailscale
participant Vite
participant Browser
participant Server
DevRunner->>Tailscale: publish local web port with tailscale serve
Tailscale-->>DevRunner: return tailnet host and HTTPS URL
DevRunner->>Vite: configure shared origin and allowed origins
Browser->>Vite: request application and proxied routes
Vite->>Server: proxy API, OAuth, websocket, and well-known requests
Server-->>Browser: return application responses
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
ApprovabilityVerdict: Needs human review This PR introduces significant new feature capability (dev server sharing via Tailscale) with auth-adjacent changes (cookie scoping, CORS). There is also an unresolved critical bug in dev-seed that could cause data loss when source and target directories are the same. You can customize Macroscope's approvability policy. Learn more. |
- `auth pairing url` resolved the state directory with the same flag heuristic the command exists to hide, so without --dev-url it read `userdata` while `bun run dev` writes to `dev`. It then minted into the wrong database and printed a URL the live server rejects with `invalid_credential`. It now searches both state directories and issues against whichever one the running server actually uses. - A server killed with SIGKILL leaves `server-runtime.json` behind, and that was taken as proof it was still up. Verify the recorded pid is alive before minting. - `--dry-run --share` replaced and then tore down the port's existing tailscale mapping. Dry run now returns before the share block. deriveServerPaths takes an optional explicit stateDir for callers that have already located a running server and must target it exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`findLiveServerRuntimeState` checked the configured state path first, which without --dev-url resolves to `userdata`. With both a userdata and a dev server running under the same base directory, `auth pairing url` would mint against the userdata server and print its origin — not the dev server this command exists to pair with. Rank candidates by the `devUrl` field instead, which only a server fronted by a web dev server records. Falls back to any live server when no dev server is running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
apps/server/src/http.ts (1)
55-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRoute
T3CODE_DEV_ALLOWED_ORIGINSthroughServerConfiginstead of rawprocess.env.This function already yields
ServerConfig.ServerConfigfordevOrigin; readingprocess.env.T3CODE_DEV_ALLOWED_ORIGINSdirectly alongside it breaks the config-injection pattern used everywhere else (e.g.devUrl,tailscaleServeEnabled), making this harder to override in tests that injectServerConfigvia layers.🤖 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 `@apps/server/src/http.ts` around lines 55 - 76, Update browserApiCorsLayer to read T3CODE_DEV_ALLOWED_ORIGINS from the yielded ServerConfig.ServerConfig instance instead of process.env. Add or use the corresponding ServerConfig field, preserving the existing trimming, filtering, and allowedOrigins behavior so injected configuration layers can override it in tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.agents/skills/test-t3-app/SKILL.md:
- Around line 82-85: Update the pairing-URL guidance in the skill instructions
to explicitly allow sharing the one-time URL in the response when the user
explicitly requests access, while prohibiting its inclusion in screenshots,
committed files, or durable logs. Reconcile this exception with the existing
prohibition so the intended behavior is unambiguous.
In `@apps/server/src/cli/auth.ts`:
- Around line 142-228: 添加针对 findLiveServerRuntimeState 和 pairingUrlCommand
的聚焦测试,覆盖无存活候选、从 dev 与 userdata 候选中选择正确运行时状态,以及 devUrl 存在时优先使用它、否则回退到 origin
的分支。将测试放在现有 CLI/服务器运行时状态测试约定的位置,并确保相关测试命令会执行这些用例。
In `@docs/reference/scripts.md`:
- Around line 47-50: Qualify the worktree port-stability wording in
docs/reference/scripts.md lines 47-50: replace the claim that worktrees do not
collide with siblings, state that the hash provides a stable preferred pair when
available, and emphasize using the resolved values printed on the “[dev-runner]”
line. Apply the same “stable when available, not guaranteed unique” wording to
the worktree port description in .agents/skills/test-t3-app/SKILL.md lines
21-24.
In `@scripts/lib/dev-share.ts`:
- Around line 148-173: Update shareDevServer so a failed new serve does not
silently discard an existing working mapping: either defer unshareDevServer
until the replacement serve is ready, or preserve and restore the prior mapping
when serve fails. If restoration is not possible, include that the previous
mapping was removed in the DevShareError detail.
---
Nitpick comments:
In `@apps/server/src/http.ts`:
- Around line 55-76: Update browserApiCorsLayer to read
T3CODE_DEV_ALLOWED_ORIGINS from the yielded ServerConfig.ServerConfig instance
instead of process.env. Add or use the corresponding ServerConfig field,
preserving the existing trimming, filtering, and allowedOrigins behavior so
injected configuration layers can override it in tests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 66754ed0-b428-4310-a2bc-a7839a1977b1
📒 Files selected for processing (20)
.agents/skills/test-t3-app/SKILL.mdAGENTS.mdapps/server/src/auth/EnvironmentAuth.test.tsapps/server/src/auth/EnvironmentAuth.tsapps/server/src/auth/EnvironmentAuthPolicy.test.tsapps/server/src/auth/EnvironmentAuthPolicy.tsapps/server/src/auth/PairingGrantStore.tsapps/server/src/auth/SessionStore.tsapps/server/src/auth/utils.tsapps/server/src/cli/auth.tsapps/server/src/config.tsapps/server/src/http.tsapps/server/src/serverRuntimeState.test.tsapps/server/src/serverRuntimeState.tsapps/web/vite.config.tsdocs/reference/scripts.mdpackage.jsonscripts/dev-runner.test.tsscripts/dev-runner.tsscripts/lib/dev-share.ts
- The skill told agents both to never put a pairing URL in a response and to hand the shared one to the user. State the rule and its single exception explicitly: give the URL to the person who asked for access, never put it anywhere durable. - A failed `tailscale serve` left the port unshared, because the stale- mapping clear had already run. The clear is still required (old mappings carry path routes that serving "/" would not replace), so say so in the error instead of letting an operator assume the previous mapping survived. - Worktree-derived ports were described as collision-free. They are a preferred offset, not a guarantee — hashes can collide and occupied ports shift both values. Qualified in AGENTS.md, the skill, and docs/reference/scripts.md. CodeRabbit also asked for tests around the pairing-url command; those landed in 0555604, which it had not yet seen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`unshareDevServer` applied Effect.ignore, so a failed removal was indistinguishable from a successful one. `shareDevServer` then served over routes it had not removed: stale path entries from the older `tsdev t3` layout (/ws, /api pointing at a separate backend port) would survive, producing a URL that loads while its API calls resolve to a dead port. The serve-failed message also asserted the port was cleared without knowing it. It now reports whether the port is clear, and sharing refuses when it is not. `tailscale serve … off` exits nonzero with "handler does not exist" when nothing was mapped — the normal first-share case — so that counts as cleared; anything else does not. The dev-runner finalizer warns, with the command to run, when cleanup does not take. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dev server started in a worktree resolved its data directory to the shared `~/.t3`. Worse, an ambient `T3CODE_HOME` counts as an *explicit* base dir, which flips the state directory from `<base>/dev` to `<base>/userdata` — the database the user's installed T3 Code is actively running against. Feature work in a throwaway branch was sharing a database with the user's real app, and its `server-runtime.json` overwrote the live server's entry. Worktrees now default to their own gitignored `.t3`, outranking the ambient env var; `--home-dir` still wins, and the main checkout keeps `~/.t3/dev`. `auth pairing url` applies the same default, so `dev:pair` from a worktree can no longer mint a credential into the real database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An isolated dev database starts empty, which makes any list- or thread-shaped UI impossible to look at without hand-writing fixtures. `dev:seed` copies the newest threads and their projects out of the shared home into the worktree's own database. Projections only — `orchestration_events` is never copied. The projector cursor is exclusive, so an empty event log means bootstrap streams nothing and leaves the copied rows alone; a partial event range is the real hazard, since the projector would replay a tail whose creating events are missing. Details that matter for a usable copy: - writes all nine projection_state rows, or computeSnapshotSequence reports 0 for every shell snapshot - forces sessions to stopped with no active turn, since a copied "running" session has no agent behind it and the reaper skips anything with an active turn - zeroes pending approval/input counts, as approvals are not copied - copies the intersection of columns, because the two databases are routinely on different migrations - refuses to write to the shared home Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 18aadef. Configure here.
Four parallel cleanup reviews over the branch; the fixes that survived verification: - scripts/lib/dev-share.ts was a 216-line reimplementation of @t3tools/tailscale (the client the server's own --tailscale-serve uses). Now a thin wrapper adding only the dev-share semantics: the stale-mapping pre-clear and the cleared/not-cleared result. Picks up Windows tailscale.exe handling and command timeouts for free. The package's exit error gains a bounded stderrPreview so callers can recognize "handler does not exist" without a parallel spawner. - The .git-is-a-file worktree check existed three times across two packages; now once in @t3tools/shared/devHome. dev-runner drops its duplicated T3CODE_HOME fallback so base-dir precedence lives in one function. - The dev proxied-prefix list existed in apps/web's vite config and apps/server's http catch-all with nothing keeping them in sync; both now consume @t3tools/shared/devProxy. The vite proxy map is built from the list. - PROJECTOR_NAMES and the projection-table delete order were pasted in both seeding scripts; now shared via scripts/lib/projection-tables. - PairingGrantStore keyed the dev startup TTL by string-comparing the caller's subject; issueOneTimeToken takes an explicit purpose: "startup" instead, and the subject constant returns to EnvironmentAuth where its other uses live. - dev-seed: iterate rows instead of materializing (unbounded copies held the whole table in memory and the spread had a ~128k-element ceiling); parameterized IN lists replace hand-rolled quoting. - Smaller: cli/auth's layer stack deduped into environmentAuthLayer, Set-based dedupe, precomputed proxied prefixes, vite HOST parsed once, dead T3CODE_DEV_SHARE_URL removed, redundant tests dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if (canonicalTarget === canonicalShared) { | ||
| return yield* new DevSeedTargetError({ reason: "shared-home", detail: targetBaseDir }); | ||
| } |
There was a problem hiding this comment.
🔴 Critical scripts/dev-seed.ts:107
When --from and --to resolve to the same directory, the command opens the same SQLite database as both source and target. seedDevDatabase then deletes every projection table from the target before copying, so it wipes the very data it was supposed to copy — projection data is permanently lost. The safety check at line 107 only guards targetBaseDir === sharedHome; it never checks sourceBaseDir === targetBaseDir. Add a guard that rejects when the source and target paths are identical.
if (canonicalTarget === canonicalShared) {
return yield* new DevSeedTargetError({ reason: "shared-home", detail: targetBaseDir });
}
+ if (sourceBaseDir !== sharedHome) {
+ const canonicalSource = yield* fileSystem
+ .realPath(sourceBaseDir)
+ .pipe(Effect.orElseSucceed(() => sourceBaseDir));
+ if (canonicalSource === canonicalTarget) {
+ return yield* new DevSeedTargetError({ reason: "shared-home", detail: sourceBaseDir });
+ }
+ }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/dev-seed.ts around lines 107-109:
When `--from` and `--to` resolve to the same directory, the command opens the same SQLite database as both source and target. `seedDevDatabase` then deletes every projection table from the target before copying, so it wipes the very data it was supposed to copy — projection data is permanently lost. The safety check at line 107 only guards `targetBaseDir === sharedHome`; it never checks `sourceBaseDir === targetBaseDir`. Add a guard that rejects when the source and target paths are identical.
- dev:seed wrote projector cursors at 1..9 while leaving the target's event log intact. The cursor is exclusive and a fresh log restarts at sequence 1, so every projector would skip a different slice of the user's first real events; the retained events would also replay over the copied projections and resurrect the target's deleted threads. All nine cursors now start at 0, and the event log and command receipts are emptied with the projections. - The thread-selection query referenced archived_at and latest_user_message_at unconditionally, so a source older than migrations 017/023 threw before copying anything. It now builds its filter and ordering from the columns the source actually has, matching how the rest of the copy handles drift. A table missing from the target is likewise skipped rather than aborting the seed. - --threads/--activities accepted negatives, which SQLite reads as "no limit" — a capped copy silently became a full-table one. Both now require a positive integer. - resolveGitWorktreePath only looked at cwd, so running dev:pair from a subdirectory fell through to the shared home. It walks up to the repository root now. - resolveWorktreeT3Home no longer returns undefined when .t3 is absent: inside a worktree that is the answer, and falling back to the shared home is precisely the accident the default prevents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Sharing a dev server so it can be opened from a phone or another laptop took a full session of workarounds and still ended up hand-launching processes. This fixes the causes.
Browser dev is now single-origin: Vite proxies
/api,/ws,/oauth, and/.well-knownto the backend, anddev/dev:webno longer setVITE_HTTP_URL/VITE_WS_URL. Those were compiled into the bundle, so any browser not on the dev machine dialed its own localhost — no amount of external path mapping could fix it.dev:desktopstill sets them; its renderer talks to the backend directly.dev --share(bun run dev:share) publishes the web port on the tailnet and logs a pairing URL already built against that origin. The mapping is removed on exit, and re-running clears a stale one.bun run dev:pairmints a pairing URL for an already-running server, resolving port and state dir fromserver-runtime.json— no flags to get wrong. The previously documentedauth pairing createsnippet passed--base-dir, which points the CLI at a different SQLite file than the server reads.tailscale servepush the dev server to another port mid-setup.t3_session_<port>) like desktop's. Cookies ignore ports, so servers sharing a hostname clobbered each other into a permanentInvalid session token signatureloop.allowedHostscovers.ts.net;T3CODE_DEV_ALLOWED_HOSTS/T3CODE_DEV_ALLOWED_ORIGINShandle anything else.Testing
Ran over a real tailnet:
/,/pair,/.well-known/t3/environment, and/api/auth/sessionall 200;/wsupgrade reaches the backend; token exchange returnedauthenticated: truewith cookiet3_session_<port>. Confirmed in SQLite that the dev startup token gets 24h while CLI-minted tokens stay at 5m, and that Ctrl+C removes the tailnet mapping.Typecheck clean; 3,282 tests pass across
t3,@t3tools/web, andscripts.Note
Not addressed here:
vp's child processes can outlive the dev-runner parent on interrupt and keep holding ports. Pre-existing, and separate from this change.🤖 Generated with Claude Code
Note
Medium Risk
Port-scoped
t3_session_<port>invalidates existing web cookie sessions; seeding overwrites dev projection tables and auth/pairing behavior changes affect local dev workflows.Overview
Makes browser dev single-origin so a shared tailnet/LAN URL works:
dev/dev:webstop settingVITE_HTTP_URL/VITE_WS_URL, Vite proxies/api,/ws,/oauth, and/.well-knownvia a shared prefix list, and HMR only pins toHOSTwhen set (e.g. desktop).Adds
dev --share/dev:share(Tailscale HTTPS on the web port, pairing URL against that origin, mapping cleared on exit) anddev:pair(auth pairing url), which locates a live server fromserver-runtime.json(bothdev/userdata, PID check) and mints a link against recordeddevUrlor origin. Worktrees default dev state to gitignored.t3, ahead of ambientT3CODE_HOME; ports prefer a worktree-hashed offset with loopback-only availability checks.Auth/dev ergonomics: session cookies are always
t3_session_<port>(web sessions reset after upgrade); startup pairing tokens use a 24h TTL whendevUrlis set;T3CODE_DEV_ALLOWED_HOSTS/T3CODE_DEV_ALLOWED_ORIGINSsupport non-localhost access.dev:seedcopies recent projection data from~/.t3into an isolated dev DB (schema drift tolerant, event log cleared, refuses the shared home).Reviewed by Cursor Bugbot for commit dda3515. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Make dev servers shareable from other devices via Tailscale
--shareflag to the dev runner that publishes the web port on the tailnet viashareDevServer, prints a tailnet HTTPS URL, and cleans up the mapping on exit.dev:share,dev:pair, anddev:seednpm scripts;dev:pairprints a fresh pairing URL from the running server'sserver-runtime.json.dev-seedCLI (scripts/dev-seed.ts) to copy recent threads and projection data from the shared~/.t3home into an isolated worktree.t3, with configurable thread/activity limits and schema drift tolerance.t3_session_<port>) across all server modes to prevent conflicts between concurrent dev servers.devUrlis configured now use a 24-hour TTL instead of the default short TTL.devanddev:webmodes,VITE_HTTP_URLandVITE_WS_URLare left unset; the browser resolves the backend viawindow.location.origin, and Vite proxies/api,/oauth,/.well-known, and/wsto the backend.Hostheaders ending in.ts.netand any hosts inT3CODE_DEV_ALLOWED_HOSTS, enabling access over Tailnet.t3_session(unsuffixed) is no longer produced; any client storing the old cookie name will lose its session.Macroscope summarized dda3515.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation