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...@@ -26,6 +26,12 @@ Shared browser dev is single-origin: Vite proxies the backend paths, so never se
2626
27The 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.27The 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
29## Preserve the environment while iterating35## Preserve the environment while iterating
3036
31Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary.37Treat 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 @@...@@ -19,6 +19,7 @@
19- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet.19- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet.
20- 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`.20- 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`.
21- 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.21- 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
23## Package Roles24## Package Roles
2425
docs/reference/scripts.md+1-1
...@@ -43,7 +43,7 @@...@@ -43,7 +43,7 @@
4343
44`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.44`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
48## Running multiple dev instances48## Running multiple dev instances
4949
scripts/dev-runner.test.ts+49
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 devPortProbeHosts,25 devPortProbeHosts,
26 findFirstAvailableOffset,26 findFirstAvailableOffset,
27 getDevRunnerModeArgs,27 getDevRunnerModeArgs,
28 isBrowserAllowedPort,
28 resolveModePortOffsets,29 resolveModePortOffsets,
29 resolveOffset,30 resolveOffset,
30 runDevRunnerWithInput,31 runDevRunnerWithInput,
...@@ -547,6 +548,41 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -547,6 +548,41 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
547 }),548 }),
548 );549 );
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
550 it.effect("allows offsets where the non-required server port exceeds max", () =>586 it.effect("allows offsets where the non-required server port exceeds max", () =>
551 Effect.gen(function* () {587 Effect.gen(function* () {
552 const offset = yield* findFirstAvailableOffset({588 const offset = yield* findFirstAvailableOffset({
...@@ -583,6 +619,19 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -583,6 +619,19 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
583 );619 );
584 });620 });
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
586 describe("checkPortAvailabilityOnHosts", () => {635 describe("checkPortAvailabilityOnHosts", () => {
587 it.effect("checks overlapping hosts sequentially to avoid self-interference", () =>636 it.effect("checks overlapping hosts sequentially to avoid self-interference", () =>
588 Effect.gen(function* () {637 Effect.gen(function* () {
scripts/dev-runner.ts+19
...@@ -29,6 +29,17 @@ const BASE_WEB_PORT = 5733;...@@ -29,6 +29,17 @@ const BASE_WEB_PORT = 5733;
29const MAX_HASH_OFFSET = 3000;29const MAX_HASH_OFFSET = 3000;
30const MAX_PORT = 65535;30const MAX_PORT = 65535;
31const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";31const 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]);
32// Dev servers bind loopback, so loopback is the only interface whose43// Dev servers bind loopback, so loopback is the only interface whose
33// availability decides whether we can use a port. Probing wildcards too made44// availability decides whether we can use a port. Probing wildcards too made
34// the runner walk away from a perfectly free port whenever something else held45// the runner walk away from a perfectly free port whenever something else held
...@@ -91,6 +102,10 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> {...@@ -91,6 +102,10 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> {
91 return MODE_ARGS[mode];102 return MODE_ARGS[mode];
92}103}
93104
105export function isBrowserAllowedPort(port: number): boolean {
106 return !FETCH_BAD_PORTS.has(port);
107}
108
94export class DevRunnerConfigurationError extends Schema.TaggedErrorClass<DevRunnerConfigurationError>()(109export class DevRunnerConfigurationError extends Schema.TaggedErrorClass<DevRunnerConfigurationError>()(
95 "DevRunnerConfigurationError",110 "DevRunnerConfigurationError",
96 {111 {
...@@ -498,6 +513,10 @@ export function findFirstAvailableOffset<R = NetService.NetService>({...@@ -498,6 +513,10 @@ export function findFirstAvailableOffset<R = NetService.NetService>({
498 break;513 break;
499 }514 }
500515
516 if (requireWebPort && !isBrowserAllowedPort(webPort)) {
517 continue;
518 }
519
501 const checks: Array<Effect.Effect<boolean, never, R>> = [];520 const checks: Array<Effect.Effect<boolean, never, R>> = [];
502 if (requireServerPort) {521 if (requireServerPort) {
503 checks.push(checkPort(serverPort, "server"));522 checks.push(checkPort(serverPort, "server"));