mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
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>
98 lines
3.7 KiB
TypeScript
98 lines
3.7 KiB
TypeScript
/**
|
|
* 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()");
|
|
});
|
|
});
|