From ab3b6f02e8dfd0e94f3d7d0f308c8c3a6458255d Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 30 Sep 2026 05:47:22 +0000 Subject: [PATCH] W1: one state-root owner (lib/state-root.ts + bin/gstack-state-root.sh), gstack-paths --explain and fail-stop, parity tests --- bin/gstack-paths | 84 +++++++++++++++--- bin/gstack-state-root.sh | 125 +++++++++++++++++++++++++++ lib/state-root.ts | 99 ++++++++++++++++++++++ test/gstack-paths.test.ts | 116 +++++++++++++++++++++++++ test/state-root-parity.test.ts | 150 +++++++++++++++++++++++++++++++++ 5 files changed, 561 insertions(+), 13 deletions(-) create mode 100644 bin/gstack-state-root.sh create mode 100644 lib/state-root.ts create mode 100644 test/state-root-parity.test.ts diff --git a/bin/gstack-paths b/bin/gstack-paths index 5b66e3102..2b4da7f38 100755 --- a/bin/gstack-paths +++ b/bin/gstack-paths @@ -2,6 +2,7 @@ # gstack-paths — output portable state-root paths for skill bash blocks # Usage: eval "$(gstack-paths)" → sets GSTACK_STATE_ROOT, PLAN_ROOT, TMP_ROOT # Or: gstack-paths → prints GSTACK_STATE_ROOT=... etc. +# gstack-paths --explain → which variable selected the state root, and why # # Resolves three roots with explicit fallback chains so skills work the same # whether installed as a Claude Code plugin (CLAUDE_PLUGIN_DATA / CLAUDE_PLANS_DIR @@ -9,9 +10,18 @@ # CI / container env where HOME may be unset. # # Chains: -# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*) -> $HOME/.gstack -> .gstack +# GSTACK_STATE_ROOT: GSTACK_STATE_ROOT -> GSTACK_HOME -> GSTACK_STATE_DIR +# -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*) +# -> $HOME/.gstack -> .gstack +# (one rule, owned by bin/gstack-state-root.sh; set GSTACK_HOME +# to relocate state; GSTACK_STATE_ROOT is this script's output, +# also honored as input; GSTACK_STATE_DIR is a legacy alias) # PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans # TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort) +# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md +# +# Callers guard the eval, because eval "$(f)" succeeds even when f fails: +# eval "$(gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" # # Output: values are emitted shell-quoted (printf %q) so `eval` round-trips them # byte-for-byte. This matters on Windows, where $TMP is a backslash path like @@ -24,19 +34,67 @@ # reason any path variable needs quoting. set -u +_GSTACK_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" +_GSTACK_TWIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/gstack-state-root.sh" + +# Fail stop: never print a payload that would set an empty GSTACK_STATE_ROOT. +_gstack_paths_fail() { + echo "gstack-paths: cannot resolve the gstack state root: $1" >&2 + echo "fix: reinstall gstack with ./setup (in the gstack checkout) or /gstack-upgrade. Docs: $_GSTACK_DOC" >&2 + exit 1 +} +if [ ! -f "$_GSTACK_TWIN" ] || ! . "$_GSTACK_TWIN" 2>/dev/null || ! command -v gstack_state_root_select >/dev/null 2>&1; then + _gstack_paths_fail "missing or broken $_GSTACK_TWIN" +fi + # State root: where gstack writes projects/, sessions/, analytics/. -if [ -n "${GSTACK_HOME:-}" ]; then - _state_root="$GSTACK_HOME" -elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && echo "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then - # Guard: only trust CLAUDE_PLUGIN_DATA when CLAUDE_PLUGIN_ROOT confirms we are - # running as the gstack plugin. Without this, a CLAUDE_PLUGIN_DATA from another - # plugin (e.g. codex) that leaked into the session env via CLAUDE_ENV_FILE would - # be picked up, writing all gstack state into the wrong directory. - _state_root="$CLAUDE_PLUGIN_DATA" -elif [ -n "${HOME:-}" ]; then - _state_root="$HOME/.gstack" -else - _state_root=".gstack" +gstack_state_root_select +_state_root="$_gstack_sr_root" +[ -n "$_state_root" ] || _gstack_paths_fail "the resolver in $_GSTACK_TWIN returned an empty root" + +if [ "${1:-}" = "--explain" ]; then + echo "state root: $_state_root (selected by $_gstack_sr_var)" + echo "chain (first non-empty wins):" + _gstack_sel_seen=0 + for _v in GSTACK_STATE_ROOT GSTACK_HOME GSTACK_STATE_DIR CLAUDE_PLUGIN_DATA; do + eval "_val=\${$_v:-}" + if [ -z "$_val" ]; then + printf ' %-20s unset\n' "$_v" + elif [ "$_v" = "$_gstack_sr_var" ]; then + printf ' %-20s %s selected\n' "$_v" "$_val" + elif [ "$_v" = CLAUDE_PLUGIN_DATA ] && [ "$_gstack_sr_var" = default ]; then + printf ' %-20s %s ignored (CLAUDE_PLUGIN_ROOT does not contain "gstack")\n' "$_v" "$_val" + else + printf ' %-20s %s ignored\n' "$_v" "$_val" + fi + done + _gstack_user_home + if [ -n "$_gstack_home_val" ]; then + _gstack_default="$_gstack_home_val/.gstack" + else + _gstack_default=".gstack" + fi + if [ "$_gstack_sr_var" = default ]; then + printf ' %-20s %s selected\n' "default" "$_gstack_default" + else + printf ' %-20s %s ignored\n' "default" "$_gstack_default" + _gstack_has_state=no + for _e in "$_gstack_default"/* "$_gstack_default"/.[!.]*; do + [ -e "$_e" ] && { _gstack_has_state=yes; break; } + done + echo "default root $_gstack_default also holds gstack state: $_gstack_has_state" + fi + echo "merged privacy keys (most restrictive value across roots wins):" + for _k in telemetry memorable_recall codex_reviews update_check; do + gstack_config_select "$_k" + if [ -n "$_gstack_cfg_value" ]; then + printf ' %-17s %s (from %s/config.yaml)\n' "$_k:" "$_gstack_cfg_value" "$_gstack_cfg_root" + else + printf ' %-17s not set (default applies)\n' "$_k:" + fi + done + echo "docs: $_GSTACK_DOC" + exit 0 fi # Plan root: where /context-save and /codex consult write plan files. diff --git a/bin/gstack-state-root.sh b/bin/gstack-state-root.sh new file mode 100644 index 000000000..236f2e27b --- /dev/null +++ b/bin/gstack-state-root.sh @@ -0,0 +1,125 @@ +# shellcheck shell=bash +# gstack-state-root.sh — the one bash owner of where gstack keeps its state +# (twin of lib/state-root.ts; test/state-root-parity.test.ts keeps them equal). +# Sourced, never executed: bin/gstack-paths and the careful/freeze hooks source +# it; other executables run `eval "$(/gstack-paths)"` plus the +# `: "${GSTACK_STATE_ROOT:?...}"` guard. Chain: GSTACK_STATE_ROOT → GSTACK_HOME → +# GSTACK_STATE_DIR → CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT contains +# "gstack") → $HOME/.gstack → .gstack. Bash builtins only (bash 3.2, no +# subprocess): hooks run it on every tool call. Enforced by +# test/state-root-ratchet.test.ts. Generalizes careful/bin/hook-extract.sh's +# former gstack_hook_state_root. Docs: docs/state-root.md. + +# _gstack_user_home — HOME, or USERPROFILE on Windows shells when HOME is unset. +_gstack_user_home() { + _gstack_home_val="${HOME:-}" + if [ -z "$_gstack_home_val" ]; then + case "${OSTYPE:-}" in + msys*|cygwin*|win32*) _gstack_home_val="${USERPROFILE:-}" ;; + esac + fi +} + +# gstack_state_root_select — sets _gstack_sr_root and _gstack_sr_var (the +# variable that selected it, or "default") without printing. +gstack_state_root_select() { + _gstack_user_home + if [ -n "${GSTACK_STATE_ROOT:-}" ]; then + _gstack_sr_root="$GSTACK_STATE_ROOT"; _gstack_sr_var="GSTACK_STATE_ROOT" + elif [ -n "${GSTACK_HOME:-}" ]; then + _gstack_sr_root="$GSTACK_HOME"; _gstack_sr_var="GSTACK_HOME" + elif [ -n "${GSTACK_STATE_DIR:-}" ]; then + _gstack_sr_root="$GSTACK_STATE_DIR"; _gstack_sr_var="GSTACK_STATE_DIR" + else + _gstack_sr_root="" + if [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then + case "${CLAUDE_PLUGIN_ROOT:-}" in + *[gG][sS][tT][aA][cC][kK]*) _gstack_sr_root="$CLAUDE_PLUGIN_DATA"; _gstack_sr_var="CLAUDE_PLUGIN_DATA" ;; + esac + fi + if [ -z "$_gstack_sr_root" ]; then + if [ -n "$_gstack_home_val" ]; then + _gstack_sr_root="$_gstack_home_val/.gstack"; _gstack_sr_var="default" + else + _gstack_sr_root=".gstack"; _gstack_sr_var="default" + fi + fi + fi +} + +# gstack_state_root — print the state root WITHOUT a trailing newline. Capture +# with a sentinel so a root ending in a newline round-trips exactly: +# r="$(gstack_state_root; printf x)"; r="${r%x}" +gstack_state_root() { + gstack_state_root_select + printf '%s' "$_gstack_sr_root" +} + +# _gstack_config_from_root ROOT KEY — sets _gstack_cfg_one to the value of the +# last `KEY:` line in ROOT/config.yaml, trimmed ("" when absent). Same parse as +# `gstack-config get`. +_gstack_config_from_root() { + _gstack_cfg_one="" + [ -f "$1/config.yaml" ] || return 0 + while IFS= read -r _gstack_cfg_line || [ -n "$_gstack_cfg_line" ]; do + case "$_gstack_cfg_line" in + "$2":*) + _gstack_cfg_one="${_gstack_cfg_line#"$2":}" + _gstack_cfg_one="${_gstack_cfg_one#"${_gstack_cfg_one%%[![:space:]]*}"}" + _gstack_cfg_one="${_gstack_cfg_one%"${_gstack_cfg_one##*[![:space:]]}"}" + ;; + esac + done < "$1/config.yaml" +} + +# _gstack_config_rank KEY VALUE — sets _gstack_cfg_rank: position in the key's +# most-restrictive-first order, -1 for an unrecognized value, "" when KEY is +# not a merged key. +_gstack_config_rank() { + case "$1" in + telemetry) case "$2" in off) _gstack_cfg_rank=0 ;; anonymous) _gstack_cfg_rank=1 ;; community) _gstack_cfg_rank=2 ;; *) _gstack_cfg_rank=-1 ;; esac ;; + memorable_recall) case "$2" in off) _gstack_cfg_rank=0 ;; on) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;; + codex_reviews) case "$2" in disabled) _gstack_cfg_rank=0 ;; enabled) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;; + update_check) case "$2" in false) _gstack_cfg_rank=0 ;; true) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;; + *) _gstack_cfg_rank="" ;; + esac +} + +# gstack_config_select KEY — sets _gstack_cfg_value and _gstack_cfg_root (the +# root that supplied it; both "" when unset). Merged privacy keys (telemetry, +# memorable_recall, codex_reviews, update_check) take the most restrictive +# value across the resolved root and $HOME/.gstack; every other key reads the +# resolved root only. GSTACK_TEST_LEGACY_ROOT replaces $HOME/.gstack in tests. +gstack_config_select() { + gstack_state_root_select + _gstack_config_from_root "$_gstack_sr_root" "$1" + _gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root="" + [ -n "$_gstack_cfg_value" ] && _gstack_cfg_root="$_gstack_sr_root" + _gstack_config_rank "$1" "x" + [ -n "$_gstack_cfg_rank" ] || return 0 + if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then + _gstack_cfg_legacy="$GSTACK_TEST_LEGACY_ROOT" + elif [ -n "$_gstack_home_val" ]; then + _gstack_cfg_legacy="$_gstack_home_val/.gstack" + else + return 0 + fi + [ "$_gstack_cfg_legacy" = "$_gstack_sr_root" ] && return 0 + _gstack_config_from_root "$_gstack_cfg_legacy" "$1" + [ -n "$_gstack_cfg_one" ] || return 0 + if [ -z "$_gstack_cfg_value" ]; then + _gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root="$_gstack_cfg_legacy" + return 0 + fi + _gstack_config_rank "$1" "$_gstack_cfg_value"; _gstack_cfg_best="$_gstack_cfg_rank" + _gstack_config_rank "$1" "$_gstack_cfg_one" + if [ "$_gstack_cfg_rank" -lt "$_gstack_cfg_best" ]; then + _gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root="$_gstack_cfg_legacy" + fi +} + +# gstack_read_config_key KEY — print the (merged) value, no trailing newline. +gstack_read_config_key() { + gstack_config_select "$1" + printf '%s' "$_gstack_cfg_value" +} diff --git a/lib/state-root.ts b/lib/state-root.ts new file mode 100644 index 000000000..8ae563763 --- /dev/null +++ b/lib/state-root.ts @@ -0,0 +1,99 @@ +/** + * state-root — the one owner of where gstack keeps its state (TS twin of + * bin/gstack-state-root.sh; test/state-root-parity.test.ts keeps them equal). + * Add a caller: `join(resolveStateRoot(), 'analytics')` instead of joining + * homedir() with '.gstack'; read a config key with `readConfigKey('telemetry')`. + * Chain: GSTACK_STATE_ROOT → GSTACK_HOME → GSTACK_STATE_DIR → CLAUDE_PLUGIN_DATA + * (only when CLAUDE_PLUGIN_ROOT contains "gstack") → $HOME/.gstack → .gstack. + * Enforced by test/state-root-ratchet.test.ts. Replaces the hand-rolled chains + * formerly in browse/src/config.ts and lib/cso/state.ts. Docs: docs/state-root.md. + */ +import * as fs from 'node:fs'; + +export type StateRootEnv = Record; + +export const STATE_ROOT_VARS = ['GSTACK_STATE_ROOT', 'GSTACK_HOME', 'GSTACK_STATE_DIR'] as const; + +function userHome(env: StateRootEnv, platform: NodeJS.Platform): string { + return env.HOME || (platform === 'win32' ? env.USERPROFILE || '' : ''); +} + +/** The raw chain value. Pure: never prints, never touches the filesystem. */ +export function resolveStateRoot(env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): string { + for (const name of STATE_ROOT_VARS) { + if (env[name]) return env[name] as string; + } + if (env.CLAUDE_PLUGIN_DATA && (env.CLAUDE_PLUGIN_ROOT || '').toLowerCase().includes('gstack')) return env.CLAUDE_PLUGIN_DATA; + const home = userHome(env, platform); + return home ? `${home}/.gstack` : '.gstack'; +} + +/** + * The default root a merged privacy key is also read from. Tests point + * GSTACK_TEST_LEGACY_ROOT at an empty dir (test-setup.ts) so a developer's + * real ~/.gstack never leaks into a run. + */ +function legacyStateRoot(env: StateRootEnv, platform: NodeJS.Platform): string | null { + if (env.GSTACK_TEST_LEGACY_ROOT) return env.GSTACK_TEST_LEGACY_ROOT; + const home = userHome(env, platform); + return home ? `${home}/.gstack` : null; +} + +/** Privacy and egress opt-outs: most restrictive first. Read from every candidate root. */ +export const MERGED_CONFIG_KEYS: Record = { + telemetry: ['off', 'anonymous', 'community'], + memorable_recall: ['off', 'on'], + codex_reviews: ['disabled', 'enabled'], + update_check: ['false', 'true'], +}; + +/** Same parse as `gstack-config get`: last `^key:` line wins, value trimmed, empty = unset. */ +function readKeyFromRoot(root: string, key: string): string | null { + let yaml: string; + try { + yaml = fs.readFileSync(`${root}/config.yaml`, 'utf-8'); + } catch { + return null; + } + let value: string | null = null; + for (const line of yaml.split('\n')) { + if (line.startsWith(`${key}:`)) value = line.slice(key.length + 1).trim(); + } + return value ? value : null; +} + +export interface ConfigKeyReading { + value: string | null; + /** Root whose config.yaml supplied the value (null when no root sets it). */ + root: string | null; +} + +/** + * Read one config key. Merged keys (MERGED_CONFIG_KEYS) take the most + * restrictive value across the resolved root and $HOME/.gstack; an + * unrecognized value ranks as most restrictive. Every other key reads the + * resolved root only. + */ +export function readConfigKeyWithRoot(key: string, env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): ConfigKeyReading { + const resolved = resolveStateRoot(env, platform); + const order = MERGED_CONFIG_KEYS[key]; + const candidates = [resolved]; + const legacy = order ? legacyStateRoot(env, platform) : null; + if (legacy && legacy !== resolved) candidates.push(legacy); + let best: ConfigKeyReading = { value: null, root: null }; + let bestRank = Infinity; + for (const root of candidates) { + const value = readKeyFromRoot(root, key); + if (value === null) continue; + const rank = order ? order.indexOf(value) : 0; + if (rank < bestRank) { + best = { value, root }; + bestRank = rank; + } + } + return best; +} + +export function readConfigKey(key: string, env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform): string | null { + return readConfigKeyWithRoot(key, env, platform).value; +} diff --git a/test/gstack-paths.test.ts b/test/gstack-paths.test.ts index a8a71c8f5..eee62b5b4 100644 --- a/test/gstack-paths.test.ts +++ b/test/gstack-paths.test.ts @@ -271,3 +271,119 @@ describe('CEO plan persistence uses the selected state root', () => { } } }); + +describe('gstack-paths state-root contract (W1)', () => { + const DOC = 'https://github.com/garrytan/gstack/blob/main/docs/state-root.md'; + function runRaw(args: string[], env: Record, bin = BIN) { + return spawnSync('bash', [bin, ...args], { + env: { PATH: process.env.PATH, USERPROFILE: '', TMPDIR: os.tmpdir(), ...env } as Record, + encoding: 'utf-8', + timeout: 30_000, + }); + } + + test('GSTACK_STATE_ROOT and GSTACK_STATE_DIR join the chain in declared precedence', () => { + expect(run({ HOME: '/tmp/home', GSTACK_STATE_ROOT: '/tmp/a', GSTACK_HOME: '/tmp/b' }).GSTACK_STATE_ROOT).toBe('/tmp/a'); + expect(run({ HOME: '/tmp/home', GSTACK_STATE_DIR: '/tmp/c' }).GSTACK_STATE_ROOT).toBe('/tmp/c'); + expect(run({ HOME: '/tmp/home', GSTACK_HOME: '/tmp/b', GSTACK_STATE_DIR: '/tmp/c' }).GSTACK_STATE_ROOT).toBe('/tmp/b'); + expect(run({ HOME: '/tmp/home', GSTACK_STATE_DIR: '/tmp/c', CLAUDE_PLUGIN_DATA: '/tmp/p', CLAUDE_PLUGIN_ROOT: '/x/gstack' }).GSTACK_STATE_ROOT).toBe('/tmp/c'); + }); + + test('reading its own output back as input returns the same root', () => { + const env = { HOME: '/tmp/home', GSTACK_HOME: '/tmp/b', GSTACK_STATE_DIR: '/tmp/c' }; + const first = run(env).GSTACK_STATE_ROOT; + expect(run({ ...env, GSTACK_STATE_ROOT: first }).GSTACK_STATE_ROOT).toBe(first); + }); + + test('stderr is empty on success, even with disagreeing root variables', () => { + const r = runRaw([], { HOME: '/tmp/home', GSTACK_STATE_ROOT: '/tmp/a', GSTACK_HOME: '/tmp/b', GSTACK_STATE_DIR: '/tmp/c' }); + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + }); + + test('--explain golden: default environment', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-explain-')); + try { + const r = runRaw(['--explain'], { HOME: home, GSTACK_TEST_LEGACY_ROOT: '' }); + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + expect(r.stdout).toBe([ + `state root: ${home}/.gstack (selected by default)`, + 'chain (first non-empty wins):', + ' GSTACK_STATE_ROOT unset', + ' GSTACK_HOME unset', + ' GSTACK_STATE_DIR unset', + ' CLAUDE_PLUGIN_DATA unset', + ` default ${home}/.gstack selected`, + 'merged privacy keys (most restrictive value across roots wins):', + ' telemetry: not set (default applies)', + ' memorable_recall: not set (default applies)', + ' codex_reviews: not set (default applies)', + ' update_check: not set (default applies)', + `docs: ${DOC}`, + '', + ].join('\n')); + expect(fs.readdirSync(home)).toEqual([]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('--explain golden: disagreeing environment with state in $HOME/.gstack', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-explain-')); + const home = path.join(tmp, 'home'); + const a = path.join(tmp, 'a'); + try { + fs.mkdirSync(path.join(home, '.gstack'), { recursive: true }); + fs.writeFileSync(path.join(home, '.gstack', 'config.yaml'), 'telemetry: off\ncodex_reviews: disabled\n'); + fs.mkdirSync(a); + fs.writeFileSync(path.join(a, 'config.yaml'), 'telemetry: community\nupdate_check: true\n'); + const r = runRaw(['--explain'], { + HOME: home, GSTACK_TEST_LEGACY_ROOT: '', GSTACK_STATE_ROOT: a, GSTACK_HOME: '/tmp/b', + CLAUDE_PLUGIN_DATA: '/tmp/p', CLAUDE_PLUGIN_ROOT: '/plugins/codex', + }); + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + expect(r.stdout).toBe([ + `state root: ${a} (selected by GSTACK_STATE_ROOT)`, + 'chain (first non-empty wins):', + ` GSTACK_STATE_ROOT ${a} selected`, + ' GSTACK_HOME /tmp/b ignored', + ' GSTACK_STATE_DIR unset', + ' CLAUDE_PLUGIN_DATA /tmp/p ignored', + ` default ${home}/.gstack ignored`, + `default root ${home}/.gstack also holds gstack state: yes`, + 'merged privacy keys (most restrictive value across roots wins):', + ` telemetry: off (from ${home}/.gstack/config.yaml)`, + ' memorable_recall: not set (default applies)', + ` codex_reviews: disabled (from ${home}/.gstack/config.yaml)`, + ` update_check: true (from ${a}/config.yaml)`, + `docs: ${DOC}`, + '', + ].join('\n')); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('a missing bin/gstack-state-root.sh fails stop: nonzero, empty stdout, message, no writes', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-paths-broken-')); + try { + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + fs.copyFileSync(BIN, path.join(bin, 'gstack-paths')); + const tmpRoot = path.join(tmp, 'tmproot'); + const r = runRaw([], { HOME: path.join(tmp, 'home'), TMPDIR: tmpRoot }, path.join(bin, 'gstack-paths')); + expect(r.status).not.toBe(0); + expect(r.stdout).toBe(''); + expect(r.stderr).toContain('gstack-paths: cannot resolve the gstack state root'); + expect(r.stderr).toContain(path.join(bin, 'gstack-state-root.sh')); + expect(r.stderr).toContain('./setup'); + expect(r.stderr).toContain('/gstack-upgrade'); + expect(r.stderr).toContain(DOC); + expect(fs.readdirSync(tmp).sort()).toEqual(['bin']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/state-root-parity.test.ts b/test/state-root-parity.test.ts new file mode 100644 index 000000000..295411129 --- /dev/null +++ b/test/state-root-parity.test.ts @@ -0,0 +1,150 @@ +/** + * Parity: lib/state-root.ts (TS) and bin/gstack-state-root.sh (bash) are the + * two owners of the one state-root chain. Every row runs through both with an + * empty PATH (the bash twin must use builtins only — hooks call it on every + * tool use) and must produce byte-identical roots and config readings. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveStateRoot, readConfigKey } from '../lib/state-root'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const TWIN = path.join(ROOT, 'bin', 'gstack-state-root.sh').replace(/\\/g, '/'); +const BASH = Bun.which('bash') ?? '/bin/bash'; + +type Row = { name: string; env: Record; win?: boolean }; + +function bashEval(row: Row, body: string): string { + const ostype = row.win ? 'msys' : 'linux-gnu'; + const r = spawnSync(BASH, ['-c', `OSTYPE=${ostype}; . "${TWIN}" && ${body}`], { + env: { USERPROFILE: '', ...row.env, PATH: '' }, + encoding: 'utf-8', + timeout: 10_000, + }); + if (r.status !== 0) throw new Error(`bash twin failed for ${row.name}: ${r.stderr}`); + return r.stdout; +} + +function tsEnv(row: Row): Record { + return { USERPROFILE: '', ...row.env }; +} + +const A = '/state/a', B = '/state/b', C = '/state/c'; +const rootRows: Row[] = [ + { name: 'nothing set, HOME set', env: { HOME: '/home/u' } }, + { name: 'GSTACK_STATE_ROOT alone', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A } }, + { name: 'GSTACK_HOME alone', env: { HOME: '/home/u', GSTACK_HOME: B } }, + { name: 'GSTACK_STATE_DIR alone', env: { HOME: '/home/u', GSTACK_STATE_DIR: C } }, + { name: 'STATE_ROOT + HOME differ', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A, GSTACK_HOME: B } }, + { name: 'STATE_ROOT + STATE_DIR differ', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A, GSTACK_STATE_DIR: C } }, + { name: 'GSTACK_HOME + STATE_DIR differ', env: { HOME: '/home/u', GSTACK_HOME: B, GSTACK_STATE_DIR: C } }, + { name: 'all three differ', env: { HOME: '/home/u', GSTACK_STATE_ROOT: A, GSTACK_HOME: B, GSTACK_STATE_DIR: C } }, + { name: 'plugin data with gstack plugin root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' } }, + { name: 'plugin data with mixed-case gstack root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/GStack-1.2' } }, + { name: 'plugin data with foreign plugin root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/codex' } }, + { name: 'plugin data without plugin root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug' } }, + { name: 'GSTACK_STATE_DIR beats plugin data', env: { HOME: '/home/u', GSTACK_STATE_DIR: C, CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' } }, + { name: 'empty strings count as unset', env: { HOME: '/home/u', GSTACK_STATE_ROOT: '', GSTACK_HOME: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' } }, + { name: 'empty STATE_ROOT falls to GSTACK_HOME', env: { HOME: '/home/u', GSTACK_STATE_ROOT: '', GSTACK_HOME: B } }, + { name: 'empty plugin data with gstack root', env: { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' } }, + { name: 'unset HOME', env: {} }, + { name: 'empty HOME', env: { HOME: '' } }, + { name: 'unset HOME ignores USERPROFILE off Windows', env: { USERPROFILE: 'C:/Users/u' } }, + { name: 'path with spaces and newline', env: { HOME: '/home/u', GSTACK_HOME: '/state/with space\n' } }, + { name: 'Windows: HOME unset uses USERPROFILE', env: { USERPROFILE: 'C:/Users/u' }, win: true }, + { name: 'Windows: HOME wins over USERPROFILE', env: { HOME: '/c/Users/h', USERPROFILE: 'C:/Users/u' }, win: true }, + { name: 'Windows: nothing set', env: {}, win: true }, + { name: 'Windows: GSTACK_HOME backslash path', env: { GSTACK_HOME: 'C:\\gstack\\state', USERPROFILE: 'C:/Users/u' }, win: true }, +]; + +describe('state root parity (bash twin vs lib/state-root.ts)', () => { + for (const row of rootRows) { + test(row.name, () => { + const bash = bashEval(row, 'gstack_state_root; printf x').replace(/x$/, ''); + expect(bash).toBe(resolveStateRoot(tsEnv(row), row.win ? 'win32' : 'linux')); + }); + } + + test('precedence is GSTACK_STATE_ROOT, GSTACK_HOME, GSTACK_STATE_DIR, gstack plugin data, $HOME/.gstack, .gstack', () => { + const base = { HOME: '/home/u', CLAUDE_PLUGIN_DATA: '/plug', CLAUDE_PLUGIN_ROOT: '/x/gstack' }; + expect(resolveStateRoot({ ...base, GSTACK_STATE_ROOT: A, GSTACK_HOME: B, GSTACK_STATE_DIR: C }, 'linux')).toBe(A); + expect(resolveStateRoot({ ...base, GSTACK_HOME: B, GSTACK_STATE_DIR: C }, 'linux')).toBe(B); + expect(resolveStateRoot({ ...base, GSTACK_STATE_DIR: C }, 'linux')).toBe(C); + expect(resolveStateRoot(base, 'linux')).toBe('/plug'); + expect(resolveStateRoot({ HOME: '/home/u' }, 'linux')).toBe('/home/u/.gstack'); + expect(resolveStateRoot({}, 'linux')).toBe('.gstack'); + }); +}); + +describe('config key parity (gstack_read_config_key vs readConfigKey)', () => { + let tmp: string; + let resolved: string, home: string, legacyOverride: string; + + beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cfg-parity-')); + resolved = path.join(tmp, 'resolved'); + home = path.join(tmp, 'home'); + legacyOverride = path.join(tmp, 'legacy'); + fs.mkdirSync(resolved, { recursive: true }); + fs.mkdirSync(path.join(home, '.gstack'), { recursive: true }); + fs.mkdirSync(legacyOverride, { recursive: true }); + fs.writeFileSync(path.join(resolved, 'config.yaml'), [ + '# telemetry: off (comment lines never match)', + 'telemetry: community', + 'codex_reviews: enabled', + 'update_check: true ', + 'memorable_recall: on\r', + 'proactive: true', + 'proactive: false', + 'founder_resources: true', + 'empty_key:', + ].join('\n')); + fs.writeFileSync(path.join(home, '.gstack', 'config.yaml'), [ + 'telemetry: anonymous', + 'codex_reviews: disabled', + 'memorable_recall: bogus', + 'proactive: true', + 'founder_resources: false', + 'only_legacy: yes', + ].join('\n')); + fs.writeFileSync(path.join(legacyOverride, 'config.yaml'), 'telemetry: off\nupdate_check: false'); + }); + afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + + const keys = ['telemetry', 'codex_reviews', 'update_check', 'memorable_recall', 'proactive', 'founder_resources', 'empty_key', 'only_legacy', 'missing']; + + test('every key reads identically through both twins, with and without the test legacy override', () => { + const envs: Row[] = [ + { name: 'resolved + real home', env: { HOME: home, GSTACK_HOME: resolved } }, + { name: 'legacy override', env: { HOME: home, GSTACK_HOME: resolved, GSTACK_TEST_LEGACY_ROOT: legacyOverride } }, + { name: 'resolved is the default root', env: { HOME: home } }, + { name: 'no HOME', env: { GSTACK_HOME: resolved } }, + { name: 'Windows USERPROFILE home', env: { USERPROFILE: home, GSTACK_HOME: resolved }, win: true }, + ]; + for (const row of envs) { + for (const key of keys) { + const bash = bashEval(row, `gstack_read_config_key ${key}; printf x`).replace(/x$/, ''); + const ts = readConfigKey(key, tsEnv(row), row.win ? 'win32' : 'linux') ?? ''; + expect(`${row.name}/${key}=${bash}`).toBe(`${row.name}/${key}=${ts}`); + } + } + }); + + test('merged keys take the most restrictive value; other keys read the resolved root only', () => { + const env = { HOME: home, GSTACK_HOME: resolved }; + expect(readConfigKey('telemetry', env, 'linux')).toBe('anonymous'); + expect(readConfigKey('codex_reviews', env, 'linux')).toBe('disabled'); + expect(readConfigKey('update_check', env, 'linux')).toBe('true'); + expect(readConfigKey('memorable_recall', env, 'linux')).toBe('bogus'); + expect(readConfigKey('proactive', env, 'linux')).toBe('false'); + expect(readConfigKey('founder_resources', env, 'linux')).toBe('true'); + expect(readConfigKey('only_legacy', env, 'linux')).toBeNull(); + expect(readConfigKey('empty_key', env, 'linux')).toBeNull(); + const overridden = { ...env, GSTACK_TEST_LEGACY_ROOT: legacyOverride }; + expect(readConfigKey('telemetry', overridden, 'linux')).toBe('off'); + expect(readConfigKey('codex_reviews', overridden, 'linux')).toBe('enabled'); + }); +});