authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-26 20:26:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-26 20:26:21-07:00
log89c5a192f4d36bcf4201e8b490ab5ad37a4adac7
tree1ac57c3df16c0820518085bdf8caed1d32b181cf
parent23b55022175e69938514934f65c5a607d38f1e47
signature Signed by PGP key B5690EEEBB952194

fix(dev): skip browser-blocked ports (#4608)


5 files changed, 76 insertions(+), 1 deletions(-)

.agents/skills/test-t3-app/SKILL.md+6
......@@ -26,6 +26,12 @@ Shared browser dev is single-origin: Vite proxies the backend paths, so never se
2626
2727The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.
2828
29### Verify a shared environment before human handoff
30
31When another person will use the printed pairing URL, first open the shared origin without the pairing path or fragment in the controlled browser and confirm the T3 Code app loads. This browser navigation is required even when curl succeeds because browsers block some otherwise reachable ports before making a network request.
32
33Do not open the other person's complete pairing URL during this reachability check; doing so consumes its one-time token. If the agent also needs an authenticated browser, create and consume a separate pairing token, then leave a fresh token for the other person.
34
2935## Preserve the environment while iterating
3036
3137Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary.
AGENTS.md+1
......@@ -19,6 +19,7 @@
1919- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet.
2020- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.
2121- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them.
22- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports.
2223
2324## Package Roles
2425
docs/reference/scripts.md+1-1
......@@ -43,7 +43,7 @@
4343
4444`dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the server, allowing the same bundle to work from localhost or a tailnet hostname.
4545
46Worktrees derive a preferred port offset from their path. The runner shifts both ports together when either is occupied, so treat the `[dev-runner]` output as authoritative.
46Worktrees derive a preferred port offset from their path. The runner shifts both ports together when either is occupied or the web port is blocked by browsers, so treat the `[dev-runner]` output as authoritative.
4747
4848## Running multiple dev instances
4949
scripts/dev-runner.test.ts+49
......@@ -25,6 +25,7 @@ import {
2525 devPortProbeHosts,
2626 findFirstAvailableOffset,
2727 getDevRunnerModeArgs,
28 isBrowserAllowedPort,
2829 resolveModePortOffsets,
2930 resolveOffset,
3031 runDevRunnerWithInput,
......@@ -547,6 +548,41 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
547548 }),
548549 );
549550
551 it.effect("skips browser-blocked web ports before probing availability", () =>
552 Effect.gen(function* () {
553 const probed: Array<{ port: number; role: string | undefined }> = [];
554 const offset = yield* findFirstAvailableOffset({
555 // 5733 + 833 = 6566, which browsers block as sane-port.
556 startOffset: 833,
557 requireServerPort: true,
558 requireWebPort: true,
559 checkPortAvailability: (port, role) => {
560 probed.push({ port, role });
561 return Effect.succeed(true);
562 },
563 });
564
565 assert.equal(offset, 834);
566 assert.deepStrictEqual(probed, [
567 { port: 14_607, role: "server" },
568 { port: 6567, role: "web" },
569 ]);
570 }),
571 );
572
573 it.effect("does not reject a server-only offset because its unused web port is blocked", () =>
574 Effect.gen(function* () {
575 const offset = yield* findFirstAvailableOffset({
576 startOffset: 833,
577 requireServerPort: true,
578 requireWebPort: false,
579 checkPortAvailability: () => Effect.succeed(true),
580 });
581
582 assert.equal(offset, 833);
583 }),
584 );
585
550586 it.effect("allows offsets where the non-required server port exceeds max", () =>
551587 Effect.gen(function* () {
552588 const offset = yield* findFirstAvailableOffset({
......@@ -583,6 +619,19 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
583619 );
584620 });
585621
622 describe("isBrowserAllowedPort", () => {
623 it.each([6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697])(
624 "rejects Fetch-blocked web port %s from the worktree offset range",
625 (port) => {
626 assert.equal(isBrowserAllowedPort(port), false);
627 },
628 );
629
630 it.each([5733, 5900, 6567, 6670, 8733])("allows browser-safe web port %s", (port) => {
631 assert.equal(isBrowserAllowedPort(port), true);
632 });
633 });
634
586635 describe("checkPortAvailabilityOnHosts", () => {
587636 it.effect("checks overlapping hosts sequentially to avoid self-interference", () =>
588637 Effect.gen(function* () {
scripts/dev-runner.ts+19
......@@ -29,6 +29,17 @@ const BASE_WEB_PORT = 5733;
2929const MAX_HASH_OFFSET = 3000;
3030const MAX_PORT = 65535;
3131const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
32// HTTP(S) requests to these ports are blocked by the Fetch standard before a
33// browser reaches the network. Keep the complete list here so explicit or
34// future wider offsets cannot produce a URL that curl accepts but browsers
35// reject. https://fetch.spec.whatwg.org/#port-blocking
36const FETCH_BAD_PORTS = new Set([
37 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
38 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
39 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
40 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
41 6669, 6679, 6697, 10080,
42]);
3243// Dev servers bind loopback, so loopback is the only interface whose
3344// availability decides whether we can use a port. Probing wildcards too made
3445// the runner walk away from a perfectly free port whenever something else held
......@@ -91,6 +102,10 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> {
91102 return MODE_ARGS[mode];
92103}
93104
105export function isBrowserAllowedPort(port: number): boolean {
106 return !FETCH_BAD_PORTS.has(port);
107}
108
94109export class DevRunnerConfigurationError extends Schema.TaggedErrorClass<DevRunnerConfigurationError>()(
95110 "DevRunnerConfigurationError",
96111 {
......@@ -498,6 +513,10 @@ export function findFirstAvailableOffset<R = NetService.NetService>({
498513 break;
499514 }
500515
516 if (requireWebPort && !isBrowserAllowedPort(webPort)) {
517 continue;
518 }
519
501520 const checks: Array<Effect.Effect<boolean, never, R>> = [];
502521 if (requireServerPort) {
503522 checks.push(checkPort(serverPort, "server"));