mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
readConfigKey / gstack_read_config_key return the most restrictive telemetry, memorable_recall, codex_reviews and update_check across the resolved root and ~/.gstack; other keys read the resolved root only. gstack-config set reports an overriding root with the exact override command, list shows the winning root and a root-variable disagreement line. gstack-gbrain-repo-policy get merges deny/read-only tiers. gstack-egress reads through readConfigKey. test-setup.ts strips inherited GSTACK_STATE_ROOT/GSTACK_STATE_DIR and redirects the legacy root.
150 lines
8.2 KiB
TypeScript
150 lines
8.2 KiB
TypeScript
/**
|
|
* Privacy and egress opt-outs never become more permissive when the state
|
|
* root moves (W1): merged keys and trust-policy deny tiers take the most
|
|
* restrictive value across the resolved root and $HOME/.gstack. These are the
|
|
* dedicated merge tests, so they unset GSTACK_TEST_LEGACY_ROOT (test-setup.ts)
|
|
* and use a fake HOME instead.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { readConfigKey } from '../lib/state-root';
|
|
import { repoPolicyTier, repoPolicyTierBatch } from '../lib/gbrain-repo-policy-client';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const CONFIG = path.join(ROOT, 'bin', 'gstack-config');
|
|
const POLICY = path.join(ROOT, 'bin', 'gstack-gbrain-repo-policy');
|
|
|
|
let tmp: string, home: string, legacy: string, resolved: string, env: Record<string, string>;
|
|
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-merge-'));
|
|
home = path.join(tmp, 'home');
|
|
legacy = path.join(home, '.gstack');
|
|
resolved = path.join(tmp, 'custom');
|
|
fs.mkdirSync(legacy, { recursive: true });
|
|
fs.mkdirSync(resolved, { recursive: true });
|
|
env = {
|
|
...(process.env as Record<string, string>),
|
|
HOME: home, GSTACK_HOME: resolved, GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '',
|
|
};
|
|
delete env.GSTACK_TEST_LEGACY_ROOT;
|
|
});
|
|
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
function config(args: string[], extra: Record<string, string> = {}) {
|
|
return spawnSync('bash', [CONFIG, ...args], { env: { ...env, ...extra }, encoding: 'utf-8', timeout: 20_000 });
|
|
}
|
|
|
|
describe('merged config keys', () => {
|
|
test('a restrictive $HOME/.gstack only applies when GSTACK_TEST_LEGACY_ROOT does not redirect the second root', () => {
|
|
fs.writeFileSync(path.join(legacy, 'config.yaml'), 'telemetry: off\n');
|
|
fs.writeFileSync(path.join(resolved, 'config.yaml'), 'telemetry: community\n');
|
|
expect(config(['get', 'telemetry']).stdout).toBe('off');
|
|
const emptyLegacy = path.join(tmp, 'empty-legacy');
|
|
fs.mkdirSync(emptyLegacy);
|
|
expect(config(['get', 'telemetry'], { GSTACK_TEST_LEGACY_ROOT: emptyLegacy }).stdout).toBe('community');
|
|
expect(readConfigKey('telemetry', { ...env, GSTACK_TEST_LEGACY_ROOT: emptyLegacy })).toBe('community');
|
|
expect(readConfigKey('telemetry', env)).toBe('off');
|
|
});
|
|
|
|
test('codex_reviews: disabled in $HOME/.gstack survives a later unrelated set in a different resolved root', () => {
|
|
fs.writeFileSync(path.join(legacy, 'config.yaml'), 'codex_reviews: disabled\n');
|
|
expect(config(['set', 'explain_level', 'terse']).status).toBe(0);
|
|
expect(config(['set', 'codex_reviews', 'enabled']).status).toBe(0);
|
|
expect(config(['get', 'codex_reviews']).stdout).toBe('disabled');
|
|
});
|
|
|
|
test('proactive: true in the resolved root wins even when $HOME/.gstack says false (not a merged key)', () => {
|
|
fs.writeFileSync(path.join(legacy, 'config.yaml'), 'proactive: false\n');
|
|
expect(config(['set', 'proactive', 'true']).status).toBe(0);
|
|
expect(config(['get', 'proactive']).stdout).toBe('true');
|
|
});
|
|
|
|
test('set names the receiving root and the overriding root, and the printed command changes the merged value', () => {
|
|
fs.writeFileSync(path.join(legacy, 'config.yaml'), 'codex_reviews: disabled\n');
|
|
const r = config(['set', 'codex_reviews', 'enabled']);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).toContain(`gstack-config: set codex_reviews in ${resolved}/config.yaml, but codex_reviews is still 'disabled': ${legacy}/config.yaml sets that more restrictive value`);
|
|
expect(r.stderr).toContain('docs/state-root.md');
|
|
const fix = r.stderr.split('\n').find((l) => l.startsWith('fix: '))!;
|
|
expect(fix).toContain(`GSTACK_STATE_ROOT='${legacy}'`);
|
|
const command = fix.slice('fix: '.length).replace(/ \(https:.*\)$/, '');
|
|
expect(spawnSync('bash', ['-c', command], { env, encoding: 'utf-8', timeout: 20_000 }).status).toBe(0);
|
|
expect(config(['get', 'codex_reviews']).stdout).toBe('enabled');
|
|
});
|
|
|
|
test('a set with no overriding root stays silent', () => {
|
|
fs.writeFileSync(path.join(legacy, 'config.yaml'), 'telemetry: anonymous\n');
|
|
const r = config(['set', 'telemetry', 'off']);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).toBe('');
|
|
});
|
|
|
|
test('list shows the winning root for merged keys and a disagreement line when root variables differ', () => {
|
|
fs.writeFileSync(path.join(legacy, 'config.yaml'), 'telemetry: off\n');
|
|
fs.writeFileSync(path.join(resolved, 'config.yaml'), 'telemetry: community\nproactive: false\n');
|
|
const plain = config(['list']).stdout;
|
|
expect(plain).toContain(`telemetry: off (set, ${legacy})`);
|
|
expect(plain).toContain('proactive: false (set)');
|
|
expect(plain).not.toContain('root-selecting variables disagree');
|
|
const other = path.join(tmp, 'other');
|
|
const disagree = config(['list'], { GSTACK_STATE_DIR: other }).stdout;
|
|
expect(disagree).toContain(`# note: root-selecting variables disagree: GSTACK_HOME=${resolved} GSTACK_STATE_DIR=${other}; using ${resolved} (GSTACK_HOME wins).`);
|
|
expect(disagree).toContain('gstack-paths --explain');
|
|
});
|
|
});
|
|
|
|
describe('trust-policy deny tiers merge through bin/gstack-gbrain-repo-policy', () => {
|
|
const url = 'https://github.com/acme/secret';
|
|
function policy(args: string[], input?: string) {
|
|
return spawnSync('bash', [POLICY, ...args], { env, input, encoding: 'utf-8', timeout: 20_000 });
|
|
}
|
|
|
|
test('a deny in $HOME/.gstack wins over read-write in the resolved root, for get, get --batch and the client', () => {
|
|
expect(policy(['set', url, 'read-write']).status).toBe(0);
|
|
fs.writeFileSync(path.join(legacy, 'gbrain-repo-policy.json'), JSON.stringify({ _schema_version: 2, 'github.com/acme/secret': 'deny' }));
|
|
expect(policy(['get', url]).stdout.trim()).toBe('deny');
|
|
expect(policy(['get', '--batch'], `${url}\nhttps://github.com/acme/other\n`).stdout).toBe('deny\nnone\n');
|
|
expect(repoPolicyTier(url, env).tier).toBe('deny');
|
|
expect(repoPolicyTierBatch([url], env).get(url)?.tier).toBe('deny');
|
|
});
|
|
|
|
test('a legacy store alone still denies, and a legacy read-write never loosens the resolved tier', () => {
|
|
fs.writeFileSync(path.join(legacy, 'gbrain-repo-policy.json'), JSON.stringify({ _schema_version: 2, 'github.com/acme/secret': 'deny', 'github.com/acme/open': 'read-write' }));
|
|
expect(repoPolicyTier(url, env).tier).toBe('deny');
|
|
expect(policy(['get', '--batch'], `${url}\n`).stdout).toBe('deny\n');
|
|
expect(policy(['set', 'https://github.com/acme/open', 'read-only']).status).toBe(0);
|
|
expect(policy(['get', 'https://github.com/acme/open']).stdout.trim()).toBe('read-only');
|
|
expect(fs.readFileSync(path.join(legacy, 'gbrain-repo-policy.json'), 'utf-8')).toContain('"read-write"');
|
|
});
|
|
});
|
|
|
|
describe('test preload hermeticity', () => {
|
|
test('bun test strips an inherited GSTACK_STATE_ROOT / GSTACK_STATE_DIR with one line each, and redirects the legacy root', () => {
|
|
const probe = path.join(tmp, 'probe.test.ts');
|
|
fs.writeFileSync(probe, [
|
|
"import { test, expect } from 'bun:test';",
|
|
"import { resolveStateRoot } from '" + path.join(ROOT, 'lib', 'state-root.ts').replace(/\\/g, '/') + "';",
|
|
"test('probe', () => {",
|
|
" expect(process.env.GSTACK_STATE_ROOT).toBeUndefined();",
|
|
" expect(process.env.GSTACK_STATE_DIR).toBeUndefined();",
|
|
" expect(process.env.GSTACK_TEST_LEGACY_ROOT).toBeTruthy();",
|
|
" expect(resolveStateRoot()).toBe(process.env.GSTACK_HOME);",
|
|
"});",
|
|
].join('\n'));
|
|
const r = spawnSync('bun', ['test', '--preload', path.join(ROOT, 'test-setup.ts'), probe], {
|
|
cwd: tmp,
|
|
env: { ...env, GSTACK_HOME: resolved, GSTACK_STATE_ROOT: path.join(tmp, 'ambient'), GSTACK_STATE_DIR: path.join(tmp, 'ambient2') },
|
|
encoding: 'utf-8',
|
|
timeout: 60_000,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).toContain('test-setup: stripped inherited GSTACK_STATE_ROOT; tests isolate state via GSTACK_HOME');
|
|
expect(r.stderr).toContain('test-setup: stripped inherited GSTACK_STATE_DIR; tests isolate state via GSTACK_HOME');
|
|
expect(r.stderr.match(/test-setup: stripped/g)?.length).toBe(2);
|
|
});
|
|
});
|