mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 08:59:01 +02:00
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:
+28
-7
@@ -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;
|
||||
|
||||
+42
-22
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user