Files
gstack/test/provider-model-defaults.test.ts
T
Garry Tanandcapy-ai[bot] 3766edac8d v1.91.144.0 feat(evals): Opus 5.5 is the paid gate's model under test; judge stays Fable (GSTA-49 TM1) (#3275)
* feat(evals): Opus 5.5 is the paid gate's model under test; judge stays Fable (GSTA-49 TM1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.140.0 chore: stamp TM1 (Opus 5.5 model under test) and CHANGELOG

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): repair the five Opus gate reds from run 39161502626

- coverage audit: owned cd; followed by one && chain, wc -l on in-cwd files
- shared-libs: Claude Code 2.1.292 git --version probe, exact argv only
- ship hook: accept 'integrate the hook yourself' / 'combine ... by hand'
- qa-only: deadline starts only after the charter Write completes
- plan floor: tool-only parent session identifies the owned Create grant;
  floor runner fails fast with PERMISSION_DIALOG_UNANSWERED

Each repair replays its captured run as a free regression with negative
controls. HARNESS_VERSION stays 6 (re-recorded from main's 5).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): ship hook manual-integration sentence rule; qa-only inspection uses Read/Glob; tool-only permission tests join their owner file

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): tpa-absent-linux accepts instructions the user follows themselves as the manual option

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): owned plan Edit menu binds to exactly one pending Edit, including unpublished ones

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): shared-libs read evidence counts a literal path loop whose output names each file

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: restamp to v1.91.142.0 (queue slot moved)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): investigate boundary fixture identifies the owner question by its continue/abort options

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): plan-design-with-ui focus check accepts the skill's design-completeness rating in the question body

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): phrasing sweep across the 108 gate rule cases; replay captured Opus paraphrases with negative controls

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): command-detector sweep — exact-count anchor, label slot, 2.1.292 worktree probe

- coverage audit (gate 39207508651 plan-eng-coverage-audit): output of exactly
  known length (wc -l over literal in-cwd files) anchors the next owned read
  at that line; a label's slot counts from the end and a later complete copy
  voids the credit (review/specialists/testing.md prints '---')
- shared-libs-unsupported-git (39161502626): Claude Code 2.1.292's askPass-first
  'worktree list --porcelain -z' cwd probe is Claude's own; the read-only Git
  check is shared (repoGitRequests, isReadOnlyRepoGitRequest) and the free
  regression replays every Git request of that capture

Each fix replays its captured command with negative controls. No threshold,
trial count, case kind or judge changed. HARNESS_VERSION stays 6.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-19 07:26:49 +00:00

