fix(browse): terminal-agent allocates from the fixed port scan range, not port:0 (#2314)

The terminal-agent bound `Bun.serve({ port: 0 })` and kept that OS-assigned
port for its whole (weeks-long) lifetime. `port: 0` draws from the OS
EPHEMERAL range (49152-65535 on macOS) — the exact pool every short-lived
`app.listen(0)` test server draws from — so the agent squatted ports that
test suites expected to receive and silently absorbed their traffic as
phantom 404s (two squatting daemons verified in the report).

Fix per decision 8: extract the main server's port allocation into
browse/src/port-allocator.ts (checkPortAvailable / isPortAvailable /
findAvailablePort + the 10000-60000 range constants and the actionable
sandbox-vs-occupied error formatters, all verbatim from server.ts) and make
BOTH long-lived listeners use it — server.ts's findPort is now a thin
findAvailablePort(BROWSE_PORT) wrapper, and terminal-agent's buildServer
takes a pre-allocated port from the same range. No terminal-port consumer
carries a range assumption (they read the port file), verified by grep.

Tests: terminal-agent-port-range (new — allocator stays inside
10000-60000 and below the 49152 ephemeral floor, explicit-port honored,
occupied-explicit throws, static tripwires pin no-port:0 in
terminal-agent.ts and the shared wrapper in server.ts) + findport +
terminal-agent-integration/session-routing/detach-reattach +
dual-listener: 67 pass, 0 fail.

Fixes #2314.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 10:59:07 -07:00
co-authored by Claude Fable 5
parent 908c5a6a69
commit 0d4d554e13
4 changed files with 241 additions and 121 deletions
+17 -5
View File
@@ -27,6 +27,7 @@ import { writeSecureFile, restrictFilePermissions, mkdirSecure } from './file-pe
import { atomicWriteSync, atomicWriteQuiet } from '../../lib/fs-atomic';
import { safeUnlink } from './error-handling';
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
import { findAvailablePort } from './port-allocator';
import { extractPtyCookie } from './pty-session-cookie';
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
@@ -490,10 +491,15 @@ function maybeSpawnPty(ws: any, session: PtySession): boolean {
return true;
}
function buildServer() {
function buildServer(port: number) {
return Bun.serve({
hostname: '127.0.0.1',
port: 0,
// #2314: allocated from the SAME fixed 10000-60000 scan range the main
// server uses (port-allocator.ts, decision 8) — never `port: 0`. Binding
// 0 drew from the OS EPHEMERAL range (49152-65535 on macOS), where this
// weeks-lived agent squatted ports that short-lived `app.listen(0)` test
// servers expected to receive, absorbing their traffic as phantom 404s.
port,
idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them
fetch(req, server) {
@@ -944,9 +950,12 @@ function readBrowseToken(): string {
}
// Boot.
function main() {
async function main() {
writeClaudeAvailable();
const server = buildServer();
// #2314: allocate from the shared fixed scan range, then bind. Same
// probe-then-bind semantics as the main server's findPort.
const allocatedPort = await findAvailablePort();
const server = buildServer(allocatedPort);
const port = (server as any).port || (server as any).address?.port;
if (!port) {
console.error('[terminal-agent] failed to bind: no port');
@@ -1015,4 +1024,7 @@ try {
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
} catch {}
main();
main().catch((err) => {
console.error(`[terminal-agent] boot failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});