From 1608300c7aa7eccc8dcb540131f8d6a001524dd6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 28 Oct 2026 04:00:47 +0000 Subject: [PATCH 1/3] fix(server): preview discovery no longer sends HTTP or TLS to agent-started ports Preview discovery sent HTTP and then HTTPS requests to every listener owned by a T3 terminal or by a process carrying T3CODE_THREAD_ID. Owning a process says nothing about its protocol, so binary services an agent launched, such as Unity's test runner, received unsolicited bytes and failed tests (#26279). Owned listeners are now listed passively as http://localhost: without any request, keeping thread attribution, shared-port exclusion, and stop. Only explicitly configured preview URLs are probed, with their scheme and path preserved. T3CODE_PORT_DISCOVERY=0 turns off listener discovery entirely while configured preview URLs keep working. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 160 ++++++++++++++------ apps/server/src/preview/PortScanner.ts | 104 ++++--------- docs/user/remote-access.md | 13 ++ 3 files changed, 160 insertions(+), 117 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 903e9a7010094..875fb5a9d33a4 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -62,6 +62,17 @@ const layerProbeFailure = ( const LSOF_TEST_PORT = 43_123; +const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + const ownedScanner = (processIds: ReadonlyArray = [1234]) => Effect.tap(PortScanner.PortDiscovery, (scanner) => scanner.registerTerminalProcesses({ @@ -289,28 +300,27 @@ effectIt.effect("uses the current configured fragment when readiness comes from }).pipe(Effect.provide(layer)); }); -effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => { - const redirects: Array = []; +effectIt.effect("probes only a configured HTTPS URL and does not follow its redirects", () => { + const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`; + const requests: Array = []; const fetchFn = (async ( input: Parameters[0], init?: Parameters[1], ) => { - redirects.push(init?.redirect); - if (String(input).startsWith("http:")) throw new TypeError("TLS listener"); + requests.push([String(input), init?.redirect]); return new Response(null, { status: 302, headers: { location: "https://example.com" } }); }) as typeof globalThis.fetch; const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { const scanner = yield* ownedScanner(); - const servers = yield* scanner.scan(); - expect(servers).toHaveLength(1); - expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); - expect(redirects).toEqual(["manual", "manual"]); + const servers = yield* scanner.scan([configuredUrl]); + expect(servers).toMatchObject([{ pid: 1234, url: configuredUrl }]); + expect(requests).toEqual([[configuredUrl, "manual"]]); }).pipe(Effect.provide(layer)); }); -effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { +effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", () => { const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; let stdout = unowned; @@ -330,19 +340,19 @@ effectIt.effect("probes a port only while T3 terminals own every listener on it" stdout = owned; expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + expect(requests).toEqual([]); yield* scanner.unregisterTerminal({ threadId: "scanner-thread", terminalId: "scanner-terminal", }); expect(yield* scanner.scan()).toHaveLength(0); - expect(requests).toHaveLength(1); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); effectIt.effect( - "excludes HTTP errors, non-navigation responses, and successful non-documents", + "excludes configured URLs answering with HTTP errors, non-navigation responses, or non-documents", () => { let pid = 1; let makeResponse = () => @@ -350,33 +360,35 @@ effectIt.effect( const fetchFn = ((_input: Parameters[0]) => Promise.resolve(makeResponse())) as typeof globalThis.fetch; const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/`; return Effect.gen(function* () { - const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); - expect(yield* scanner.scan()).toHaveLength(0); + const scanner = yield* PortScanner.PortDiscovery; + const scan = () => scanner.scan([configuredUrl]); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 204, headers: { "content-type": "text/html" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 302 }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => @@ -384,7 +396,7 @@ effectIt.effect( status: 200, headers: { "content-type": "application/xhtml+xml; charset=utf-8" }, }); - expect(yield* scanner.scan()).toHaveLength(1); + expect(yield* scan()).toHaveLength(1); }).pipe(Effect.provide(layer)); }, ); @@ -433,10 +445,11 @@ effectIt.effect( return target === undefined ? missing.readLink(path) : Effect.succeed(target); }, }); - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = layerProbeFailure( (input) => { lsofSpawns += 1; @@ -449,37 +462,45 @@ effectIt.effect( return Effect.gen(function* () { const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); - expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 8765, pid: 4242, processName: "python3" }, - ]); + expect( + first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })), + ).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]); yield* scanner.scan(); expect(lsofSpawns).toBe(1); expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); yield* scanner.scan(); yield* scanner.scan(); expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }, ); effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + let ipv6Fails = true; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => { + if (input.command === "tasklist.exe") { + return Effect.succeed( + processOutput('"node.exe","1234","Console","1","50,000 K"\r\n'), + ); + } const isIpv6 = input.args.includes("tcpv6"); return Effect.succeed({ stdout: isIpv6 ? "" : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n", stderr: "", - code: isIpv6 ? ChildProcessSpawner.ExitCode(1) : null, + code: isIpv6 && ipv6Fails ? ChildProcessSpawner.ExitCode(1) : null, timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -502,20 +523,14 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits processIds: [1234], }); expect(yield* scanner.scan()).toEqual([]); + ipv6Fails = false; + expect(yield* scanner.scan()).toMatchObject([ + { port: 5173, pid: 1234, processName: "node", url: "http://localhost:5173" }, + ]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); -const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ - stdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, -}); - effectIt.effect("rechecks the socket owner before stopping a Linux server without lsof", () => { let holder = "4242"; const missing = FileSystem.makeNoop({}); @@ -569,6 +584,63 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou }).pipe(Effect.provide(layer)); }); +effectIt.effect.each(["linux", "darwin", "win32"] as const)( + "reads no listeners on %s while T3CODE_PORT_DISCOVERY=0, and still probes configured URLs", + (platform) => { + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const commands: string[] = []; + const fileReads: string[] = []; + const requests: string[] = []; + const missing = FileSystem.makeNoop({}); + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: (path) => { + fileReads.push(path); + return missing.readFileString(path); + }, + readDirectory: (path) => { + fileReads.push(path); + return missing.readDirectory(path); + }, + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + commands.push(input.command); + return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`)); + }, + }), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Environment, { T3CODE_PORT_DISCOVERY: "0" }), + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch, (input) => { + requests.push(String(input)); + return Promise.resolve( + new Response("docs", { headers: { "content-type": "text/html" } }), + ); + }), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + scanner.trackAgent(); + expect(yield* scanner.scan()).toEqual([]); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { port: LSOF_TEST_PORT, pid: null, terminal: null, url: configuredUrl }, + ]); + expect(requests).toEqual([configuredUrl]); + expect(commands).toEqual([]); + expect(fileReads).toEqual([]); + }).pipe(Effect.provide(layer)); + }, +); + effectIt.effect.each(["linux", "darwin"] as const)( "attributes listeners to the agent thread in their environment on %s, after the agent exits", (platform) => { @@ -672,7 +744,7 @@ effectIt.effect.each(["linux", "darwin"] as const)( expect(yield* scanner.scan()).toMatchObject([ { port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } }, ]); - expect(requests).toEqual([`http://localhost:${agentPort}/`]); + expect(requests).toEqual([]); expect(yield* scanner.scan()).toHaveLength(1); expect(environmentReads).toBe(2); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 1080624d7508d..76cf1d1346c1d 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -68,6 +68,9 @@ export class PortDiscovery extends Context.Service< */ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; +/** `0` turns off listener discovery; configured preview URLs are still probed. */ +const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY"; + const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; @@ -132,8 +135,8 @@ interface WebProbeCacheEntry { interface WebProbeGroup { readonly server: DiscoveredLocalServer; - readonly urls: ReadonlyArray; - readonly configuredKey: string | null; + readonly url: string; + readonly configuredKey: string; } interface WebProbeSnapshot { @@ -498,6 +501,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; + const listenerDiscovery = (yield* HostProcess.Environment)[PORT_DISCOVERY_ENV] !== "0"; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ listeners: new Map(), @@ -715,24 +719,17 @@ export const make = Effect.gen(function* PortDiscoveryMake() { pid: null, terminal: null, }, - urls: [raw], + url: raw, configuredKey: resourceKey, }); } - for (const server of servers) { - // Never probe an unidentified listener with HTTP or TLS; binary RPC servers can crash on it. - if (!isOwned(server)) continue; - groups.push({ - server, - urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], - configuredKey: null, - }); - } - return groups; }; + // Only configured URLs are requested. Owned listeners are listed as plain HTTP without a + // request: owning a process says nothing about its protocol, and HTTP or TLS bytes break + // binary ones such as Unity's test runner or Steam's IPC. const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( servers: ReadonlyArray, configuredUrls: ReadonlyArray, @@ -740,77 +737,37 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); const groups = makeWebProbeGroups(servers, configuredUrls); - const batchProbes = new Map< - string, - Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> - >(); - const batchProbeSemaphore = yield* Semaphore.make(1); - const getProbe = (url: string, pid: number | null) => { - const key = webProbeCacheKey(url); - const identity = `${key}\u0000${pid ?? ""}`; - return batchProbeSemaphore - .withPermits(1)( - Effect.gen(function* () { - const existing = batchProbes.get(identity); - if (existing) return [existing] as const; - const cachedProbe = cached.get(key); - const cachedIsCurrent = - cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; - const memoized = yield* Effect.cached( - cachedIsCurrent - ? Effect.succeed({ probe: cachedProbe, fresh: false }) - : probeWebUrl(url).pipe( - Effect.map((result) => ({ - probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, - fresh: true, - })), - ), - ); - batchProbes.set(identity, memoized); - return [memoized] as const; - }), - ) - .pipe(Effect.flatMap(([probe]) => probe)); - }; const probed = yield* Effect.forEach( groups, - (group) => - Effect.gen(function* () { - const probes: Array = []; - let visibleUrl: string | null = null; - for (const url of group.urls) { - const key = webProbeCacheKey(url); - const { probe, fresh } = yield* getProbe(url, group.server.pid); - probes.push([key, probe, fresh]); - if (probe.isWeb) { - visibleUrl = url; - break; - } - } - return { group, probes, visibleUrl }; - }), + (group) => { + const { pid } = group.server; + const cachedProbe = cached.get(group.configuredKey); + return cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis + ? Effect.succeed({ group, probe: cachedProbe, fresh: false }) + : probeWebUrl(group.url).pipe( + Effect.map((result) => ({ + group, + probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, + fresh: true, + })), + ); + }, { concurrency: WEB_PROBE_CONCURRENCY }, ); const completedAtMillis = yield* Clock.currentTimeMillis; const nextCache = new Map( [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), ); - const discovered: DiscoveredLocalServer[] = []; const configured = new Map(); - for (const { group, probes, visibleUrl } of probed) { - for (const [key, probe, fresh] of probes) { - nextCache.set( - key, - fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, - ); - } - if (visibleUrl === null) continue; - const server = { ...group.server, url: visibleUrl }; - if (group.configuredKey === null) discovered.push(server); - else configured.set(group.configuredKey, server); + for (const { group, probe, fresh } of probed) { + nextCache.set( + group.configuredKey, + fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, + ); + if (probe.isWeb) configured.set(group.configuredKey, { ...group.server, url: group.url }); } yield* Ref.set(webProbeCacheRef, nextCache); - return { discovered, configured } satisfies WebProbeSnapshot; + return { discovered: servers.filter(isOwned), configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -873,6 +830,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray, ) { + if (!listenerDiscovery) return yield* probeWebServers([], configuredUrls); const state = yield* Ref.get(stateRef); const terminalOwners = new Map(); for (const registration of state.terminalProcesses.values()) { diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index e78992b69f200..9c426b64b1344 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -243,6 +243,19 @@ On a phone, tap the floating preview's corner dot to show its controls, then **Pop into separate window** to keep watching in picture-in-picture over other apps. +### Local servers + +The browser lists ports held by processes your terminals and agents started. +T3 Code reads which process holds each port and never connects to it, so a +listed port opens as `http://localhost:` and might not be a web page. For +an HTTPS server or a specific path, set the **Preview URL** on the project +script that starts it; T3 Code checks that address and opens it as written. + +To stop listing ports, set `T3CODE_PORT_DISCOVERY=0` in the environment that +starts the T3 Code server, such as +[`service.env`](./background-service.md#configure-the-service-environment) for +the background service, and restart it. Script preview URLs still appear. + ### Browser host setup macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need From f0261e2388facfd2f59f1317edeba2a8cb62b3d0 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 18:47:57 +0000 Subject: [PATCH 2/3] fix(server): owned listeners keep the configured URL when its check fails When the configured preview URL for an owned listener failed its check, the scanner listed the raw http://localhost: instead, dropping the configured scheme and path. The per-subscriber projection now keeps that caller's first configured URL for the listener. Unowned listeners still need a passing check, and no new requests are sent. Also stop calling every passively listed port a browser-ready web server in the preview empty state and the thread details docs. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 64 +++++++++++++------ apps/server/src/preview/PortScanner.ts | 7 +- .../components/preview/PreviewEmptyState.tsx | 2 +- docs/user/thread-sidebar.md | 7 +- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 875fb5a9d33a4..3dee90313a2b3 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -185,10 +185,11 @@ effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites" effectIt.effect("projects configured paths independently for simultaneous subscribers", () => { const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`; + let ready = true; const fetchFn = ((input: Parameters[0]) => { const url = String(input); return Promise.resolve( - url === docsUrl || url === adminUrl + ready && (url === docsUrl || url === adminUrl) ? new Response("app", { headers: { "content-type": "text/html" } }) : new Response("not found", { status: 404 }), ); @@ -196,7 +197,7 @@ effectIt.effect("projects configured paths independently for simultaneous subscr const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const docsSnapshots: ReadonlyArray[] = []; const adminSnapshots: ReadonlyArray[] = []; yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) => @@ -209,6 +210,11 @@ effectIt.effect("projects configured paths independently for simultaneous subscr expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); + + ready = false; + yield* TestClock.adjust(Duration.seconds(15)); + expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); + expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); }).pipe(Effect.scoped, Effect.provide(layer)); }); @@ -300,25 +306,43 @@ effectIt.effect("uses the current configured fragment when readiness comes from }).pipe(Effect.provide(layer)); }); -effectIt.effect("probes only a configured HTTPS URL and does not follow its redirects", () => { - const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`; - const requests: Array = []; - const fetchFn = (async ( - input: Parameters[0], - init?: Parameters[1], - ) => { - requests.push([String(input), init?.redirect]); - return new Response(null, { status: 302, headers: { location: "https://example.com" } }); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); +effectIt.effect( + "probes only a configured HTTPS URL, keeps it for an owned listener when the check fails, and does not follow redirects", + () => { + const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`; + const requests: Array = []; + let respond = async () => + new Response(null, { status: 302, headers: { location: "https://example.com" } }); + const fetchFn = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requests.push([String(input), init?.redirect]); + return respond(); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - const servers = yield* scanner.scan([configuredUrl]); - expect(servers).toMatchObject([{ pid: 1234, url: configuredUrl }]); - expect(requests).toEqual([[configuredUrl, "manual"]]); - }).pipe(Effect.provide(layer)); -}); + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + + respond = async () => new Response("unavailable", { status: 503 }); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + + respond = () => Promise.reject(new TypeError("fetch failed")); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + expect(requests).toEqual(Array.from({ length: 3 }, () => [configuredUrl, "manual"])); + }).pipe(Effect.provide(layer)); + }, +); effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", () => { const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 76cf1d1346c1d..e026b204c03d8 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -192,17 +192,22 @@ const projectWebProbeSnapshot = ( configuredUrls: ReadonlyArray, ): ReadonlyArray => { const visibleByServer = new Map(); + const firstConfiguredByServer = new Map(); for (const raw of normalizeConfiguredUrls(configuredUrls)) { const url = new URL(raw); const port = urlPort(url); const serverKey = localServerKey(url.hostname, port); + if (!firstConfiguredByServer.has(serverKey)) firstConfiguredByServer.set(serverKey, raw); if (visibleByServer.has(serverKey)) continue; const configured = snapshot.configured.get(webProbeCacheKey(raw)); if (configured) visibleByServer.set(serverKey, { ...configured, url: raw }); } + // An owned listener whose configured check failed still opens at this caller's configured URL. for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); - if (!visibleByServer.has(key)) visibleByServer.set(key, server); + if (visibleByServer.has(key)) continue; + const configuredUrl = firstConfiguredByServer.get(key); + visibleByServer.set(key, configuredUrl ? { ...server, url: configuredUrl } : server); } return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port); }; diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 615d7e22586fb..fc70ff9ecb060 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -40,7 +40,7 @@ export function PreviewEmptyState({ No preview yet - Type a URL above, or run a dev script. Browser-ready localhost servers will show up here + Type a URL above, or run a dev script. Local servers this thread starts will show up here automatically. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index fb5e12b08372b..062fd749a1793 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -270,9 +270,10 @@ Expand a tool call in the conversation to see its full command and output. Summaries shorten shell wrappers and can still describe the latest call after it finishes; the call's own result shows its status. -On web and desktop, the thread details card lists the web servers that the -thread's terminals or its Claude agent started, with their ports. A server the -agent started stays listed after the agent's turn ends. +On web and desktop, the thread details card lists the local servers that the +thread's terminals or its Claude agent started, with their ports. Not every one +serves a web page. A server the agent started stays listed after the agent's +turn ends. Select a server to open it in the browser panel. **Stop** ends the server's job and keeps the agent, the terminal, and T3 Code running. Stop needs terminal access to the environment and is not available on Windows. From 153534be801905e9cb53a1beedf686555787411f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 30 Oct 2026 16:34:24 -0700 Subject: [PATCH 3/3] chore(web): thread servers panel comment says local servers Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ThreadServersPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ThreadServersPanel.tsx b/apps/web/src/components/chat/ThreadServersPanel.tsx index 353f6032b6f87..f1c8e1a1808f1 100644 --- a/apps/web/src/components/chat/ThreadServersPanel.tsx +++ b/apps/web/src/components/chat/ThreadServersPanel.tsx @@ -30,7 +30,7 @@ import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./threadDetailsPanelStyles"; const serverKey = (server: DiscoveredLocalServer) => `${server.pid}:${server.port}`; -/** Web servers the thread's terminals and agent started. Hidden while none run. */ +/** Local servers the thread's terminals and agent started. Hidden while none run. */ export function ThreadServersPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId;