feat: make remote pair-agent tunnel opt-in (default off)

The ngrok pair-agent tunnel could auto-start whenever ngrok was installed
and shipped active on every install, despite ~0.01% usage. Gate all three
activation points behind a single opt-in config flag so nothing is exposed
to the internet unless the user explicitly enables it.

- New `pair_agent` config key (off | on), default off, read via the shared
  fail-closed `isPairAgentEnabled()` guard in browse/src/config.ts (honors
  `GSTACK_PAIR_AGENT` env override for tests/emergency).
- CLI no longer auto-starts the tunnel when disabled, even if ngrok is
  installed/authed; prints the enable command instead.
- `/tunnel/start` returns 403 with the enable hint when disabled (tunnel
  listener never binds).
- `BROWSE_TUNNEL=1` startup path skips the tunnel bind when disabled.

Local browse/QA (local listener, /command, /browse, /qa, cookie import,
/inspector, /health) is unchanged. When enabled, behavior is identical to
before.

The /pair-agent skill doc is parity-locked GStack 2 legacy (evals/parity/
contracts/pair-agent.json pins the render + blob SHAs). Its up-front
"enable pair_agent first" wording needs a separate parity-aware regen and
is intentionally not touched here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-21 17:37:01 -07:00
co-authored by Claude Opus 4.8
parent d977070f28
commit 7baffc003b
5 changed files with 149 additions and 8 deletions
+4
View File
@@ -52,6 +52,9 @@ const defaults = Object.freeze({
redact_repo_visibility: "", redact_repo_visibility: "",
redact_prepush_hook: false, redact_prepush_hook: false,
salience_allowlist: "", 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); const [command, ...args] = process.argv.slice(2);
@@ -133,6 +136,7 @@ function validateClosedValue(key, value) {
[/^redact_repo_visibility$/, ["public", "private", "unknown"], "unknown"], [/^redact_repo_visibility$/, ["public", "private", "unknown"], "unknown"],
[/^redact_prepush_hook$/, ["true", "false"], "false"], [/^redact_prepush_hook$/, ["true", "false"], "false"],
[/^plan_tune_hooks$/, ["prompt", "yes", "no"], "prompt"], [/^plan_tune_hooks$/, ["prompt", "yes", "no"], "prompt"],
[/^pair_agent$/, ["off", "on"], "off"],
]; ];
if (key === "codex_reviews" && !["enabled", "disabled"].includes(value)) { if (key === "codex_reviews" && !["enabled", "disabled"].includes(value)) {
throw new Error(`codex_reviews '${value}' not recognized. Valid values: enabled, disabled. Existing value left unchanged.`); throw new Error(`codex_reviews '${value}' not recognized. Valid values: enabled, disabled. Existing value left unchanged.`);
+13 -5
View File
@@ -14,7 +14,7 @@ import * as path from 'path';
import { spawn as nodeSpawn } from 'child_process'; import { spawn as nodeSpawn } from 'child_process';
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling'; import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions'; 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 { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
import { redactProxyUrl } from './proxy-redact'; import { redactProxyUrl } from './proxy-redact';
@@ -914,8 +914,11 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
if (pairData.tunnel_url) { if (pairData.tunnel_url) {
serverUrl = pairData.tunnel_url; serverUrl = pairData.tunnel_url;
} else if (!localHost) { } else if (!localHost) {
// No tunnel active. Check if ngrok is available and auto-start. // No tunnel active. Remote tunneling (pair-agent) is opt-in — never
const ngrokAvailable = isNgrokAvailable(); // auto-start it unless the user explicitly enabled it, even if ngrok is
// installed and authed.
const pairEnabled = isPairAgentEnabled();
const ngrokAvailable = pairEnabled && isNgrokAvailable();
if (ngrokAvailable) { if (ngrokAvailable) {
console.log('[browse] ngrok detected. Starting tunnel...'); console.log('[browse] ngrok detected. Starting tunnel...');
try { try {
@@ -939,10 +942,15 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
console.warn('[browse] Using localhost (same-machine only).\n'); console.warn('[browse] Using localhost (same-machine only).\n');
serverUrl = pairData.server_url; serverUrl = pairData.server_url;
} }
} else {
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 { } else {
console.warn('[browse] No tunnel active and ngrok is not installed/configured.'); console.warn('[browse] No tunnel active and ngrok is not installed/configured.');
console.warn('[browse] Instructions will use localhost (same-machine only).'); console.warn('[browse] For remote agents: install ngrok (https://ngrok.com) and run `ngrok config add-authtoken <TOKEN>`');
console.warn('[browse] For remote agents: install ngrok (https://ngrok.com) and run `ngrok config add-authtoken <TOKEN>`\n'); }
console.warn('[browse] Instructions will use localhost (same-machine only).\n');
serverUrl = pairData.server_url; serverUrl = pairData.server_url;
} }
} else { } else {
+22
View File
@@ -165,6 +165,28 @@ export function resolveGstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); 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. * Resolve the Chromium profile directory.
* *
+12 -2
View File
@@ -34,7 +34,7 @@ import {
isRootToken, checkConnectRateLimit, type TokenInfo, isRootToken, checkConnectRateLimit, type TokenInfo,
} from './token-registry'; } from './token-registry';
import { validateTempPath } from './path-security'; 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 { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
import { createSseEndpoint } from './sse-helpers'; import { createSseEndpoint } from './sse-helpers';
import { initAuditLog, writeAuditEntry } from './audit'; import { initAuditLog, writeAuditEntry } from './audit';
@@ -1788,6 +1788,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 403, headers: { 'Content-Type': 'application/json' }, 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) { if (tunnelActive && tunnelUrl && tunnelServer) {
// Verify tunnel is still alive before returning cached URL. // Verify tunnel is still alive before returning cached URL.
// Probe GET /connect (the only unauth-reachable path on the tunnel // 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 // Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
// pattern: bind a dedicated tunnel listener on an ephemeral port and // pattern: bind a dedicated tunnel listener on an ephemeral port and
// point ngrok.forward() at IT, not the local daemon port. // 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(); const authtoken = resolveNgrokAuthtoken();
if (!authtoken) { if (!authtoken) {
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env'); console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
+97
View File
@@ -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()");
});
});