fix(pair-agent): tunnel activation is consent-gated — and the receipt's consent claim is now real

The tunnel egress receipts have claimed consent: 'pair_agent=on' since v1.63
while no such key or gate existed — ngrok installed+authed was enough for
the CLI to auto-start an internet-facing tunnel. isPairAgentEnabled() (fail-
closed, env-overridable) now gates all three activation points: CLI
auto-start, POST /tunnel/start (refuses with the enable hint), and the
BROWSE_TUNNEL=1 startup bind. Consent-on-first-use, not silent breakage:
the /pair-agent skill asks once (one-way-door posture), sets pair_agent via
gstack-config (registered with on|off validation, default off), and never
asks again; direct API callers get the same hint in the refusal.

Adapted from the fork's gate: their reader targeted config.json, which on
main would have made the gate silently un-enableable — ours reads the
canonical ~/.gstack/config.yaml with the JSON shape as fallback, pinned by
tests either way (11 cases, gate wiring tripwires included).

Ported from time-attack/gstack (GStack 2), store adaptation ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 12:43:24 -07:00
co-authored by Sina Matian Claude Fable 5
parent 93d92c4585
commit b141fb3511
6 changed files with 190 additions and 8 deletions
+7 -3
View File
@@ -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';
import { spawnTerminalAgent } from './terminal-agent-control';
@@ -898,8 +898,12 @@ 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. First use goes through the /pair-agent skill's
// consent question, which sets the key.
const pairEnabled = isPairAgentEnabled();
const ngrokAvailable = pairEnabled && isNgrokAvailable();
if (ngrokAvailable) {
console.log('[browse] ngrok detected. Starting tunnel...');
try {
+35
View File
@@ -165,6 +165,41 @@ 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`
* (the /pair-agent skill asks once on first use and sets it). Any read/parse
* failure (missing config, malformed JSON) also resolves OFF. The tunnel
* egress receipts cite this gate as their consent — it must exist and gate
* every activation point (#B6, fork port wave 2).
*
* 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;
const home = resolveGstackHome();
// Canonical store: ~/.gstack/config.yaml (flat `key: value` lines, written
// by bin/gstack-config — which is what the /pair-agent consent step runs).
// The fork read config.json; porting that verbatim would have made the gate
// silently un-enableable on main. JSON kept as a fallback shape only.
try {
const yaml = fs.readFileSync(path.join(home, 'config.yaml'), 'utf-8');
const m = yaml.match(/^\s*pair_agent\s*:\s*['"]?(on|off)['"]?\s*(?:#.*)?$/m);
if (m) return m[1] === 'on';
} catch { /* fall through */ }
try {
const raw = fs.readFileSync(path.join(home, 'config.json'), 'utf-8');
return JSON.parse(raw)?.pair_agent === 'on';
} catch {
return false;
}
}
/**
* Resolve the Chromium profile directory.
*
+14 -4
View File
@@ -36,7 +36,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';
@@ -2369,6 +2369,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
if (!isPairAgentEnabled()) {
// Consent-on-first-use: the /pair-agent skill asks once and sets the
// key; a direct API caller gets the same hint instead of a tunnel.
return new Response(JSON.stringify({
error: 'pair-agent is off (tunnel exposes this browser beyond the machine)',
hint: 'enable once with: gstack-config set pair_agent on — or run /pair-agent, which asks for consent and sets it',
}), { 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
@@ -2433,7 +2441,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
bytes: 0,
sha256: null,
consent: 'pair_agent=on',
consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)',
});
tunnelListener = await ngrok.forward(forwardOpts);
@@ -3125,7 +3133,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: pair-agent is off. Enable once 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');
@@ -3153,7 +3163,7 @@ export async function start() {
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
bytes: 0,
sha256: null,
consent: 'pair_agent=on (BROWSE_TUNNEL=1)',
consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)',
});
tunnelListener = await ngrok.forward(forwardOpts);