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 {