Make browser dev shareable over Tailscale#4556
Conversation
🚥 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 New feature introducing Tailscale dev sharing with auth-adjacent changes (port-scoped session cookies, extended token TTL) and CORS modifications across multiple files. The scope and nature of changes warrant human review. You can customize Macroscope's approvability policy. Learn more. |
|
Took ownership of this PR, reviewed it, and pushed one fix. Fixed:
|
Desktop dev is not single-origin: the renderer gets VITE_HTTP_URL and VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI and then watch it dial its own 127.0.0.1 for the backend. Sharing also overwrote VITE_DEV_SERVER_URL, which is the origin Electron loads the renderer from. Warn and carry on serving locally instead of handing out a URL that is broken in a way the user cannot see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
edc8e4f to
dbad3bc
Compare
|
Rebased onto the updated base — #4555 picked up two new commits ( Resolution kept the base's improvements and adapted this PR to them:
45/45 tests passing (the base's 5 new tests merged in cleanly), typecheck 0 errors, and the runner still resolves the same stable offset ( |
Two findings from review. stderrPreview carried the first 200 chars of raw CLI stderr into DevShareError.detail and on into dev-runner's logs. tailscale prints auth keys (tskey-...) and node names to stderr, and the package's own tests already seed a token there and assert it does not leak — stderrPreview walked straight past that invariant. Replace it with stderrDiagnostic, a closed set of labels (no-existing-handler, not-logged-in, permission-denied, unknown); callers match on the label and render our own wording. Unrecognized stderr degrades to "unknown" rather than falling back to text. Port probing was narrowed to loopback, but --host/T3CODE_HOST moves the backend onto another interface, and that interface decides whether the bind succeeds. A port free on loopback and taken there was selected and then failed to bind. Probe the configured host alongside loopback. 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 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ce4664d. Configure here.
The runner deleted VITE_HTTP_URL/VITE_WS_URL for dev and dev:web, but vite.config.ts calls loadRepoEnv, which merges .env/.env.local underneath the process env. A developer with either URL in their .env got it back, and Vite baked it into the bundle — pinning the client to localhost and breaking every non-localhost origin. Invisible, too: the page still loads, then dials the visitor's own machine. Absence is not a usable signal, so state the intent: the runner sets T3CODE_SINGLE_ORIGIN_DEV=1, and Vite ignores both URLs when it is set. VITE_HTTP_URL is now pinned in `define` as well, since Vite's automatic VITE_ exposure would otherwise leak it past the check. Verified by A/B with a .env setting both URLs: the served module carries "http://localhost:13773" without this change and "" with it, proxying still 200. Also point the dev:desktop --share refusal at `dev` rather than `dev:web`, which starts no backend of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--host/T3CODE_HOST configures the backend. Vite takes its bind address from HOST, which the runner sets for desktop alone, so the web port stays on loopback. Probing it against the backend's interface could reject a port that was free where Vite would actually bind, shifting offsets or failing startup for no reason. The checker now takes the port's role. Passed explicitly rather than inferred from the port number, which stops discriminating once a large T3CODE_PORT_OFFSET pushes the web port past the server base. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Review round converged — CI green on Fixed
Declined, with reasoningRefusing Things I checked empirically rather than by reading
Still worth a human eyeTwo intentional behavior changes that will generate reports if they surprise people: session cookies are renamed to 65 tests, typecheck 0 errors, lint clean on changed files. Still babysitting. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
scripts/dev-runner.ts (3)
639-639: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRecomputes the web port instead of reusing
env.PORT.
createDevRunnerEnvalready derived the same value intoenv.PORT; duplicating theBASE_WEB_PORT + webOffsetarithmetic here is a second source of truth that will drift if web-port resolution ever changes.♻️ Suggested change
- const sharedWebPort = BASE_WEB_PORT + webOffset; + const sharedWebPort = Number(env.PORT);🤖 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 `@scripts/dev-runner.ts` at line 639, Update the code near sharedWebPort to reuse the resolved env.PORT value produced by createDevRunnerEnv instead of recomputing BASE_WEB_PORT + webOffset. Remove the duplicate arithmetic while preserving the existing shared web-port behavior.
295-324: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
T3CODE_PORTis now set identically in both branches.Lines 296 and 319 assign the same value; hoisting it above the
isDesktopModesplit would make the branch about the Vite URL/marker policy only.🤖 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 `@scripts/dev-runner.ts` around lines 295 - 324, Move the shared T3CODE_PORT assignment above the isDesktopMode conditional, then remove the duplicate assignments from both branches. Keep the existing Vite URL and T3CODE_SINGLE_ORIGIN_DEV handling unchanged so the split only controls URL and marker policy.
206-216: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueNumeric
T3CODE_DEV_INSTANCEbypasses the bounds applied elsewhere.Unlike
T3CODE_PORT_OFFSET(negative-checked) and the hashed path (% MAX_HASH_OFFSET), a numeric seed is used verbatim, soT3CODE_DEV_INSTANCE=90000resolves to an out-of-range port pair and surfaces asDevRunnerPortExhaustedErrorrather than a message naming the bad input. Consider validating it against the same offset range.🤖 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 `@scripts/dev-runner.ts` around lines 206 - 216, Update the numeric-seed branch in the dev-runner offset resolution to validate Number(seed) against the same allowed offset bounds used by the other paths, rejecting values outside that range with an error that identifies the invalid T3CODE_DEV_INSTANCE input; preserve the existing hashed behavior and valid numeric offsets.scripts/lib/dev-share.test.ts (1)
86-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider covering the
no-tailnet-name/tailscale-unavailablebranches.Both produce user-facing hints and neither is exercised — a status payload without
Self.DNSNameand a non-zerostatusexit would close the gap cheaply given the existingspawnerLayer.🤖 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 `@scripts/lib/dev-share.test.ts` around lines 86 - 161, Add tests in the shareDevServer suite for the no-tailnet-name and tailscale-unavailable branches: configure spawnerLayer with a successful status response whose payload omits Self.DNSName, and with a non-zero status exit respectively. Flip each effect and assert the resulting DevShareError reason and user-facing hint message.packages/tailscale/src/tailscale.ts (1)
252-254: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDuplicate
stderrDiagnosticOf(stderr)evaluation in both error constructions. Both sites call the classifier once for the presence check and again for the value; binding the result once makes the intent clearer and avoids re-running the regex list.
packages/tailscale/src/tailscale.ts#L252-L254: hoistconst stderrDiagnostic = stderrDiagnosticOf(stderr);before theTailscaleCommandExitErrorconstruction and spread...(stderrDiagnostic ? { stderrDiagnostic } : {}).packages/tailscale/src/tailscale.ts#L318-L320: apply the same hoist insiderunTailscaleCommand.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/tailscale/src/tailscale.ts` around lines 252 - 254, Hoist the result of stderrDiagnosticOf(stderr) into a local stderrDiagnostic before each TailscaleCommandExitError construction, then reuse it for the conditional stderrDiagnostic property. Apply this at packages/tailscale/src/tailscale.ts lines 252-254 and 318-320 within runTailscaleCommand, preserving the existing behavior when no diagnostic is available.
🤖 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 `@apps/server/src/auth/EnvironmentAuth.test.ts`:
- Around line 18-26: The makeServerConfigLayer helper currently allows overrides
to replace TEST_SERVER_PORT, which can desynchronize the server port and
makeCookieRequest’s fixed cookie name. Apply ...overrides before assigning port:
TEST_SERVER_PORT so the test port is always pinned while preserving all other
overrides.
In `@docs/reference/scripts.md`:
- Line 4: Update the documented base and sharing commands in the web-stack
startup instructions to use the required Bun workflow: replace the base command
with “bun run dev” and the sharing variant with “bun run dev:share”. Preserve
the existing descriptions of sharing behavior.
In `@packages/tailscale/src/tailscale.test.ts`:
- Around line 29-40: Update assertCarriesNoSecret to recursively inspect strings
inside nested objects, arrays, and causes, while preserving the message check.
Track visited objects with cycle protection so cyclic error structures terminate
safely, and retain field-specific context in assertion failures where practical.
---
Nitpick comments:
In `@packages/tailscale/src/tailscale.ts`:
- Around line 252-254: Hoist the result of stderrDiagnosticOf(stderr) into a
local stderrDiagnostic before each TailscaleCommandExitError construction, then
reuse it for the conditional stderrDiagnostic property. Apply this at
packages/tailscale/src/tailscale.ts lines 252-254 and 318-320 within
runTailscaleCommand, preserving the existing behavior when no diagnostic is
available.
In `@scripts/dev-runner.ts`:
- Line 639: Update the code near sharedWebPort to reuse the resolved env.PORT
value produced by createDevRunnerEnv instead of recomputing BASE_WEB_PORT +
webOffset. Remove the duplicate arithmetic while preserving the existing shared
web-port behavior.
- Around line 295-324: Move the shared T3CODE_PORT assignment above the
isDesktopMode conditional, then remove the duplicate assignments from both
branches. Keep the existing Vite URL and T3CODE_SINGLE_ORIGIN_DEV handling
unchanged so the split only controls URL and marker policy.
- Around line 206-216: Update the numeric-seed branch in the dev-runner offset
resolution to validate Number(seed) against the same allowed offset bounds used
by the other paths, rejecting values outside that range with an error that
identifies the invalid T3CODE_DEV_INSTANCE input; preserve the existing hashed
behavior and valid numeric offsets.
In `@scripts/lib/dev-share.test.ts`:
- Around line 86-161: Add tests in the shareDevServer suite for the
no-tailnet-name and tailscale-unavailable branches: configure spawnerLayer with
a successful status response whose payload omits Self.DNSName, and with a
non-zero status exit respectively. Flip each effect and assert the resulting
DevShareError reason and user-facing hint message.
🪄 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: 6650ded2-f1b8-41f0-88dd-2bcf360de729
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (22)
.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/http.tsapps/web/vite.config.tsdocs/reference/scripts.mdpackage.jsonpackages/shared/package.jsonpackages/shared/src/devProxy.tspackages/tailscale/src/tailscale.test.tspackages/tailscale/src/tailscale.tsscripts/dev-runner.test.tsscripts/dev-runner.tsscripts/lib/dev-share.test.tsscripts/lib/dev-share.tsscripts/package.json
Make assertCarriesNoSecret recurse. It checked only top-level strings, so a leak inside a nested cause or an array would have passed while the comment claimed coverage for fields added later. Verified the gap is real: the shallow version passes on both a nested cause and an array carrying the token, and the recursive one fails on each. Walks message/cause explicitly (getters on Error subclasses are not own enumerable props) and tracks visited objects so cyclic causes terminate. Pin port after overrides in makeServerConfigLayer. makeCookieRequest builds the cookie name from TEST_SERVER_PORT, so an override that changed the port would leave the server reading one cookie name while requests sent another. Use bun run in docs/reference/scripts.md for the dev and dev:share lines, matching AGENTS.md. Confirmed `bun run dev --share` forwards the flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing onto the updated #4556 turned up two doc drifts: the base moved the script list from `vp run` to `bun run`, and the state-directory paragraph still described the fixed `userdata` preference that the mtime tiebreak replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Effect service conventions check was failing on this, correctly. The single DevShareError violated four rules at once: a `reason` discriminator chose both the user-facing message and the caller's remedy, the `message` getter was a lookup table over it, `detail` copied `cause.message` and was then used to build that message, and the underlying TailscaleCommandError was discarded entirely so the error chain ended at the wrapper. Now three classes, one per genuinely distinct failure: TailscaleUnavailableError and TailnetNameMissingError each have one fixed message and one remedy; DevServeFailedError keeps a `stage` discriminator, which is legitimate — both stages share the same semantics (a serve invocation failed for this port) and differ only in which one. Each wrapping construction now preserves the immediate error as `cause`, and every message derives solely from structural fields. unshareDevServer returns the structured cause rather than a flattened string so its caller can do the same. TailnetNameMissingError has no cause because none exists — the status read succeeded and simply had no name. Verified the chain end to end: the wrapped TailscaleCommandExitError is reachable with exitCode and stderrDiagnostic intact, and the auth token in stderr still never reaches the message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Fixed the failing The check was right, and its finding had been sitting in an orphaned inline comment from an earlier commit — the force-pushes marked it outdated, so it never showed up in my unreplied-thread sweep. My fault for only ever checking live threads.
Now three classes. Every wrapping construction now keeps the immediate error as Verified the chain survives rather than assuming it: the wrapped 204 tests passing across |

What
dev:share/--sharesupport that publishes the web port with Tailscale and cleans up the mapping on exit.Why
Remote browsers could load the frontend but then try to connect to their own localhost for backend traffic. Sharing also needed the startup pairing URL, cookie policy, allowed origins, and Tailscale lifecycle to agree on the same public origin.
Impact
This is PR 2 of 3 and is stacked on #4555. Its diff is limited to single-origin browser hosting and Tailscale sharing.
Verification
Note
Add
--shareflag to expose the browser dev server over Tailscale--shareflag (anddev:sharenpm script) to the dev runner that publishes the Vite web server over Tailscale, registers cleanup on exit, and sets allowed CORS origins andVITE_DEV_SERVER_URLto the tailnet URL.shareDevServer/unshareDevServerin dev-share.ts with structured error types for Tailscale unavailability, missing tailnet name, and serve failures.dev/dev:webmodes to single-origin mode (T3CODE_SINGLE_ORIGIN_DEV=1), removing baked-inVITE_HTTP_URL/VITE_WS_URLand routing backend traffic through the Vite proxy instead.*.ts.nethosts, proxy a shared set of path prefixes fromDEV_PROXIED_PATH_PREFIXES, and derive HMR connection from page origin when no explicit host is set.stderrDiagnosticclassification toTailscaleCommandExitErrorand port-scoped session cookie names (t3_session_<port>) across all server modes.resolveSessionCookieNamenow always returns a port-suffixed name; clients previously relying ont3_sessionin web mode will receive a different cookie name.Macroscope summarized 2db512a.
Note
Medium Risk
Session cookie renaming invalidates existing dev sessions on upgrade; auth and Tailscale serve lifecycle changes affect how pairing and credentialed requests behave across origins.
Overview
Browser dev is now single-origin so the UI works from localhost, a tailnet hostname, or another device without baking in
localhostbackend URLs.dev/dev:webunsetVITE_HTTP_URLandVITE_WS_URL, setT3CODE_SINGLE_ORIGIN_DEV=1, and Vite proxies/api,/oauth,/.well-known, and/wsto the backend via a sharedDEV_PROXIED_PATH_PREFIXESlist; the server 404s those paths in dev instead of redirecting to Vite.bun run dev:share/--sharepublishes the web port withtailscale serve, wiresVITE_DEV_SERVER_URLandT3CODE_DEV_ALLOWED_ORIGINSto the HTTPS tailnet origin, and removes the mapping on exit (skipped fordev:desktopand--dry-run). A newscripts/lib/dev-sharelayer clears stale mappings before serving and surfaces safe Tailscale failure diagnostics instead of raw stderr.Auth and ports align with multi-instance and remote use: session cookies are always
t3_session_<port>(web included), startup pairing tokens get a 24h TTL when a dev URL is configured, worktrees get stable hashed port offsets, and port probes focus on loopback (plus the backend bind host for server-role checks) so Tailscale-held ports do not spuriously shift assignments.Reviewed by Cursor Bugbot for commit 2db512a. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
dev:share, publishing the web app over HTTPS on a tailnet.Bug Fixes
Documentation