mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
The paid CSO producer gives `/cso` the Agent tool users have, so independent review and repair verification can run in the evaluation. The producer's receipt now counts subagent tokens.
371 lines
24 KiB
TypeScript
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 Fable and preserves the full override chain', async () => {
|
|
const adapter = new ClaudeAdapter();
|
|
expect(await selected(adapter)).toBe('claude-fable-5-1');
|
|
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);
|
|
}
|
|
});
|
|
});
|