mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
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:
co-authored by
Claude Opus 4.8
parent
d977070f28
commit
7baffc003b
@@ -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.`);
|
||||
|
||||
+14
-6
@@ -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<void
|
||||
if (pairData.tunnel_url) {
|
||||
serverUrl = pairData.tunnel_url;
|
||||
} else if (!localHost) {
|
||||
// No tunnel active. Check if ngrok is available and auto-start.
|
||||
const ngrokAvailable = isNgrokAvailable();
|
||||
// No tunnel active. Remote tunneling (pair-agent) is opt-in — never
|
||||
// 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) {
|
||||
console.log('[browse] ngrok detected. Starting tunnel...');
|
||||
try {
|
||||
@@ -940,9 +943,14 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
||||
serverUrl = pairData.server_url;
|
||||
}
|
||||
} else {
|
||||
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>`\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 <TOKEN>`');
|
||||
}
|
||||
console.warn('[browse] Instructions will use localhost (same-machine only).\n');
|
||||
serverUrl = pairData.server_url;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+12
-2
@@ -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');
|
||||
|
||||
@@ -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()");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user