371 lines
24 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { assertClaudeProducerSandboxPolicy, ClaudeAdapter, claudeExecArgs, claudeExecEnvironment, claudeExecWorkingDirectory, claudeProducerPaths, resultFromClaudeOutput } from './helpers/providers/claude';
import { codexExecArgs, codexExecEnvironment, codexExecWorkingDirectory, codexProducerConfig, codexProducerPaths, GptAdapter, resultFromCodexStream } from './helpers/providers/gpt';
import { geminiExecArgs, geminiExecEnvironment, geminiExecWorkingDirectory, geminiProducerPaths, geminiProducerSystemSettings } from './helpers/providers/gemini';
import { CSO_PRODUCER_SHELL_ENV, csoProducerChildEnvironment } from './helpers/providers/types';
const ENV_KEYS = ['PATH', 'GSTACK_CLAUDE_BIN', 'GSTACK_CLAUDE_BIN_ARGS',
'GSTACK_CODEX_MODEL', 'EVALS_MODEL', 'GSTACK_EVAL_MODEL', 'GSTACK_EVAL_MODEL_CAPTURE'];
let saved: Record<string, string | undefined>;
let workdir: string;
describe('CSO producer provider policies', () => {
const suffix = process.platform === 'win32' ? '.exe' : '';
const helperAt = (directory: string) => join(directory, `gstack-cso-launcher${suffix}`);
const providerAt = (directory: string) => ({ executable: join(directory, `provider${suffix}`), argsPrefix: [] as string[] });
const policy = (stateDirectory: string, sourceDirectory: string, helperLauncher: string) => ({
stateDirectory, sourceDirectory, helperLauncher, helperGeneration: join(dirname(helperLauncher), '.gstack-cso-generation'), providerCommand: providerAt(dirname(helperLauncher)),
});
test('keeps exact Codex read-only defaults and gives the producer a custom least-privilege profile', () => {
const providerWorkdir = join(tmpdir(), 'gstack-provider-work');
const common = { prompt: 'Reply OK', workdir: providerWorkdir, timeoutMs: 5000 };
expect(codexExecArgs(common, 'gpt-test')).toEqual([
'exec', 'Reply OK', '-C', providerWorkdir,
'-s', 'read-only', '--skip-git-repo-check', '--json', '-m', 'gpt-test',
]);
const state = join(providerWorkdir, 'state');
const source = join(providerWorkdir, 'source');
const helper = helperAt(join(providerWorkdir, 'installed'));
const producer = { ...common, csoProducer: policy(state, source, helper) };
expect(codexExecArgs(producer, 'gpt-test')).toEqual([
'exec', 'Reply OK', '-C', codexProducerPaths(state).workdir,
'--strict-config', '--ephemeral', '--ignore-rules', '--skip-git-repo-check',
'--json', '-m', 'gpt-test',
]);
expect(codexExecWorkingDirectory(common)).toBe(providerWorkdir);
expect(codexExecWorkingDirectory(producer)).toBe(codexProducerPaths(state).workdir);
const config = codexProducerConfig(producer);
expect(config).toContain('default_permissions = "cso-producer"');
expect(config).toContain('":root" = "deny"\n":minimal" = "read"');
expect(config).toContain('[permissions.cso-producer.filesystem.":workspace_roots"]\n"." = "write"');
expect(config).toContain('[permissions.cso-producer.network]\nenabled = false');
expect(config).toContain(`include_only = ${JSON.stringify(CSO_PRODUCER_SHELL_ENV)}`);
expect(config).toContain(`${JSON.stringify(join(state, 'cso-home'))} = "write"`);
for (const admitted of [
join(dirname(helper), `cso-eval-producer${suffix}`), helper,
join(dirname(helper), `gstack-cso-core${suffix}`), join(dirname(helper), `gstack-cso-watchdog${suffix}`),
join(dirname(helper), '.gstack-cso-generation'), source,
]) expect(config).toContain(`${JSON.stringify(admitted)} = "read"`);
expect(config).not.toContain(`${JSON.stringify(dirname(helper))} = "read"`);
expect(config).not.toContain(`${JSON.stringify(dirname(source))} = "read"`);
expect(config).not.toContain('OPENAI_API_KEY');
expect(() => codexExecArgs({ ...common, csoProducer: policy('relative-state', source, helper) }, 'gpt-test')).toThrow('absolute normalized path');
expect(() => codexExecArgs({ ...common, csoProducer: policy(state, 'relative-source', helper) }, 'gpt-test')).toThrow('absolute normalized path');
expect(() => codexExecArgs({ ...common, csoProducer: { ...policy(state, source, helper), helperGeneration: join(dirname(helper), 'wrong-generation') } }, 'gpt-test')).toThrow('exact adjacent');
expect(codexExecArgs(producer, 'gpt-test').join(' ')).not.toMatch(/(?:^|\s)(?:-s|--sandbox)(?:\s|$)|danger|bypass|approve-for-me/);
});
test('keeps exact Claude defaults and enables its producer-only noninteractive safe policy', () => {
const work = join(tmpdir(), 'gstack-claude-work');
const state = join(work, 'state');
const source = join(work, 'source');
const helper = helperAt(join(work, 'installed'));
const common = { prompt: 'Reply OK', workdir: work, timeoutMs: 5000 };
expect(claudeExecArgs(common, 'claude-test', ['wrapper'])).toEqual([
'wrapper', '-p', '--output-format', 'json', '--model', 'claude-test',
]);
const producer = { ...common, csoProducer: policy(state, source, helper) };
expect(claudeExecArgs(producer, 'claude-test')).toEqual([
'-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--model', 'claude-test',
'--restricted', '--safe-mode', '--no-session-persistence',
'--permission-prompts', 'none', '--permission-mode', 'dontAsk',
'--tools', 'Bash,Write,Agent',
'--allowed-tools', `Bash(${helper}),Bash(${helper} *),Write`,
'--add-dir', claudeProducerPaths(state).workdir,
'--add-dir', join(state, 'cso-home'),
'--settings', JSON.stringify({ sandbox: { enabled: true, failIfUnavailable: true, allowUnsandboxedCommands: false, autoAllowBashIfSandboxed: false, excludedCommands: [helper, `${helper} *`] } }),
'--strict-mcp-config', '--no-chrome',
]);
expect(claudeExecArgs(producer, 'claude-test')).not.toContain(source);
expect(claudeExecWorkingDirectory(common)).toBe(work);
expect(claudeExecWorkingDirectory(producer)).toBe(claudeProducerPaths(state).workdir);
expect(claudeExecArgs(producer, 'claude-test').join(' ')).not.toMatch(/bypassPermissions|danger/);
});
test('fails the Claude producer closed unless its sandbox exempts exactly the launcher and its tool policy is the one subagents inherit', () => {
const work = join(tmpdir(), 'gstack-claude-sandbox-policy');
const state = join(work, 'state');
const helper = helperAt(join(work, 'installed'));
const producer = { prompt: '', workdir: work, timeoutMs: 1, csoProducer: policy(state, join(work, 'source'), helper) };
const args = claudeExecArgs(producer, 'claude-test'), env = claudeExecEnvironment(producer, { PATH: '/bin', ANTHROPIC_API_KEY: 'key' });
expect(() => assertClaudeProducerSandboxPolicy(args, env, helper)).not.toThrow();
const at = args.indexOf('--settings'), settings = JSON.parse(args[at + 1]);
const withSettings = (change: (sandbox: Record<string, unknown>) => void) => {
const next = structuredClone(settings); change(next.sandbox);
return args.map((arg, index) => index === at + 1 ? JSON.stringify(next) : arg);
};
const withoutSettings = args.filter((_, index) => index !== at && index !== at + 1);
for (const [tampered, tamperedEnv, detail] of [
[withoutSettings, env, 'expected exactly one --settings'],
[[...args, '--settings', args[at + 1]], env, 'expected exactly one --settings'],
[[...withoutSettings, `--settings=${args[at + 1]}`], env, 'expected exactly one --settings'],
[withSettings(sandbox => { sandbox.excludedCommands = [helper, `${helper} *`, 'cat']; }), env, 'sandbox.excludedCommands'],
[withSettings(sandbox => { sandbox.excludedCommands = [`${helper}-link`, `${helper}-link *`]; }), env, 'sandbox.excludedCommands'],
[withSettings(sandbox => { sandbox.allowUnsandboxedCommands = true; }), env, 'sandbox.allowUnsandboxedCommands'],
[withSettings(sandbox => { delete sandbox.autoAllowBashIfSandboxed; }), env, 'keys differ'],
[withSettings(sandbox => { sandbox.enabled = false; }), env, 'sandbox.enabled'],
[withSettings(sandbox => { sandbox.dangerouslyDisableSandbox = true; }), env, 'keys differ'],
[args.map(arg => arg.startsWith('Bash(') ? `${arg},Bash(cat *)` : arg), env, '--allowed-tools'],
[args, { ...env, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' }, 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB'],
[args.map(arg => arg === 'Bash,Write,Agent' ? 'Bash,Write' : arg), env, '--tools must be exactly Bash,Write,Agent'],
[args.map(arg => arg === 'Bash,Write,Agent' ? 'Bash,Write,Agent,Read' : arg), env, '--tools must be exactly Bash,Write,Agent'],
[args.map(arg => arg === 'dontAsk' ? 'acceptEdits' : arg), env, '--permission-mode must be dontAsk'],
[args.filter(arg => arg !== '--restricted'), env, 'expected exactly one --restricted'],
[args.filter(arg => arg !== '--strict-mcp-config'), env, 'expected exactly one --strict-mcp-config'],
[[...args, '--mcp-config', '{}'], env, '--mcp-config would change'],
[[...args, '--agents', '{}'], env, '--agents would change'],
[[...args, '--setting-sources=user'], env, '--setting-sources would change'],
] as Array<[string[], Record<string, string>, string]>) {
expect(() => assertClaudeProducerSandboxPolicy(tampered, tamperedEnv, helper)).toThrow(`CLAUDE_PRODUCER_SANDBOX_POLICY_MISMATCH: `);
expect(() => assertClaudeProducerSandboxPolicy(tampered, tamperedEnv, helper)).toThrow(detail);
}
});
test('keeps exact Gemini defaults and replaces deprecated yolo only for the producer', () => {
const work = join(tmpdir(), 'gstack-gemini-work');
const state = join(work, 'state');
const source = join(work, 'source');
const helper = helperAt(join(work, 'installed'));
const common = { prompt: 'Reply OK', workdir: work, timeoutMs: 5000, model: 'gemini-test' };
expect(geminiExecArgs(common)).toEqual([
'-p', 'Reply OK', '--output-format', 'stream-json', '--yolo', '--model', 'gemini-test',
]);
const producer = { ...common, csoProducer: policy(state, source, helper) };
const producerArgs = geminiExecArgs(producer);
expect(producerArgs).toEqual([
'-p', 'Reply OK', '--output-format', 'stream-json',
'--approval-mode', 'yolo', '--include-directories', geminiProducerPaths(state).workdir, '-e', 'none',
'--model', 'gemini-test',
]);
expect(producerArgs).not.toContain(work);
expect(producerArgs).not.toContain(source);
expect(geminiExecWorkingDirectory(common)).toBe(work);
expect(geminiExecWorkingDirectory(producer)).toBe(geminiProducerPaths(state).workdir);
const contextFileName = `.gstack-cso-context-${'a'.repeat(32)}.md`;
expect(geminiProducerSystemSettings(contextFileName, helper)).toEqual({
advanced: { ignoreLocalEnv: true },
admin: { extensions: { enabled: false }, mcp: { enabled: false }, skills: { enabled: false } },
context: { fileName: [contextFileName], includeDirectoryTree: false, loadMemoryFromIncludeDirectories: false, memoryBoundaryMarkers: [] },
hooksConfig: { enabled: false },
privacy: { usageStatisticsEnabled: false },
security: {
environmentVariableRedaction: { allowed: [], blocked: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'], enabled: true },
folderTrust: { enabled: false }, toolSandboxing: false,
},
skills: { enabled: false },
telemetry: { enabled: false, logPrompts: false },
tools: { allowed: [`run_shell_command(${helper})`, 'write_file'], core: [`run_shell_command(${helper})`, 'write_file'], sandbox: false },
});
expect(() => geminiProducerSystemSettings('GEMINI.md', helper)).toThrow('INVALID_GEMINI_CONTEXT_FILENAME');
});
test('passes only selected provider auth plus safe execution inputs and strips Docker credentials', () => {
const fixtureRoot = join(tmpdir(), 'gstack-provider-environment-policy');
const state = join(fixtureRoot, 'state');
const source = join(fixtureRoot, 'source');
const helpers = join(fixtureRoot, 'helpers');
const work = join(fixtureRoot, 'work');
const sourceEnv = {
PATH: '/usr/bin', HOME: '/home/eval', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC',
GSTACK_HOME: state, GSTACK_SESSION_KIND: 'spawned', GSTACK_HEADLESS: '1',
OPENAI_API_KEY: 'openai-auth', ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth',
GEMINI_API_KEY: 'gemini-auth', GOOGLE_CLOUD_PROJECT: 'project',
CSO_EVAL_PAID: '1', AWS_SECRET_ACCESS_KEY: 'unrelated',
DOCKER_HOST: 'tcp://remote.example:2376', DOCKER_CONFIG: '/credentials', DOCKER_CERT_PATH: '/certs',
};
const safe = { PATH: '/usr/bin', HOME: '/home/eval', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC', GSTACK_HOME: state, GSTACK_SESSION_KIND: 'spawned', GSTACK_HEADLESS: '1' };
expect(csoProducerChildEnvironment('gpt', sourceEnv)).toEqual({ ...safe, OPENAI_API_KEY: 'openai-auth' });
expect(csoProducerChildEnvironment('claude', sourceEnv)).toEqual({ ...safe, ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth' });
expect(csoProducerChildEnvironment('gemini', sourceEnv)).toEqual({ ...safe, GEMINI_API_KEY: 'gemini-auth', GOOGLE_CLOUD_PROJECT: 'project' });
const helper = helperAt(helpers);
const producer = policy(state, source, helper);
const common = { prompt: '', workdir: work, timeoutMs: 1, csoProducer: producer };
const codexPaths = codexProducerPaths(state);
expect(codexExecEnvironment(common, sourceEnv)).toEqual({
...safe, HOME: codexPaths.home, GSTACK_HOME: join(state, 'cso-home'), CODEX_HOME: codexPaths.home, OPENAI_API_KEY: 'openai-auth',
});
const claudePaths = claudeProducerPaths(state);
expect(claudeExecEnvironment(common, sourceEnv)).toEqual({
...safe, HOME: claudePaths.home, GSTACK_HOME: join(state, 'cso-home'),
ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth',
});
const geminiPaths = geminiProducerPaths(state);
expect(geminiExecEnvironment(common, sourceEnv)).toEqual({
...safe, HOME: geminiPaths.home, GSTACK_HOME: join(state, 'cso-home'),
GEMINI_API_KEY: 'gemini-auth', GOOGLE_API_KEY: 'gemini-auth', GOOGLE_CLOUD_PROJECT: 'project',
GEMINI_SANDBOX: 'false', GEMINI_TELEMETRY_ENABLED: 'false', GEMINI_TELEMETRY_LOG_PROMPTS: 'false',
GEMINI_CLI_TRUST_WORKSPACE: 'true', GEMINI_SYSTEM_MD: 'false', GEMINI_WRITE_SYSTEM_MD: 'false',
GEMINI_CLI_HOME: geminiPaths.home,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: geminiPaths.systemDefaults,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: geminiPaths.systemSettings,
});
for (const env of [codexExecEnvironment(common, sourceEnv), claudeExecEnvironment(common, sourceEnv), geminiExecEnvironment(common, sourceEnv)]) {
expect(env.CSO_EVAL_PAID).toBeUndefined();
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(env.DOCKER_HOST).toBeUndefined();
}
});
test('preserves default provider environments and rejects producer escape-hatch arguments', () => {
const fixtureRoot = join(tmpdir(), 'gstack-provider-default-policy');
const state = join(fixtureRoot, 'state');
const source = join(fixtureRoot, 'source');
const helpers = join(fixtureRoot, 'helpers');
const work = join(fixtureRoot, 'work');
const sourceEnv = { PATH: '/bin', CSO_EVAL_PAID: '1', UNRELATED_SECRET: 'kept-by-default', GEMINI_API_KEY: 'key' };
const common = { prompt: '', workdir: work, timeoutMs: 1 };
const producer = policy(state, source, helperAt(helpers));
expect(codexExecEnvironment(common, sourceEnv)).toBe(sourceEnv);
expect(claudeExecEnvironment(common, sourceEnv)).toEqual({ ...sourceEnv, GSTACK_HEADLESS: '1' });
expect(geminiExecEnvironment(common, sourceEnv)).toEqual({ ...sourceEnv, GOOGLE_API_KEY: 'key' });
for (const invoke of [
() => codexExecArgs({ ...common, csoProducer: producer, extraArgs: ['--unsafe'] }, 'model'),
() => claudeExecArgs({ ...common, csoProducer: producer, extraArgs: ['--unsafe'] }, 'model'),
() => geminiExecArgs({ ...common, csoProducer: producer, extraArgs: ['--unsafe'] }),
]) expect(invoke).toThrow('does not accept extra provider arguments');
});
test('fails paid Codex producers closed on empty or malformed success output',()=>{
for(const raw of ['', ' \n', 'not json\n{bad', '{"type":"turn.completed","usage":{"input_tokens":9,"output_tokens":2}}']){
expect(resultFromCodexStream(raw,{model:'gpt-test',producer:true})).toMatchObject({output:'',error:{code:'unknown',reason:'empty output from codex CLI (exit 0)'}});
}
expect(resultFromCodexStream('diagnostic\n{"type":"item.completed","item":{"type":"agent_message","text":"OK"}}',{producer:true}).error).toBeUndefined();
expect(resultFromCodexStream('',{producer:false}).error).toBeUndefined();
});
test('requires Claude producer JSON with a nonblank string result',()=>{
for(const raw of ['', 'plain text', '{}', '{"result":42}', '{"result":" "}', '{"type":"result","subtype":"success","is_error":true,"result":"API Error: connection failed"}', '{"type":"result","subtype":"error_during_execution","result":"partial"}']){
expect(resultFromClaudeOutput(raw,{model:'claude-test',producer:true})).toMatchObject({output:'',error:{code:'unknown',reason:'empty or invalid output from claude CLI (exit 0)'}});
}
const valid=resultFromClaudeOutput('{"result":"OK"}',{producer:true});expect(valid.output).toBe('OK');expect(valid.error).toBeUndefined();
const legacy=resultFromClaudeOutput('plain text');expect(legacy.output).toBe('plain text');expect(legacy.error).toBeUndefined();
});
test.skipIf(process.platform === 'win32')('executes the exact receipt-bound provider command despite a hostile PATH', async () => {
const fixture = mkdtempSync(join(tmpdir(), 'cso-bound-provider-'));
const previousPath = process.env.PATH;
try {
const state = join(fixture, 'state');
const source = join(fixture, 'source');
const installed = join(fixture, 'installed');
const hostile = join(fixture, 'hostile');
for (const directory of [state, source, installed, hostile]) mkdirSync(directory);
const bound = join(installed, 'codex');
const boundMarker = join(fixture, 'bound-ran');
const hostileMarker = join(fixture, 'hostile-ran');
writeFileSync(bound, `#!/bin/sh\ntouch ${JSON.stringify(boundMarker)}\nprintf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"BOUND"}}'\n`, { mode: 0o755 });
writeFileSync(join(hostile, 'codex'), `#!/bin/sh\ntouch ${JSON.stringify(hostileMarker)}\nprintf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"HOSTILE"}}'\n`, { mode: 0o755 });
process.env.PATH = `${hostile}:${previousPath ?? ''}`;
const helper = helperAt(installed);
const result = await new GptAdapter().run({
prompt: 'Reply OK', workdir: fixture, timeoutMs: 5000, model: 'gpt-test',
csoProducer: { ...policy(state, source, helper), providerCommand: { executable: bound, argsPrefix: [] } },
});
expect(result.output).toBe('BOUND');
expect(existsSync(boundMarker)).toBe(true);
expect(existsSync(hostileMarker)).toBe(false);
expect(existsSync(codexProducerPaths(state).root)).toBe(false);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
rmSync(fixture, { recursive: true, force: true });
}
});
});
// Both adapters execute these stubs, so a regression can never launch a paid CLI.
describe.skipIf(process.platform === 'win32')('provider model selection', () => {
beforeEach(() => {
saved = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
workdir = mkdtempSync(join(tmpdir(), 'gstack-model-defaults-'));
for (const key of ENV_KEYS) delete process.env[key];
process.env.PATH = `${workdir}:${saved.PATH ?? ''}`;
process.env.GSTACK_CLAUDE_BIN = join(workdir, 'claude');
for (const cli of ['claude', 'codex']) {
const response = cli === 'claude'
? '{"result":"OK","usage":{"input_tokens":1,"output_tokens":1}}'
: '{"type":"item.completed","item":{"type":"agent_message","text":"OK"}}';
writeFileSync(join(workdir, cli), `#!/bin/sh\nprintf '%s\\n' "$@" > args.txt\nprintf '%s\\n' '${response}'\n`, { mode: 0o755 });
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
rmSync(workdir, { recursive: true, force: true });
});
async function selected(adapter: ClaudeAdapter | GptAdapter, model?: string) {
// Start with PATH in the child environment; Bun can cache executable lookup
// when process.env.PATH is changed after startup.
const source = `
import { ${adapter.name === 'claude' ? 'ClaudeAdapter' : 'GptAdapter'} as Adapter }
from ${JSON.stringify(join(import.meta.dir, 'helpers/providers', `${adapter.name}.ts`))};
const result = await new Adapter().run(${JSON.stringify({ prompt: 'Reply OK', workdir, timeoutMs: 5000, model })});
console.log(JSON.stringify(result));
`;
const result = JSON.parse(execFileSync(process.execPath, ['-e', source], {
env: { ...process.env }, encoding: 'utf8', timeout: 10000,
}));
const args = readFileSync(join(workdir, 'args.txt'), 'utf8').trim().split('\n');
const flag = adapter.name === 'claude' ? '--model' : '-m';
expect(result.error).toBeUndefined();
expect(result.output).toBe('OK');
expect(args[args.indexOf(flag) + 1]).toBe(result.modelUsed);
return result.modelUsed;
}
test('Codex defaults to Astra and explicit model wins over the environment', async () => {
const adapter = new GptAdapter();
expect(await selected(adapter)).toBe('gpt-6-astra');
process.env.GSTACK_CODEX_MODEL = 'gpt-5.6-sol';
expect(await selected(adapter)).toBe('gpt-5.6-sol');
expect(await selected(adapter, 'custom-codex')).toBe('custom-codex');
});
test('Claude defaults to Opus 5.5 and preserves the full override chain', async () => {
const adapter = new ClaudeAdapter();
expect(await selected(adapter)).toBe('claude-opus-5-5');
process.env.GSTACK_EVAL_MODEL = 'global-model';
expect(await selected(adapter)).toBe('global-model');
process.env.GSTACK_EVAL_MODEL_CAPTURE = 'capture-model';
expect(await selected(adapter)).toBe('capture-model');
process.env.EVALS_MODEL = 'evals-model';
expect(await selected(adapter)).toBe('evals-model');
expect(await selected(adapter, 'explicit-model')).toBe('explicit-model');
});
test('Codex skill evals default to Astra and preserve model overrides', () => {
writeFileSync(join(workdir, 'SKILL.md'), '# Fixture\nReply OK.\n');
for (const [override, explicit, expected] of [
['', undefined, 'gpt-6-astra'],
['gpt-5.6-sol', undefined, 'gpt-5.6-sol'],
['gpt-5.6-sol', 'custom-codex', 'custom-codex'],
]) {
if (override) process.env.GSTACK_CODEX_MODEL = override;
else delete process.env.GSTACK_CODEX_MODEL;
const source = `
import { runCodexSkill } from ${JSON.stringify(join(import.meta.dir, 'helpers/codex-session-runner.ts'))};
const result = await runCodexSkill(${JSON.stringify({ skillDir: workdir, prompt: 'Reply OK', model: explicit, timeoutMs: 1000 })});
console.log(JSON.stringify(result));
process.exit(0);
`;
const result = JSON.parse(execFileSync(process.execPath, ['-e', source], {
env: { ...process.env }, encoding: 'utf8', timeout: 10000,
}));
expect(result.exitCode).toBe(0);
const args = readFileSync(join(workdir, 'args.txt'), 'utf8').trim().split('\n');
expect(args[args.indexOf('--model') + 1]).toBe(expected);
}
});
});