diff --git a/bin/gstack-config b/bin/gstack-config index 062a1b47f..6d2a688d0 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -52,6 +52,9 @@ const defaults = Object.freeze({ redact_repo_visibility: "", redact_prepush_hook: false, salience_allowlist: "", + // Remote pair-agent (ngrok tunnel) is opt-in: OFF exposes nothing to the + // internet. Set "on" to allow the tunnel to start. + pair_agent: "off", }); const [command, ...args] = process.argv.slice(2); @@ -133,6 +136,7 @@ function validateClosedValue(key, value) { [/^redact_repo_visibility$/, ["public", "private", "unknown"], "unknown"], [/^redact_prepush_hook$/, ["true", "false"], "false"], [/^plan_tune_hooks$/, ["prompt", "yes", "no"], "prompt"], + [/^pair_agent$/, ["off", "on"], "off"], ]; if (key === "codex_reviews" && !["enabled", "disabled"].includes(value)) { throw new Error(`codex_reviews '${value}' not recognized. Valid values: enabled, disabled. Existing value left unchanged.`); diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 256cf6891..47e04ec03 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -14,7 +14,7 @@ import * as path from 'path'; import { spawn as nodeSpawn } from 'child_process'; import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling'; import { writeSecureFile, mkdirSecure } from './file-permissions'; -import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled } from './config'; import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; @@ -914,8 +914,11 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise`\n'); + if (!pairEnabled) { + console.warn('[browse] Remote pair-agent tunnel is disabled (opt-in).'); + console.warn('[browse] Enable it with: gstack-config set pair_agent on'); + } else { + console.warn('[browse] No tunnel active and ngrok is not installed/configured.'); + console.warn('[browse] For remote agents: install ngrok (https://ngrok.com) and run `ngrok config add-authtoken `'); + } + console.warn('[browse] Instructions will use localhost (same-machine only).\n'); serverUrl = pairData.server_url; } } else { diff --git a/browse/src/config.ts b/browse/src/config.ts index fc4c97b95..9a8c7b9d2 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -165,6 +165,28 @@ export function resolveGstackHome(): string { return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); } +/** + * Is the remote pair-agent (ngrok tunnel) surface opt-in enabled? + * + * Fail-closed: the tunnel exposes the local browser to the internet, so it + * stays OFF unless the user explicitly ran `gstack-config set pair_agent on`. + * Any read/parse failure (missing config, malformed JSON) also resolves OFF. + * + * Env override `GSTACK_PAIR_AGENT=on|off` wins (used by tests and as an + * emergency knob), mirroring the telemetry env-hint convention. + */ +export function isPairAgentEnabled(): boolean { + const env = process.env.GSTACK_PAIR_AGENT; + if (env === 'on') return true; + if (env === 'off') return false; + try { + const raw = fs.readFileSync(path.join(resolveGstackHome(), 'config.json'), 'utf-8'); + return JSON.parse(raw)?.pair_agent === 'on'; + } catch { + return false; + } +} + /** * Resolve the Chromium profile directory. * diff --git a/browse/src/server.ts b/browse/src/server.ts index b7840c4fc..da0f03a22 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -34,7 +34,7 @@ import { isRootToken, checkConnectRateLimit, type TokenInfo, } from './token-registry'; import { validateTempPath } from './path-security'; -import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config'; +import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; import { createSseEndpoint } from './sse-helpers'; import { initAuditLog, writeAuditEntry } from './audit'; @@ -1788,6 +1788,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { status: 403, headers: { 'Content-Type': 'application/json' }, }); } + // Remote pair-agent is opt-in. Refuse to open the tunnel (and never + // bind the tunnel listener) unless the user explicitly enabled it. + if (!isPairAgentEnabled()) { + return new Response(JSON.stringify({ + error: 'Remote pair-agent is disabled', + hint: 'Enable it with: gstack-config set pair_agent on', + }), { status: 403, headers: { 'Content-Type': 'application/json' } }); + } if (tunnelActive && tunnelUrl && tunnelServer) { // Verify tunnel is still alive before returning cached URL. // Probe GET /connect (the only unauth-reachable path on the tunnel @@ -2531,7 +2539,9 @@ export async function start() { // Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener // pattern: bind a dedicated tunnel listener on an ephemeral port and // point ngrok.forward() at IT, not the local daemon port. - if (process.env.BROWSE_TUNNEL === '1') { + if (process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()) { + console.error('[browse] BROWSE_TUNNEL=1 ignored: remote pair-agent is disabled (opt-in). Enable it with: gstack-config set pair_agent on'); + } else if (process.env.BROWSE_TUNNEL === '1') { const authtoken = resolveNgrokAuthtoken(); if (!authtoken) { console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env'); diff --git a/browse/test/pair-agent-optin-gate.test.ts b/browse/test/pair-agent-optin-gate.test.ts new file mode 100644 index 000000000..2bfd2998a --- /dev/null +++ b/browse/test/pair-agent-optin-gate.test.ts @@ -0,0 +1,97 @@ +/** + * Pair-agent opt-in gate. + * + * The remote pair-agent (ngrok tunnel) is OFF by default. All three activation + * points — CLI auto-start, the /tunnel/start route, and the BROWSE_TUNNEL=1 + * startup path — route through the single `isPairAgentEnabled()` guard. This + * test pins the guard's behavior (the root cause) plus a source-level tripwire + * that each call site actually consults it. + */ + +import { describe, test, expect, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { isPairAgentEnabled } from '../src/config'; + +const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); +const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8'); + +const savedEnv = { GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_PAIR_AGENT: process.env.GSTACK_PAIR_AGENT }; +const tmpHomes: string[] = []; + +function tmpHomeWith(config: unknown | null): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pair-')); + tmpHomes.push(dir); + if (config !== null) fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config)); + process.env.GSTACK_HOME = dir; + delete process.env.GSTACK_PAIR_AGENT; + return dir; +} + +afterEach(() => { + for (const k of ['GSTACK_HOME', 'GSTACK_PAIR_AGENT'] as const) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + while (tmpHomes.length) fs.rmSync(tmpHomes.pop()!, { recursive: true, force: true }); +}); + +describe('isPairAgentEnabled — fail-closed default', () => { + test('OFF when config.json is missing', () => { + tmpHomeWith(null); + expect(isPairAgentEnabled()).toBe(false); + }); + + test('OFF when config has no pair_agent key', () => { + tmpHomeWith({ telemetry: 'off' }); + expect(isPairAgentEnabled()).toBe(false); + }); + + test('OFF when pair_agent is explicitly "off"', () => { + tmpHomeWith({ pair_agent: 'off' }); + expect(isPairAgentEnabled()).toBe(false); + }); + + test('ON only when pair_agent is exactly "on"', () => { + tmpHomeWith({ pair_agent: 'on' }); + expect(isPairAgentEnabled()).toBe(true); + }); + + test('OFF when config.json is malformed (fail-closed)', () => { + const dir = tmpHomeWith(null); + fs.writeFileSync(path.join(dir, 'config.json'), '{ not json'); + expect(isPairAgentEnabled()).toBe(false); + }); + + test('env override wins: GSTACK_PAIR_AGENT=on forces ON even with config off', () => { + tmpHomeWith({ pair_agent: 'off' }); + process.env.GSTACK_PAIR_AGENT = 'on'; + expect(isPairAgentEnabled()).toBe(true); + }); + + test('env override wins: GSTACK_PAIR_AGENT=off forces OFF even with config on', () => { + tmpHomeWith({ pair_agent: 'on' }); + process.env.GSTACK_PAIR_AGENT = 'off'; + expect(isPairAgentEnabled()).toBe(false); + }); +}); + +describe('gate wiring — every tunnel activation point consults the guard', () => { + test('CLI auto-start is gated (never auto-starts when disabled)', () => { + // pairEnabled short-circuits the ngrok probe so the tunnel can't auto-start. + expect(CLI_SRC).toContain('const pairEnabled = isPairAgentEnabled();'); + expect(CLI_SRC).toContain('const ngrokAvailable = pairEnabled && isNgrokAvailable();'); + }); + + test('/tunnel/start refuses with the enable hint when disabled', () => { + const startIdx = SERVER_SRC.indexOf("url.pathname === '/tunnel/start'"); + const block = SERVER_SRC.slice(startIdx, startIdx + 1200); + expect(block).toContain('if (!isPairAgentEnabled())'); + expect(block).toContain('gstack-config set pair_agent on'); + }); + + test('BROWSE_TUNNEL=1 startup skips tunnel bind when disabled', () => { + expect(SERVER_SRC).toContain("process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()"); + }); +});