diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 7bc0d86498a0b35ec048b17f09a15d6c3d20fe67..45524f6fcd330a4c13cc8f7739ba4bde03707784 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -26,6 +26,12 @@ Shared browser dev is single-origin: Vite proxies the backend paths, so never se The 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. +### Verify a shared environment before human handoff + +When 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. + +Do 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. + ## Preserve the environment while iterating Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary. diff --git a/AGENTS.md b/AGENTS.md index b6881156b84dd6d6a451dd6e9745a2feaaa79aa1..0e55e5946c258c39f03cef5a90a8fa350251e9bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ - Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. - 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`. - 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. +- 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. ## Package Roles diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index dac49a8825c229fe3864fd6d07ac0b56ed769020..3927adcfe295154a952056e638dad22dd9b87055 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -43,7 +43,7 @@ `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. -Worktrees 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. +Worktrees 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. ## Running multiple dev instances diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index c8cc75b0c5f7d2107041607e4af7c23d35555a9f..2ea3064c2a4783d1fa4a095f26b7c835dd5fb789 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -25,6 +25,7 @@ import { devPortProbeHosts, findFirstAvailableOffset, getDevRunnerModeArgs, + isBrowserAllowedPort, resolveModePortOffsets, resolveOffset, runDevRunnerWithInput, @@ -547,6 +548,41 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("skips browser-blocked web ports before probing availability", () => + Effect.gen(function* () { + const probed: Array<{ port: number; role: string | undefined }> = []; + const offset = yield* findFirstAvailableOffset({ + // 5733 + 833 = 6566, which browsers block as sane-port. + startOffset: 833, + requireServerPort: true, + requireWebPort: true, + checkPortAvailability: (port, role) => { + probed.push({ port, role }); + return Effect.succeed(true); + }, + }); + + assert.equal(offset, 834); + assert.deepStrictEqual(probed, [ + { port: 14_607, role: "server" }, + { port: 6567, role: "web" }, + ]); + }), + ); + + it.effect("does not reject a server-only offset because its unused web port is blocked", () => + Effect.gen(function* () { + const offset = yield* findFirstAvailableOffset({ + startOffset: 833, + requireServerPort: true, + requireWebPort: false, + checkPortAvailability: () => Effect.succeed(true), + }); + + assert.equal(offset, 833); + }), + ); + it.effect("allows offsets where the non-required server port exceeds max", () => Effect.gen(function* () { const offset = yield* findFirstAvailableOffset({ @@ -583,6 +619,19 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); }); + describe("isBrowserAllowedPort", () => { + it.each([6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697])( + "rejects Fetch-blocked web port %s from the worktree offset range", + (port) => { + assert.equal(isBrowserAllowedPort(port), false); + }, + ); + + it.each([5733, 5900, 6567, 6670, 8733])("allows browser-safe web port %s", (port) => { + assert.equal(isBrowserAllowedPort(port), true); + }); + }); + describe("checkPortAvailabilityOnHosts", () => { it.effect("checks overlapping hosts sequentially to avoid self-interference", () => Effect.gen(function* () { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 976951dc543df2042d3b6ea0b1b2a9bbc34556e8..cb4e3f74c4f3c68f3e5ec8612fadc61a9949b9b3 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -29,6 +29,17 @@ const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; +// HTTP(S) requests to these ports are blocked by the Fetch standard before a +// browser reaches the network. Keep the complete list here so explicit or +// future wider offsets cannot produce a URL that curl accepts but browsers +// reject. https://fetch.spec.whatwg.org/#port-blocking +const FETCH_BAD_PORTS = new Set([ + 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, + 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, + 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, + 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, + 6669, 6679, 6697, 10080, +]); // Dev servers bind loopback, so loopback is the only interface whose // availability decides whether we can use a port. Probing wildcards too made // the runner walk away from a perfectly free port whenever something else held @@ -91,6 +102,10 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray { return MODE_ARGS[mode]; } +export function isBrowserAllowedPort(port: number): boolean { + return !FETCH_BAD_PORTS.has(port); +} + export class DevRunnerConfigurationError extends Schema.TaggedErrorClass()( "DevRunnerConfigurationError", { @@ -498,6 +513,10 @@ export function findFirstAvailableOffset({ break; } + if (requireWebPort && !isBrowserAllowedPort(webPort)) { + continue; + } + const checks: Array> = []; if (requireServerPort) { checks.push(checkPort(serverPort, "server"));