diff --git a/CHANGELOG.md b/CHANGELOG.md index e45ae4f5..0fa1c02f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.15.15.1] - 2026-04-06 + +### Fixed +- pair-agent tunnel drops after 15 seconds. The browse server was monitoring its parent process ID and self-terminating when the CLI exited. Now pair-agent sessions disable the parent watchdog so the server and tunnel stay alive. +- `$B connect` crashes with "domains is not defined". A stray variable reference in the headed-mode status check prevented GStack Browser from initializing properly. + ## [0.15.15.0] - 2026-04-06 Community security wave: 8 PRs from 4 contributors, every fix credited as co-author. diff --git a/VERSION b/VERSION index 176efdf1..f53ee8a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.15.0 +0.15.15.1 diff --git a/browse/src/cli.ts b/browse/src/cli.ts index c4b24b4c..bbd5c733 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -839,6 +839,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: BROWSE_PORT: '34567', BROWSE_SIDEBAR_CHAT: '1', }; + // If parent explicitly set BROWSE_PARENT_PID=0 (pair-agent disabling + // self-termination), pass it through so startServer doesn't override it. + if (process.env.BROWSE_PARENT_PID === '0') { + serverEnv.BROWSE_PARENT_PID = '0'; + } const newState = await startServer(serverEnv); // Print connected status @@ -848,9 +853,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: 'Content-Type': 'application/json', 'Authorization': `Bearer ${newState.token}`, }, - body: JSON.stringify({ - domains, - command: 'status', args: [] }), + body: JSON.stringify({ command: 'status', args: [] }), signal: AbortSignal.timeout(5000), }); const status = await resp.text(); @@ -977,7 +980,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: const connectProc = Bun.spawn([browseBin, 'connect'], { cwd: process.cwd(), stdio: ['ignore', 'inherit', 'inherit'], - env: process.env, + // Disable parent-PID monitoring: pair-agent needs the server to outlive + // the connect subprocess. Setting to 0 tells the server not to self-terminate. + env: { ...process.env, BROWSE_PARENT_PID: '0' }, }); await connectProc.exited; // Re-read state after headed mode switch diff --git a/browse/src/server.ts b/browse/src/server.ts index df4dccd8..161d079d 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -929,7 +929,8 @@ async function handleCommandInternal( } // ─── Tab ownership check (for scoped tokens) ────────────── - if (tokenInfo && tokenInfo.clientId !== 'root' && (WRITE_COMMANDS.has(command) || tokenInfo.tabPolicy === 'own-only')) { + // Skip for newtab — it creates a new tab, doesn't access an existing one. + if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && (WRITE_COMMANDS.has(command) || tokenInfo.tabPolicy === 'own-only')) { const targetTab = tabId ?? browserManager.getActiveTabId(); if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: WRITE_COMMANDS.has(command), ownOnly: tokenInfo.tabPolicy === 'own-only' })) { return { diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 16bcbf92..dab03437 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -277,4 +277,44 @@ describe('Server auth security', () => { expect(batchBlock).toContain('tabId: cmd.tabId'); expect(batchBlock).toContain('handleCommandInternal'); }); + + // ─── Pair-agent regression tests ────────────────────────── + + // Regression: connect command crashed with "domains is not defined" because + // a stray `domains,` variable was in the status fetch body (cli.ts:852). + test('connect command status fetch body has no undefined variable references', () => { + const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Sidebar agent started'); + // The status fetch should use a clean JSON body + expect(connectBlock).toContain("command: 'status'"); + // Must NOT contain a bare `domains` reference in the fetch body + // (it would be `domains,` on its own line, not part of a key like `domains:`) + const bodyMatch = connectBlock.match(/body:\s*JSON\.stringify\(\{([^}]+)\}\)/); + expect(bodyMatch).not.toBeNull(); + if (bodyMatch) { + // The body should only contain command and args, no stray variables + expect(bodyMatch[1]).not.toMatch(/\bdomains\b/); + } + }); + + // Regression: pair-agent server died 15s after CLI exited because the server + // monitored the connect subprocess PID. pair-agent must set BROWSE_PARENT_PID=0 + // to disable self-termination. + test('pair-agent disables parent PID monitoring via BROWSE_PARENT_PID=0', () => { + const pairBlock = sliceBetween(CLI_SRC, 'Ensure headed mode', 'handlePairAgent'); + // The connect subprocess env must override BROWSE_PARENT_PID + expect(pairBlock).toContain("BROWSE_PARENT_PID"); + expect(pairBlock).toContain("'0'"); + // The connect command must propagate BROWSE_PARENT_PID=0 to serverEnv + const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Sidebar agent started'); + expect(connectBlock).toContain("BROWSE_PARENT_PID"); + expect(connectBlock).toContain("serverEnv.BROWSE_PARENT_PID"); + }); + + // Regression: newtab returned 403 for scoped tokens because the tab ownership + // check ran before the newtab handler, checking the active tab (owned by root). + test('newtab is excluded from tab ownership check', () => { + const ownershipBlock = sliceBetween(SERVER_SRC, 'Tab ownership check (for scoped tokens)', 'newtab with ownership for scoped tokens'); + // The ownership check condition must exclude newtab + expect(ownershipBlock).toContain("command !== 'newtab'"); + }); });