fix(browse): telemetry defaults to off like every other surface

The persistent tier defaulted ON when the config key was absent, while
gstack-config's DEFAULTS table answers 'off' for the same question —
preamble-spawned daemons and direct $B daemons disagreed about consent.
Absent key/file now means disabled; community/anonymous enable; env
kill-switch still beats everything. Both config.yaml consumers now
share one readGstackConfigYamlKey reader. 12-case consent suite.
This commit is contained in:
Garry Tan 2026-08-14 17:12:27 -07:00
parent a840d0b7df
commit 531d9a6e1f
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 245 additions and 29 deletions

View File

@ -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 <gstack home>/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;

View File

@ -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<void> {
}
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'))

View File

@ -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');
});
});