diff --git a/browse/src/config.ts b/browse/src/config.ts index e43d9999a..09acf0790 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -165,6 +165,28 @@ export function resolveGstackHome(): string { return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); } +/** + * Read one key from the flat-YAML config store at /config.yaml + * (the shape bin/gstack-config writes: `key: value` lines). Tolerates + * optional single/double quotes around the value and a trailing `# comment`. + * Returns the unquoted value string, or null when the file is missing or + * unreadable or the key is absent. + * + * Single source of truth for flat-YAML key reads — isPairAgentEnabled + * (pair_agent) and telemetry.ts (telemetry tier) both route through it so + * the two consent gates can never drift on parsing semantics. + */ +export function readGstackConfigYamlKey(key: string): string | null { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + try { + const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8'); + const m = yaml.match(new RegExp(`^\\s*${escaped}\\s*:\\s*['"]?([^'"#\\n]*?)['"]?\\s*(?:#.*)?$`, 'm')); + return m ? m[1] : null; + } catch { + return null; + } +} + /** * Is the remote pair-agent (ngrok tunnel) surface opt-in enabled? * @@ -182,18 +204,17 @@ 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. + // Anything other than exactly on/off (missing key, malformed value) falls + // through to the JSON fallback and ultimately fails closed. + const yamlValue = readGstackConfigYamlKey('pair_agent'); + if (yamlValue === 'on') return true; + if (yamlValue === 'off') return false; 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'); + const raw = fs.readFileSync(path.join(resolveGstackHome(), 'config.json'), 'utf-8'); return JSON.parse(raw)?.pair_agent === 'on'; } catch { return false; diff --git a/browse/src/telemetry.ts b/browse/src/telemetry.ts index b728c5f01..094f6a2f1 100644 --- a/browse/src/telemetry.ts +++ b/browse/src/telemetry.ts @@ -21,6 +21,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { readGstackConfigYamlKey } from './config'; function gstackHome(): string { return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); @@ -43,32 +44,51 @@ async function ensureDir(): Promise { } let telemetryDisabled: boolean | null = null; -function isDisabled(): boolean { +/** + * Is telemetry disabled for this process? Telemetry is OPT-IN: the consent + * prompt writes a granted tier ('community' | 'anonymous') to + * ~/.gstack/config.yaml, and only a granted tier enables emission. Tiers, + * checked in order: + * + * 1. Env hint GSTACK_TELEMETRY_OFF=1 (set by preambles and test + * harnesses): always disabled, even over a granted config tier. + * 2. Persistent tier via the shared flat-YAML helper in config.ts (same + * parser as the pair-agent gate, so the two consent gates never drift): + * explicit `telemetry: off` disables; 'community'/'anonymous' enable. + * 3. Default: DISABLED. An absent key, absent file, or unrecognized value + * means consent was never granted — matching bin/gstack-config's + * DEFAULTS table, which reports 'off' for an unset telemetry key. + * Anything else would be a split-brain where `gstack-config get + * telemetry` tells the user 'off' while a direct-$B daemon emits. + * One escape hatch: GSTACK_TELEMETRY_OFF=0 is a harness-side consent + * assertion that flips this DEFAULT only (test harnesses exercising the + * write path against a scratch GSTACK_HOME) — it never overrides an + * explicit `telemetry: off` the user wrote. + * + * Exported so tests can pin the consent gate directly; the cached verdict + * resets via _resetTelemetryCache. + */ +export function isTelemetryDisabled(): boolean { if (telemetryDisabled !== null) return telemetryDisabled; - // Check env (set by preamble or test harnesses). + // Env kill switch (set by preamble or test harnesses): beats everything. if (process.env.GSTACK_TELEMETRY_OFF === '1') { telemetryDisabled = true; return true; } - // Persistent tier: gstack-config set telemetry off must hold even when the - // daemon is spawned outside a skill preamble (direct $B use, embedders) and - // the env hint was never set (fork port wave 2 polish). - try { - const fs = require('fs') as typeof import('fs'); - const path = require('path') as typeof import('path'); - const os = require('os') as typeof import('os'); - const home = process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); - const yaml = fs.readFileSync(path.join(home, 'config.yaml'), 'utf-8'); - if (/^\s*telemetry\s*:\s*['"]?off['"]?\s*(?:#.*)?$/m.test(yaml)) { - telemetryDisabled = true; - return true; - } - } catch { /* no config — fall through to default */ } - // Conservative default: telemetry ON unless explicitly off. Users opt out via - // gstack-config set telemetry off (env hint from the preamble OR the - // persistent tier read above). - telemetryDisabled = false; - return false; + // Persistent tier: an explicit user-written value always wins next. + const tier = readGstackConfigYamlKey('telemetry'); + if (tier === 'off') { + telemetryDisabled = true; + return true; + } + if (tier === 'community' || tier === 'anonymous') { + telemetryDisabled = false; + return false; + } + // No granted consent on record (absent key/file, unrecognized value): + // disabled — unless the harness asserted consent via the env seam. + telemetryDisabled = process.env.GSTACK_TELEMETRY_OFF !== '0'; + return telemetryDisabled; } export interface TelemetryEvent { @@ -78,7 +98,7 @@ export interface TelemetryEvent { /** Fire-and-forget log. Never throws. */ export function logTelemetry(payload: TelemetryEvent): void { - if (isDisabled()) return; + if (isTelemetryDisabled()) return; const enriched = { ...payload, ts: new Date().toISOString() }; ensureDir() .then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8')) diff --git a/browse/test/telemetry-optout.test.ts b/browse/test/telemetry-optout.test.ts new file mode 100644 index 000000000..0c4626c16 --- /dev/null +++ b/browse/test/telemetry-optout.test.ts @@ -0,0 +1,175 @@ +/** + * Telemetry consent tiers — the user-consent enforcement point. + * + * Telemetry is OPT-IN: it emits only when the user granted a tier through + * the consent prompt (`telemetry: community` or `telemetry: anonymous` in + * ~/.gstack/config.yaml). An absent key, an absent config file, an explicit + * `off`, or any unrecognized value all mean DISABLED — the same default + * bin/gstack-config's DEFAULTS table reports for an unset key, so a daemon + * spawned outside a skill preamble (direct $B use, embedders) can never + * emit while `gstack-config get telemetry` tells the user 'off'. + * + * The persistent tier reads through the shared flat-YAML helper in + * config.ts (readGstackConfigYamlKey), same parser as the pair-agent gate. + * Env tier: GSTACK_TELEMETRY_OFF=1 always disables; =0 is a harness-side + * consent assertion that covers the no-config default only — it never + * overrides an explicit `telemetry: off`. + * + * Harness mirrors pair-agent-optin-gate.test.ts: GSTACK_HOME → temp dir, + * env saved/restored per test, cache reset via _resetTelemetryCache. + */ + +import { describe, test, expect, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { isTelemetryDisabled, logTelemetry, _resetTelemetryCache } from '../src/telemetry'; + +const savedEnv = { + GSTACK_HOME: process.env.GSTACK_HOME, + GSTACK_TELEMETRY_OFF: process.env.GSTACK_TELEMETRY_OFF, +}; +const tmpHomes: string[] = []; + +/** Fresh GSTACK_HOME with the given config.yaml body (null = no file). */ +function tmpHomeWith(configYaml: string | null): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-telemetry-optout-')); + tmpHomes.push(dir); + if (configYaml !== null) { + fs.writeFileSync(path.join(dir, 'config.yaml'), configYaml); + } + process.env.GSTACK_HOME = dir; + delete process.env.GSTACK_TELEMETRY_OFF; + _resetTelemetryCache(); + return dir; +} + +afterEach(() => { + for (const k of ['GSTACK_HOME', 'GSTACK_TELEMETRY_OFF'] as const) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]!; + } + _resetTelemetryCache(); + while (tmpHomes.length) fs.rmSync(tmpHomes.pop()!, { recursive: true, force: true }); +}); + +describe('telemetry persistent opt-out tier (config.yaml)', () => { + test('DISABLED when config.yaml has plain `telemetry: off`', () => { + tmpHomeWith('telemetry: off\n'); + expect(isTelemetryDisabled()).toBe(true); + }); + + test("DISABLED when the value is single-quoted: telemetry: 'off'", () => { + tmpHomeWith("telemetry: 'off'\n"); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('DISABLED when the value is double-quoted: telemetry: "off"', () => { + tmpHomeWith('telemetry: "off"\n'); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('DISABLED with a trailing comment: telemetry: off # user opted out', () => { + tmpHomeWith('telemetry: off # user opted out\n'); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('DISABLED when the key sits among other keys', () => { + tmpHomeWith('pair_agent: off\ntelemetry: off\nskill_prefix: none\n'); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('ENABLED when the user granted the `anonymous` tier', () => { + tmpHomeWith('telemetry: anonymous\n'); + expect(isTelemetryDisabled()).toBe(false); + }); + + test('ENABLED when the user granted the `community` tier', () => { + tmpHomeWith('telemetry: community\n'); + expect(isTelemetryDisabled()).toBe(false); + }); + + test('DISABLED when the key is absent — consent was never granted', () => { + // bin/gstack-config's DEFAULTS table reports 'off' for an unset telemetry + // key; the daemon must agree or direct-$B spawns emit while the user is + // told telemetry is off (default-polarity split-brain). + tmpHomeWith('pair_agent: on\n'); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('DISABLED when config.yaml does not exist — fresh installs emit nothing', () => { + tmpHomeWith(null); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('DISABLED on an unrecognized tier value (fail-closed)', () => { + tmpHomeWith('telemetry: banana\n'); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('a commented-out consent line does not enable: `# telemetry: community`', () => { + tmpHomeWith('# telemetry: community\n'); + expect(isTelemetryDisabled()).toBe(true); + }); +}); + +describe('telemetry env tier + cache semantics', () => { + test('GSTACK_TELEMETRY_OFF=1 disables even when config says anonymous', () => { + tmpHomeWith('telemetry: anonymous\n'); + process.env.GSTACK_TELEMETRY_OFF = '1'; + _resetTelemetryCache(); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('GSTACK_TELEMETRY_OFF=0 never overrides an explicit `telemetry: off`', () => { + // The =0 hint is a harness-side consent assertion for scratch homes with + // no config store; a user's written opt-out always wins over it. + tmpHomeWith('telemetry: off\n'); + process.env.GSTACK_TELEMETRY_OFF = '0'; + _resetTelemetryCache(); + expect(isTelemetryDisabled()).toBe(true); + }); + + test('GSTACK_TELEMETRY_OFF=0 enables when no config store exists (harness seam)', () => { + tmpHomeWith(null); + process.env.GSTACK_TELEMETRY_OFF = '0'; + _resetTelemetryCache(); + expect(isTelemetryDisabled()).toBe(false); + }); + + test('verdict is cached per process; _resetTelemetryCache re-reads config', () => { + const dir = tmpHomeWith('telemetry: anonymous\n'); + expect(isTelemetryDisabled()).toBe(false); + // Opt out on disk mid-process: the cached verdict holds until reset. + fs.writeFileSync(path.join(dir, 'config.yaml'), 'telemetry: off\n'); + expect(isTelemetryDisabled()).toBe(false); + _resetTelemetryCache(); + expect(isTelemetryDisabled()).toBe(true); + }); +}); + +describe('enforcement: logTelemetry writes only with granted consent', () => { + test('config-tier opt-out suppresses the JSONL append', async () => { + const dir = tmpHomeWith('telemetry: off\n'); + logTelemetry({ event: 'domain_skill_fired', host: 'example.com' }); + // Fire-and-forget path: give any (incorrect) async append time to land. + await new Promise((r) => setTimeout(r, 30)); + expect(fs.existsSync(path.join(dir, 'analytics', 'browse-telemetry.jsonl'))).toBe(false); + }); + + test('no consent ever recorded (absent key) suppresses the JSONL append', async () => { + const dir = tmpHomeWith('pair_agent: on\n'); + logTelemetry({ event: 'domain_skill_fired', host: 'example.com' }); + await new Promise((r) => setTimeout(r, 30)); + expect(fs.existsSync(path.join(dir, 'analytics', 'browse-telemetry.jsonl'))).toBe(false); + }); + + test('granted `community` tier appends the event', async () => { + const dir = tmpHomeWith('telemetry: community\n'); + logTelemetry({ event: 'domain_skill_fired', host: 'example.com' }); + await new Promise((r) => setTimeout(r, 30)); + const file = path.join(dir, 'analytics', 'browse-telemetry.jsonl'); + expect(fs.existsSync(file)).toBe(true); + expect(fs.readFileSync(file, 'utf-8')).toContain('domain_skill_fired'); + }); +});