From 50c930fe803201c9849852bb690431fd37617027 Mon Sep 17 00:00:00 2001 From: Alexandre Pereira Date: Thu, 15 Oct 2026 12:39:59 +0100 Subject: [PATCH 1/3] fix: ask the OpenCode binary for --standalone instead of reading its version The version probe decided the launch from `--version`, but every dev, beta and source build of *both* OpenCode generations reports `0.0.0-*` (or `local`), which the regex never matched, so they all fell back to the v1 launch. Verified against the published `@opencode/cli@beta`: $ opencode --version opencode v0.0.0-beta-19507 <- probe: no match -> major 1 $ apex run opencode Unrecognized flag: --model in command opencode The whole preview channel hits the exact error PR #12 exists to prevent, and `0.0.0-*` cannot be classified by version at all: the old `opencode-ai` package has published over 11k `0.0.0-*` versions too, so "major 0 means v2" would just move the bug to v1 dev builds. Probe the capability instead. `--standalone` exists if and only if the interface is v2, and `--help` says so on all four real binaries, including the `0.0.0-*` pair no regex can separate: v1 stable, v1 dev, v2 beta and v2 stable all answer correctly. v1 writes its help to stderr and v2 to stdout, so both streams are read. The probe now answers a tri-state: true (OpenCode 2), false (OpenCode 1), or null (no answer). It never rejects: execFile can throw synchronously (a .cmd launcher since CVE-2024-27980) and a failed or timed-out --help is no answer either. On null, `apex run` adds neither --model nor --standalone, so the assistant still starts on the user's own config, and says so on stderr rather than guessing a generation and dying on its flag; the init summary admits the same instead of blindly showing the v1 expansion. Also scrub OPENCODE_CONFIG_CONTENT in the test fixture: it reached launched fakes from the developer's shell, the same leak #11 closed for gateway keys. Docs-only inaccuracies PR #12 merged (the "flags only follow a subcommand" comment, the `auth list` example) are untouched here and tracked as F6. --- README.md | 10 +++-- src/assistants.js | 44 ++++++++++++++------ src/cli.js | 12 ++++-- test/apex.test.js | 101 ++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 131 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3e4cb1b..4a744e4 100644 --- a/README.md +++ b/README.md @@ -234,10 +234,12 @@ Other launchers: `apex run opencode`, `apex run claude`, `apex run pi`, `apex ru Launchers pass extra arguments through unchanged (without a shell), can be overridden by explicit user arguments, and never run `init`. `apex run claude` sets the gateway URL, token, attribution flag and model aliases in the child process only, leaving your default Claude provider alone. -`apex run opencode` checks which OpenCode is installed: OpenCode 1 gets `--model`; OpenCode 2, -whose terminal interface has no `--model`, starts with `--standalone` and the model in -`OPENCODE_CONFIG_CONTENT`, so the private server sees the model and, for a config that references -the key variable, your key too. +`apex run opencode` asks the installed binary which interface it has (`--standalone` exists only on +OpenCode 2; its version number says nothing, because dev and beta builds of both generations report +`0.0.0-*`): OpenCode 1 gets `--model`; OpenCode 2, whose terminal interface has no `--model`, starts +with `--standalone` and the model in `OPENCODE_CONFIG_CONTENT`, so the private server sees the +model and, for a config that references the key variable, your key too. A binary that cannot be +asked launches with neither flag, on your own configuration, and says so. ## Safety and recovery diff --git a/src/assistants.js b/src/assistants.js index 526c848..eead4f3 100644 --- a/src/assistants.js +++ b/src/assistants.js @@ -97,16 +97,26 @@ export async function executable(command, env = process.env, platform = process. return null; } -// OpenCode 1 prints `1.18.34`, OpenCode 2 prints `opencode v2.0.29`, each on a line of its own, -// so a notice naming another version is not mistaken for it. Anything unreadable is treated as 1, -// the launch Apex CLI has always used. -export function openCodeMajor(binary) { - if (!binary) return Promise.resolve(1); +// OpenCode 2's interface has --standalone; OpenCode 1's never had it. Asking the binary which +// interface it has beats reading its version: dev, beta and source builds of *both* generations +// report `0.0.0-*` or `local`, so no version regex separates them. OpenCode 1 writes --help to +// stderr and OpenCode 2 to stdout, so both streams are checked. The answer is true (OpenCode 2), +// false (OpenCode 1) or null: no answer, which callers must report instead of guessing a launch. +// --help exits immediately on both generations, so a short timeout means a hung or interactive +// binary, and that counts as no answer too. +export function openCodeSupportsStandalone(binary) { + if (!binary) return Promise.resolve(null); return new Promise(resolve => { - execFile(binary, ['--version'], { timeout: 10000 }, (error, stdout) => { - const match = !error && /^(?:opencode )?v?(\d+)\.\d+\.\d+\s*$/m.exec(stdout); - resolve(match ? Number(match[1]) : 1); - }); + try { + execFile(binary, ['--help'], { timeout: 5000 }, (error, stdout, stderr) => { + if (error) resolve(null); + else resolve(stdout.includes('--standalone') || stderr.includes('--standalone')); + }); + } catch { + // execFile can throw synchronously (a .cmd launcher on Windows since CVE-2024-27980); + // a probe that cannot ask must still answer, and its answer is "no answer". + resolve(null); + } }); } @@ -272,14 +282,23 @@ export function launchToken(env, provider) { return token; } -export function launchOptions(id, env = process.env, provider = defaultProvider(env), { openCode = 1 } = {}) { +// `openCodeStandalone` is openCodeSupportsStandalone's answer: true launches the OpenCode 2 way, +// false the OpenCode 1 way, and null — no answer — launches with no model flag at all rather than +// guessing a generation and dying on a flag it rejects. The default is the launch Apex CLI has +// always used for a caller that never probed. +export function launchOptions(id, env = process.env, provider = defaultProvider(env), { openCodeStandalone = false } = {}) { const { model } = provider; const token = launchToken(env, provider); const childEnv = { ...env }; switch (id) { case 'opencode': { const selected = `${provider.opencode}/${model}`; - if (openCode < 2) return { args: ['--model', selected], env: childEnv }; + if (openCodeStandalone === null) { + // Both generations launch fine without our flag, so the assistant still starts, just on + // the user's own model; the warning on stderr says the gateway model was not selected. + return { args: [], env: childEnv, note: '(OpenCode version unknown: no model flag added)' }; + } + if (!openCodeStandalone) return { args: ['--model', selected], env: childEnv }; // OpenCode 2's interface has no --model, and its shared background service never sees this // environment. A private server (--standalone) does: it reads the model from inline config, // and the gateway key the config references. Its flags only follow a subcommand. @@ -308,7 +327,8 @@ export function launchOptions(id, env = process.env, provider = defaultProvider( // The placeholder token is never shown: only the argument list and the launch's note are. export function runExpansion(id, provider, installed = {}) { const { args, note } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, installed); - return [`${id} ${args.join(' ')}`, note].filter(Boolean).join(' '); + // An unknown OpenCode adds nothing, and a bare `opencode ` would leave its gap to the note alone. + return [`${id} ${args.join(' ')}`.trimEnd(), note].filter(Boolean).join(' '); } // One line per key: the whole object on one line cannot be folded to a terminal, and a step that diff --git a/src/cli.js b/src/cli.js index 8143f37..4803159 100644 --- a/src/cli.js +++ b/src/cli.js @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import { readFile, stat } from 'node:fs/promises'; import { basename } from 'node:path'; import { randomUUID } from 'node:crypto'; -import { IDS, MANUAL_IDS, NAMES, PROVIDERS, PROVIDER_IDS, RUNNABLE, defaultProvider, detect, executable, launchOptions, launchToken, manualSteps, openCodeMajor, planAssistant, runExpansion } from './assistants.js'; +import { IDS, MANUAL_IDS, NAMES, PROVIDERS, PROVIDER_IDS, RUNNABLE, defaultProvider, detect, executable, launchOptions, launchToken, manualSteps, openCodeSupportsStandalone, planAssistant, runExpansion } from './assistants.js'; import { removeChange, writeChange } from './config.js'; import { MODE_NOTE, describeFile, resolveMode, runWrites } from './plan.js'; import { appendEntry, batchIds, journalPath, markUndone, newEntry, planUndo, readJournal } from './journal.js'; @@ -379,7 +379,7 @@ async function commandInit(rest) { ui.dim('Apex CLI never reads, stores or logs the key itself.'), ]); const runnable = active.map(entry => entry.id).filter(id => RUNNABLE.includes(id)); - const installed = { openCode: await openCodeMajor(active.find(entry => entry.id === 'opencode')?.binary) }; + const installed = { openCodeStandalone: await openCodeSupportsStandalone(active.find(entry => entry.id === 'opencode')?.binary) }; const rows = runnable.map(id => ({ command: `${apex} run ${id}`, expansion: runExpansion(id, provider, installed) })); const commandWidth = ui.columnWidth(rows.map(row => row.command)); ui.section(runnable.length @@ -567,7 +567,13 @@ async function commandRun(rest) { throw new Error('Windows .cmd/.bat launchers are not executed through a shell. Use WSL or launch the configured assistant directly.'); } // OpenCode 1 and 2 take the model in different ways, so the launch depends on which is installed. - const options = launchOptions(id, process.env, provider, { openCode: await openCodeMajor(id === 'opencode' && binary) }); + // An unknown answer adds no flag at all, and says so on stderr: the child owns stdout from here on. + const standalone = id === 'opencode' ? await openCodeSupportsStandalone(binary) : false; + if (standalone === null) { + console.error(styleText('yellow', 'Apex CLI: Could not tell which OpenCode is installed, so it launches without a model flag.' + + ' OpenCode 1 takes --model, OpenCode 2 takes --standalone; opencode --help says which you have.')); + } + const options = launchOptions(id, process.env, provider, { openCodeStandalone: standalone }); const args = passed[0] === '--' ? passed.slice(1) : passed; const child = spawn(binary, await launchArgs(options, args), { env: options.env, stdio: 'inherit', shell: false }); await new Promise((resolve, reject) => { diff --git a/test/apex.test.js b/test/apex.test.js index 39ff25d..d2eb6eb 100644 --- a/test/apex.test.js +++ b/test/apex.test.js @@ -4,7 +4,7 @@ import { chmod, mkdtemp, mkdir, writeFile, readFile, readdir, stat, lstat, symli import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { PROVIDERS, defaultProvider, detect, locations, planAssistant, launchOptions, manualSteps, runExpansion } from '../src/assistants.js'; +import { PROVIDERS, defaultProvider, detect, locations, planAssistant, launchOptions, manualSteps, openCodeSupportsStandalone, runExpansion } from '../src/assistants.js'; import { addToml, updateToml, editJson, parseJson, parseToml, readConfig, sha256, writeChange } from '../src/config.js'; import { diffValues, unifiedDiff } from '../src/diff.js'; import { isSecretKey, redactText } from '../src/secrets.js'; @@ -34,9 +34,11 @@ async function fixture(context) { delete env.CI; delete env.npm_command; // A gateway key in the developer's shell would change the default gateway, and their own - // XDG_DATA_HOME would point OpenCode's key store at their real one. + // XDG_DATA_HOME would point OpenCode's key store at their real one. Their inline config would + // reach a launched OpenCode the same way it reaches their shell. for (const provider of Object.values(PROVIDERS)) if (provider !== CALLSTACK) delete env[provider.envVar]; delete env.XDG_DATA_HOME; + delete env.OPENCODE_CONFIG_CONTENT; return { home, env, journal: join(home, '.apex-state', 'journal.json') }; } @@ -890,38 +892,101 @@ test('CLI run forwards argument boundaries, child environment and exit status', assert.equal(output.token, env.CALLSTACK_AUTH_TOKEN); }); +test('the OpenCode probe answers v2, v1, or nothing, and never rejects', async context => { + const { home } = await fixture(context); + const bin = join(home, 'bin'); + await mkdir(bin); + const script = stream => `#!${process.execPath} +process.${stream}.write('Usage: opencode [flags]\\n --standalone start a private server\\n'); +process.exit(0); +`; + await writeFile(join(bin, 'help-stdout'), script('stdout'), { mode: 0o755 }); + await writeFile(join(bin, 'help-stderr'), script('stderr'), { mode: 0o755 }); + // OpenCode 1 prints its help without --standalone; a v1 dev build is indistinguishable by + // --version alone, which is exactly why the probe asks about the flag instead. + await writeFile(join(bin, 'v1-help'), `#!${process.execPath} +process.stderr.write('Usage: opencode [flags]\\n --model model to use\\n'); +process.exit(0); +`, { mode: 0o755 }); + const paths = name => join(bin, name); + assert.equal(await openCodeSupportsStandalone(paths('help-stdout')), true, 'the flag on stdout'); + assert.equal(await openCodeSupportsStandalone(paths('help-stderr')), true, 'the flag on stderr, where OpenCode 1 writes'); + assert.equal(await openCodeSupportsStandalone(paths('v1-help')), false, 'a help page without the flag'); + assert.equal(await openCodeSupportsStandalone(join(home, 'missing')), null, 'a binary that is not there'); + assert.equal(await openCodeSupportsStandalone(bin), null, 'a directory is not a binary'); + // execFile can throw synchronously (an empty path; on Windows, any .cmd launcher since + // CVE-2024-27980). A probe that cannot ask must answer "no answer", not reject on the caller. + assert.equal(await openCodeSupportsStandalone(''), null, 'an empty path throws synchronously'); + await writeFile(paths('exit-3'), `#!${process.execPath}\nprocess.stdout.write(' --standalone\\n');\nprocess.exit(3);\n`, { mode: 0o755 }); + assert.equal(await openCodeSupportsStandalone(paths('exit-3')), null, 'a --help that fails is no answer'); + assert.equal(await openCodeSupportsStandalone(null), null, 'no binary, no answer'); +}); + test('CLI run picks the model the way the installed OpenCode accepts it', async context => { const { home, env } = await fixture(context); const bin = join(home, 'bin'); await mkdir(bin); + // The fake answers --help (the probe) and --version (never read by Apex CLI) from separate + // variables: which generation a build is must be assertible independently of the version string + // it prints, because dev and beta builds of both generations print 0.0.0-*. await writeFile(join(bin, 'opencode'), `#!${process.execPath} +if (process.argv[2] === '--help') { + if (process.env.FAKE_OPENCODE_HELP_FAILS === '1') process.exit(1); + const help = process.env.FAKE_OPENCODE_STANDALONE === '1' + ? ' --standalone start a private server\\n --print-logs write logs to a file\\n' + : ' --model model to use\\n --print-logs write logs to a file\\n'; + (process.env.FAKE_OPENCODE_HELP_STREAM === 'stderr' ? process.stderr : process.stdout).write(help); + process.exit(0); +} if (process.argv[2] === '--version') { console.log(process.env.FAKE_OPENCODE_VERSION); process.exit(0); } console.log(JSON.stringify({ args: process.argv.slice(2), inline: process.env.OPENCODE_CONFIG_CONTENT })); `, { mode: 0o755 }); - const run = (version, extra = {}, passed = ['--x']) => { - const result = cli(['run', 'opencode', '--', ...passed], { ...env, PATH: bin, FAKE_OPENCODE_VERSION: version, ...extra }); + const V2 = { FAKE_OPENCODE_STANDALONE: '1' }; + const V1 = { FAKE_OPENCODE_STANDALONE: '0' }; + const run = (version, capability = {}, extra = {}, passed = ['--x']) => { + const result = cli(['run', 'opencode', '--', ...passed], + { ...env, PATH: bin, FAKE_OPENCODE_VERSION: version, ...capability, ...extra }); assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); }; - assert.deepEqual(run('1.18.34'), { args: ['--model', `callstack.ai/${MODEL}`, '--x'] }); - assert.deepEqual(run('1.18.34', {}, ['run', 'hi']).args, ['--model', `callstack.ai/${MODEL}`, 'run', 'hi']); + assert.deepEqual(run('1.18.34', V1), { args: ['--model', `callstack.ai/${MODEL}`, '--x'] }); + assert.deepEqual(run('1.18.34', V1, {}, ['run', 'hi']).args, ['--model', `callstack.ai/${MODEL}`, 'run', 'hi']); // OpenCode 2's TUI rejects --model; a private server reads the model from inline config. - const v2 = run('opencode v2.0.29', { OPENCODE_CONFIG_CONTENT: '{ // OpenCode reads JSONC here too\n"theme":"dark", }' }); + const v2 = run('opencode v2.0.29', V2, { OPENCODE_CONFIG_CONTENT: '{ // OpenCode reads JSONC here too\n"theme":"dark", }' }); assert.deepEqual(v2.args, ['--standalone', '--x']); - assert.deepEqual(run('2.0.29', {}, ['run', 'hi']).args, ['run', '--standalone', 'hi'], 'v2 flags follow the subcommand'); - assert.deepEqual(run('2.0.29', {}, [home]).args, ['--standalone', home], 'a directory starts the interface'); - assert.deepEqual(run('2.0.29', {}, ['mini']).args, ['mini', '--standalone']); - assert.deepEqual(run('Update available: 2.1.0\n1.18.34').args, ['--model', `callstack.ai/${MODEL}`, '--x'], - 'a notice naming another version is not the version'); - assert.deepEqual(run('2.0.29', {}, ['auth', 'list']).args, ['auth', 'list'], 'subcommands without --standalone pass through'); + assert.deepEqual(run('2.0.29', V2, {}, ['run', 'hi']).args, ['run', '--standalone', 'hi'], 'v2 flags follow the subcommand'); + assert.deepEqual(run('2.0.29', V2, {}, [home]).args, ['--standalone', home], 'a directory starts the interface'); + assert.deepEqual(run('2.0.29', V2, {}, ['mini']).args, ['mini', '--standalone']); + assert.deepEqual(run('2.0.29', V2, {}, ['auth', 'list']).args, ['auth', 'list'], 'subcommands without --standalone pass through'); assert.deepEqual(JSON.parse(v2.inline), { theme: 'dark', model: `callstack.ai/${MODEL}` }); - assert.deepEqual(run('unreadable').args, ['--model', `callstack.ai/${MODEL}`, '--x'], 'unknown versions keep the v1 launch'); - const broken = cli(['run', 'opencode'], { ...env, PATH: bin, FAKE_OPENCODE_VERSION: '2.0.0', OPENCODE_CONFIG_CONTENT: '{' }); + // Every build of both generations that reports 0.0.0-* is decided by the flag it has, never by + // its version: these two differ only in what --help prints, and answer opposite ways. + assert.deepEqual(run('opencode v0.0.0-beta-19507', V2).args, ['--standalone', '--x'], + 'a v2 beta reports 0.0.0-* and the version regex rejected --model on it'); + assert.deepEqual(run('0.0.0-dev-202610141901', V1).args, ['--model', `callstack.ai/${MODEL}`, '--x'], + 'a v1 dev build reports 0.0.0-* too, so major 0 does not mean OpenCode 2'); + assert.deepEqual(run('opencode vlocal', V2).args, ['--standalone', '--x'], 'a source build'); + assert.deepEqual(run('Update available: 2.1.0\n1.18.34', V2).args, ['--standalone', '--x'], + 'what --version prints decides nothing'); + // OpenCode 1 prints its help on stderr, so the probe reads both streams either way. + assert.deepEqual(run('opencode v2.0.29', { ...V2, FAKE_OPENCODE_HELP_STREAM: 'stderr' }).args, + ['--standalone', '--x'], '--standalone found on stderr'); + assert.deepEqual(run('1.18.34', { ...V1, FAKE_OPENCODE_HELP_STREAM: 'stderr' }).args, + ['--model', `callstack.ai/${MODEL}`, '--x'], 'its absence on stderr too'); + // A binary that cannot be asked adds no flag rather than guessing a generation and dying on it. + const unknown = run('opencode v2.0.29', { FAKE_OPENCODE_HELP_FAILS: '1' }); + assert.deepEqual(unknown.args, ['--x'], 'an unanswerable probe launches with neither --model nor --standalone'); + assert.equal(unknown.inline, undefined, 'and the inline config is left alone'); + assert.match(cli(['run', 'opencode', '--', '--x'], { ...env, PATH: bin, FAKE_OPENCODE_HELP_FAILS: '1' }).stderr, + /launches without a model flag/); + const broken = cli(['run', 'opencode'], { ...env, PATH: bin, ...V2, FAKE_OPENCODE_VERSION: '2.0.0', OPENCODE_CONFIG_CONTENT: '{' }); assert.equal(broken.status, 1); assert.match(broken.stderr, /Invalid JSON\/JSONC object in OPENCODE_CONFIG_CONTENT/); assert.equal(runExpansion('opencode', CALLSTACK), `opencode --model callstack.ai/${MODEL}`); - assert.equal(runExpansion('opencode', CALLSTACK, { openCode: 2 }), 'opencode --standalone (OPENCODE_CONFIG_CONTENT model set)'); + assert.equal(runExpansion('opencode', CALLSTACK, { openCodeStandalone: true }), 'opencode --standalone (OPENCODE_CONFIG_CONTENT model set)'); + assert.equal(runExpansion('opencode', CALLSTACK, { openCodeStandalone: null }), + 'opencode (OpenCode version unknown: no model flag added)'); }); test('closing the pipe early exits quietly instead of crashing', async context => { @@ -1195,7 +1260,9 @@ test('the closing block shows what each run command expands to, and when undo is const applied = cli([...INIT, '--apply'], env); assert.equal(applied.status, 0, applied.stderr); assert.match(applied.stdout, at(/^Use these commands to run callstack\/Apex with your selected harnesses:$/)); - assert.match(applied.stdout, at(/^apex run opencode {2}opencode --model callstack\.ai\/callstack\/Apex$/)); + // The fixture has no opencode on PATH, so the summary admits it cannot know which flag the + // launch will add instead of blindly showing the v1 one. + assert.match(applied.stdout, at(/^apex run opencode {2}opencode {2}\(OpenCode version unknown: no model flag added\)$/)); assert.match(applied.stdout, at(/^apex run codex +codex --profile callstack_ai$/)); assert.match(applied.stdout, at(/^apex run pi +pi --provider callstack --model callstack\/Apex --thinking medium$/)); assert.match(applied.stdout, at(/^\.\.\.or pick "callstack\/Apex" from the UI when setting up manually\.$/)); From 70ad9ae16bcd8c787dfc38f2ce8d59f0d2d80686 Mon Sep 17 00:00:00 2001 From: Alexandre Pereira Date: Thu, 15 Oct 2026 13:17:49 +0100 Subject: [PATCH 2/3] refactor: simplify the probe and its messages per the review of this PR The fanout review found no bugs; these are its confirmed findings. - openCodeSupportsStandalone becomes a short async function over promisify(execFile): one try/catch covers the callback's error branch and what execFile throws synchronously (D2). - A failing or timed-out --help that printed --standalone is an answer after all; the captured streams on the error are read instead of thrown away (D1). - launchOptions owns the unknown-probe sentence as `warning` and says "interface", not "version"; commandRun prints it instead of writing its own wording of the same state (D6, D9). - The init summary drops the note when no binary was found: nothing was asked, so nothing is unknown (D5). - runExpansion joins the command tokens instead of repairing a trailing space with trimEnd() (D10). - Tests: the synchronous-throw case uses a path that actually reaches execFile (a NUL byte); the probe asserts a failed --help with and without the flag in its output; the CLI fake sheds the --version plumbing Apex no longer reads and the stderr cases the probe unit test already covers (D3, D8). --- src/assistants.js | 47 ++++++++++++++++++------------- src/cli.js | 20 ++++++++----- test/apex.test.js | 71 +++++++++++++++++++---------------------------- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/src/assistants.js b/src/assistants.js index eead4f3..d032616 100644 --- a/src/assistants.js +++ b/src/assistants.js @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { access, readFile, stat } from 'node:fs/promises'; import { constants } from 'node:fs'; import { join, delimiter } from 'node:path'; @@ -97,27 +98,27 @@ export async function executable(command, env = process.env, platform = process. return null; } +const execFileAsync = promisify(execFile); + // OpenCode 2's interface has --standalone; OpenCode 1's never had it. Asking the binary which // interface it has beats reading its version: dev, beta and source builds of *both* generations // report `0.0.0-*` or `local`, so no version regex separates them. OpenCode 1 writes --help to // stderr and OpenCode 2 to stdout, so both streams are checked. The answer is true (OpenCode 2), // false (OpenCode 1) or null: no answer, which callers must report instead of guessing a launch. -// --help exits immediately on both generations, so a short timeout means a hung or interactive -// binary, and that counts as no answer too. -export function openCodeSupportsStandalone(binary) { - if (!binary) return Promise.resolve(null); - return new Promise(resolve => { - try { - execFile(binary, ['--help'], { timeout: 5000 }, (error, stdout, stderr) => { - if (error) resolve(null); - else resolve(stdout.includes('--standalone') || stderr.includes('--standalone')); - }); - } catch { - // execFile can throw synchronously (a .cmd launcher on Windows since CVE-2024-27980); - // a probe that cannot ask must still answer, and its answer is "no answer". - resolve(null); - } - }); +// Even a failing or timed-out --help counts as an answer when its output printed the flag; the +// answer is null only when nothing proved either way. +export async function openCodeSupportsStandalone(binary) { + if (!binary) return null; + try { + const { stdout, stderr } = await execFileAsync(binary, ['--help'], { timeout: 5000 }); + return `${stdout}${stderr}`.includes('--standalone'); + } catch (error) { + // A nonzero exit or a killed timeout still delivers everything the binary printed, and the flag + // in that output answers the question; a spawn that never ran prints nothing and stays unknown. + // The await also catches what execFile throws synchronously (a .cmd launcher on Windows since + // CVE-2024-27980): a probe that cannot ask must never reject on its caller. + return `${error.stdout}${error.stderr}`.includes('--standalone') ? true : null; + } } export function locations(home = homedir(), env = process.env, platform = process.platform) { @@ -295,8 +296,15 @@ export function launchOptions(id, env = process.env, provider = defaultProvider( const selected = `${provider.opencode}/${model}`; if (openCodeStandalone === null) { // Both generations launch fine without our flag, so the assistant still starts, just on - // the user's own model; the warning on stderr says the gateway model was not selected. - return { args: [], env: childEnv, note: '(OpenCode version unknown: no model flag added)' }; + // the user's own model. `warning` is what the launch says on stderr; `apex run` prints it + // rather than writing its own version of the same sentence. + return { + args: [], env: childEnv, + note: '(OpenCode interface unknown: no model flag added)', + warning: 'Apex CLI: Could not ask which OpenCode interface is installed, so it launches' + + ' without a model flag. OpenCode 1 takes --model, OpenCode 2 takes --standalone;' + + ' opencode --help says which you have.', + }; } if (!openCodeStandalone) return { args: ['--model', selected], env: childEnv }; // OpenCode 2's interface has no --model, and its shared background service never sees this @@ -327,8 +335,7 @@ export function launchOptions(id, env = process.env, provider = defaultProvider( // The placeholder token is never shown: only the argument list and the launch's note are. export function runExpansion(id, provider, installed = {}) { const { args, note } = launchOptions(id, { [provider.envVar]: 'placeholder' }, provider, installed); - // An unknown OpenCode adds nothing, and a bare `opencode ` would leave its gap to the note alone. - return [`${id} ${args.join(' ')}`.trimEnd(), note].filter(Boolean).join(' '); + return [[id, ...args].join(' '), note].filter(Boolean).join(' '); } // One line per key: the whole object on one line cannot be folded to a terminal, and a step that diff --git a/src/cli.js b/src/cli.js index 4803159..e7cdaca 100644 --- a/src/cli.js +++ b/src/cli.js @@ -379,8 +379,16 @@ async function commandInit(rest) { ui.dim('Apex CLI never reads, stores or logs the key itself.'), ]); const runnable = active.map(entry => entry.id).filter(id => RUNNABLE.includes(id)); - const installed = { openCodeStandalone: await openCodeSupportsStandalone(active.find(entry => entry.id === 'opencode')?.binary) }; - const rows = runnable.map(id => ({ command: `${apex} run ${id}`, expansion: runExpansion(id, provider, installed) })); + // Only a probe that ran can answer "unknown interface". An OpenCode that is not on PATH yet has + // nothing to ask and `apex run` cannot launch it at all, so its line says just the command; + // `apex run opencode` itself is what tells them to install it. + const opencodeBinary = active.find(entry => entry.id === 'opencode')?.binary; + const probed = opencodeBinary ? await openCodeSupportsStandalone(opencodeBinary) : undefined; + const installed = { openCodeStandalone: probed }; + const rows = runnable.map(id => ({ + command: `${apex} run ${id}`, + expansion: id === 'opencode' && probed === undefined ? id : runExpansion(id, provider, installed), + })); const commandWidth = ui.columnWidth(rows.map(row => row.command)); ui.section(runnable.length ? `Use these commands to run ${provider.model} with your selected harnesses:` @@ -567,13 +575,11 @@ async function commandRun(rest) { throw new Error('Windows .cmd/.bat launchers are not executed through a shell. Use WSL or launch the configured assistant directly.'); } // OpenCode 1 and 2 take the model in different ways, so the launch depends on which is installed. - // An unknown answer adds no flag at all, and says so on stderr: the child owns stdout from here on. + // An unknown answer adds no flag at all; the launch's own warning says so on stderr, because the + // child owns stdout from here on. const standalone = id === 'opencode' ? await openCodeSupportsStandalone(binary) : false; - if (standalone === null) { - console.error(styleText('yellow', 'Apex CLI: Could not tell which OpenCode is installed, so it launches without a model flag.' - + ' OpenCode 1 takes --model, OpenCode 2 takes --standalone; opencode --help says which you have.')); - } const options = launchOptions(id, process.env, provider, { openCodeStandalone: standalone }); + if (options.warning) console.error(styleText('yellow', options.warning)); const args = passed[0] === '--' ? passed.slice(1) : passed; const child = spawn(binary, await launchArgs(options, args), { env: options.env, stdio: 'inherit', shell: false }); await new Promise((resolve, reject) => { diff --git a/test/apex.test.js b/test/apex.test.js index d2eb6eb..23c32ad 100644 --- a/test/apex.test.js +++ b/test/apex.test.js @@ -908,17 +908,22 @@ process.exit(0); process.stderr.write('Usage: opencode [flags]\\n --model model to use\\n'); process.exit(0); `, { mode: 0o755 }); + // What a wrapper printed before it died is still the answer; a failure that printed nothing is not. + await writeFile(join(bin, 'exit-3-flag'), `#!${process.execPath}\nprocess.stdout.write(' --standalone\\n');\nprocess.exit(3);\n`, { mode: 0o755 }); + await writeFile(join(bin, 'exit-1-silent'), `#!${process.execPath}\nprocess.exit(1);\n`, { mode: 0o755 }); const paths = name => join(bin, name); assert.equal(await openCodeSupportsStandalone(paths('help-stdout')), true, 'the flag on stdout'); assert.equal(await openCodeSupportsStandalone(paths('help-stderr')), true, 'the flag on stderr, where OpenCode 1 writes'); assert.equal(await openCodeSupportsStandalone(paths('v1-help')), false, 'a help page without the flag'); + assert.equal(await openCodeSupportsStandalone(paths('exit-3-flag')), true, 'a failing --help that printed the flag still answers v2'); + assert.equal(await openCodeSupportsStandalone(paths('exit-1-silent')), null, 'a failing --help that printed nothing is no answer'); assert.equal(await openCodeSupportsStandalone(join(home, 'missing')), null, 'a binary that is not there'); assert.equal(await openCodeSupportsStandalone(bin), null, 'a directory is not a binary'); - // execFile can throw synchronously (an empty path; on Windows, any .cmd launcher since - // CVE-2024-27980). A probe that cannot ask must answer "no answer", not reject on the caller. - assert.equal(await openCodeSupportsStandalone(''), null, 'an empty path throws synchronously'); - await writeFile(paths('exit-3'), `#!${process.execPath}\nprocess.stdout.write(' --standalone\\n');\nprocess.exit(3);\n`, { mode: 0o755 }); - assert.equal(await openCodeSupportsStandalone(paths('exit-3')), null, 'a --help that fails is no answer'); + // execFile rejects outright on some truthy paths (one with a NUL byte; on Windows, any .cmd + // launcher since CVE-2024-27980). A probe that cannot ask must answer "no answer", not reject on + // the caller. An empty or missing path answers the same way, from the guard that runs first. + assert.equal(await openCodeSupportsStandalone('a\0b'), null, 'a path execFile throws synchronously on'); + assert.equal(await openCodeSupportsStandalone(''), null, 'no path, no answer'); assert.equal(await openCodeSupportsStandalone(null), null, 'no binary, no answer'); }); @@ -926,67 +931,49 @@ test('CLI run picks the model the way the installed OpenCode accepts it', async const { home, env } = await fixture(context); const bin = join(home, 'bin'); await mkdir(bin); - // The fake answers --help (the probe) and --version (never read by Apex CLI) from separate - // variables: which generation a build is must be assertible independently of the version string - // it prints, because dev and beta builds of both generations print 0.0.0-*. + // The fake answers the probe from one variable. Apex CLI never reads --version, so the fake + // has no --version branch at all: which generation a build is comes only from what --help prints. await writeFile(join(bin, 'opencode'), `#!${process.execPath} if (process.argv[2] === '--help') { if (process.env.FAKE_OPENCODE_HELP_FAILS === '1') process.exit(1); - const help = process.env.FAKE_OPENCODE_STANDALONE === '1' + process.stdout.write(process.env.FAKE_OPENCODE_STANDALONE === '1' ? ' --standalone start a private server\\n --print-logs write logs to a file\\n' - : ' --model model to use\\n --print-logs write logs to a file\\n'; - (process.env.FAKE_OPENCODE_HELP_STREAM === 'stderr' ? process.stderr : process.stdout).write(help); + : ' --model model to use\\n --print-logs write logs to a file\\n'); process.exit(0); } -if (process.argv[2] === '--version') { console.log(process.env.FAKE_OPENCODE_VERSION); process.exit(0); } console.log(JSON.stringify({ args: process.argv.slice(2), inline: process.env.OPENCODE_CONFIG_CONTENT })); `, { mode: 0o755 }); const V2 = { FAKE_OPENCODE_STANDALONE: '1' }; const V1 = { FAKE_OPENCODE_STANDALONE: '0' }; - const run = (version, capability = {}, extra = {}, passed = ['--x']) => { - const result = cli(['run', 'opencode', '--', ...passed], - { ...env, PATH: bin, FAKE_OPENCODE_VERSION: version, ...capability, ...extra }); + const run = (capability = {}, extra = {}, passed = ['--x']) => { + const result = cli(['run', 'opencode', '--', ...passed], { ...env, PATH: bin, ...capability, ...extra }); assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); }; - assert.deepEqual(run('1.18.34', V1), { args: ['--model', `callstack.ai/${MODEL}`, '--x'] }); - assert.deepEqual(run('1.18.34', V1, {}, ['run', 'hi']).args, ['--model', `callstack.ai/${MODEL}`, 'run', 'hi']); + assert.deepEqual(run(V1), { args: ['--model', `callstack.ai/${MODEL}`, '--x'] }); + assert.deepEqual(run(V1, {}, ['run', 'hi']).args, ['--model', `callstack.ai/${MODEL}`, 'run', 'hi']); // OpenCode 2's TUI rejects --model; a private server reads the model from inline config. - const v2 = run('opencode v2.0.29', V2, { OPENCODE_CONFIG_CONTENT: '{ // OpenCode reads JSONC here too\n"theme":"dark", }' }); + const v2 = run(V2, { OPENCODE_CONFIG_CONTENT: '{ // OpenCode reads JSONC here too\n"theme":"dark", }' }); assert.deepEqual(v2.args, ['--standalone', '--x']); - assert.deepEqual(run('2.0.29', V2, {}, ['run', 'hi']).args, ['run', '--standalone', 'hi'], 'v2 flags follow the subcommand'); - assert.deepEqual(run('2.0.29', V2, {}, [home]).args, ['--standalone', home], 'a directory starts the interface'); - assert.deepEqual(run('2.0.29', V2, {}, ['mini']).args, ['mini', '--standalone']); - assert.deepEqual(run('2.0.29', V2, {}, ['auth', 'list']).args, ['auth', 'list'], 'subcommands without --standalone pass through'); + assert.deepEqual(run(V2, {}, ['run', 'hi']).args, ['run', '--standalone', 'hi'], 'v2 flags follow the subcommand'); + assert.deepEqual(run(V2, {}, [home]).args, ['--standalone', home], 'a directory starts the interface'); + assert.deepEqual(run(V2, {}, ['mini']).args, ['mini', '--standalone']); + assert.deepEqual(run(V2, {}, ['auth', 'list']).args, ['auth', 'list'], 'subcommands without --standalone pass through'); assert.deepEqual(JSON.parse(v2.inline), { theme: 'dark', model: `callstack.ai/${MODEL}` }); - // Every build of both generations that reports 0.0.0-* is decided by the flag it has, never by - // its version: these two differ only in what --help prints, and answer opposite ways. - assert.deepEqual(run('opencode v0.0.0-beta-19507', V2).args, ['--standalone', '--x'], - 'a v2 beta reports 0.0.0-* and the version regex rejected --model on it'); - assert.deepEqual(run('0.0.0-dev-202610141901', V1).args, ['--model', `callstack.ai/${MODEL}`, '--x'], - 'a v1 dev build reports 0.0.0-* too, so major 0 does not mean OpenCode 2'); - assert.deepEqual(run('opencode vlocal', V2).args, ['--standalone', '--x'], 'a source build'); - assert.deepEqual(run('Update available: 2.1.0\n1.18.34', V2).args, ['--standalone', '--x'], - 'what --version prints decides nothing'); - // OpenCode 1 prints its help on stderr, so the probe reads both streams either way. - assert.deepEqual(run('opencode v2.0.29', { ...V2, FAKE_OPENCODE_HELP_STREAM: 'stderr' }).args, - ['--standalone', '--x'], '--standalone found on stderr'); - assert.deepEqual(run('1.18.34', { ...V1, FAKE_OPENCODE_HELP_STREAM: 'stderr' }).args, - ['--model', `callstack.ai/${MODEL}`, '--x'], 'its absence on stderr too'); // A binary that cannot be asked adds no flag rather than guessing a generation and dying on it. - const unknown = run('opencode v2.0.29', { FAKE_OPENCODE_HELP_FAILS: '1' }); + const unknown = run({ FAKE_OPENCODE_HELP_FAILS: '1' }); assert.deepEqual(unknown.args, ['--x'], 'an unanswerable probe launches with neither --model nor --standalone'); assert.equal(unknown.inline, undefined, 'and the inline config is left alone'); assert.match(cli(['run', 'opencode', '--', '--x'], { ...env, PATH: bin, FAKE_OPENCODE_HELP_FAILS: '1' }).stderr, /launches without a model flag/); - const broken = cli(['run', 'opencode'], { ...env, PATH: bin, ...V2, FAKE_OPENCODE_VERSION: '2.0.0', OPENCODE_CONFIG_CONTENT: '{' }); + const broken = cli(['run', 'opencode'], { ...env, PATH: bin, ...V2, OPENCODE_CONFIG_CONTENT: '{' }); assert.equal(broken.status, 1); assert.match(broken.stderr, /Invalid JSON\/JSONC object in OPENCODE_CONFIG_CONTENT/); assert.equal(runExpansion('opencode', CALLSTACK), `opencode --model callstack.ai/${MODEL}`); assert.equal(runExpansion('opencode', CALLSTACK, { openCodeStandalone: true }), 'opencode --standalone (OPENCODE_CONFIG_CONTENT model set)'); assert.equal(runExpansion('opencode', CALLSTACK, { openCodeStandalone: null }), - 'opencode (OpenCode version unknown: no model flag added)'); + 'opencode (OpenCode interface unknown: no model flag added)'); }); test('closing the pipe early exits quietly instead of crashing', async context => { @@ -1260,9 +1247,9 @@ test('the closing block shows what each run command expands to, and when undo is const applied = cli([...INIT, '--apply'], env); assert.equal(applied.status, 0, applied.stderr); assert.match(applied.stdout, at(/^Use these commands to run callstack\/Apex with your selected harnesses:$/)); - // The fixture has no opencode on PATH, so the summary admits it cannot know which flag the - // launch will add instead of blindly showing the v1 one. - assert.match(applied.stdout, at(/^apex run opencode {2}opencode {2}\(OpenCode version unknown: no model flag added\)$/)); + // The fixture has no opencode on PATH, so there is no interface to ask about and the line says + // just the command; `apex run opencode` itself is what reports the missing binary. + assert.match(applied.stdout, at(/^apex run opencode {2}opencode$/)); assert.match(applied.stdout, at(/^apex run codex +codex --profile callstack_ai$/)); assert.match(applied.stdout, at(/^apex run pi +pi --provider callstack --model callstack\/Apex --thinking medium$/)); assert.match(applied.stdout, at(/^\.\.\.or pick "callstack\/Apex" from the UI when setting up manually\.$/)); From 1c4dc094ce6f8cb553bcbf5989452e0eadb69f98 Mon Sep 17 00:00:00 2001 From: Alexandre Pereira Date: Thu, 15 Oct 2026 14:20:00 +0100 Subject: [PATCH 3/3] chore: tighten comments around the OpenCode probe --- src/assistants.js | 27 +++++++++------------------ src/cli.js | 7 ++----- test/apex.test.js | 23 +++++++++-------------- 3 files changed, 20 insertions(+), 37 deletions(-) diff --git a/src/assistants.js b/src/assistants.js index d032616..7371f65 100644 --- a/src/assistants.js +++ b/src/assistants.js @@ -100,23 +100,18 @@ export async function executable(command, env = process.env, platform = process. const execFileAsync = promisify(execFile); -// OpenCode 2's interface has --standalone; OpenCode 1's never had it. Asking the binary which -// interface it has beats reading its version: dev, beta and source builds of *both* generations -// report `0.0.0-*` or `local`, so no version regex separates them. OpenCode 1 writes --help to -// stderr and OpenCode 2 to stdout, so both streams are checked. The answer is true (OpenCode 2), -// false (OpenCode 1) or null: no answer, which callers must report instead of guessing a launch. -// Even a failing or timed-out --help counts as an answer when its output printed the flag; the -// answer is null only when nothing proved either way. +// Only OpenCode 2 has --standalone, so its --help tells the generations apart. The version can't: +// dev and beta builds of both report `0.0.0-*` or `local`. OpenCode 1 prints its help on stderr +// and OpenCode 2 on stdout, so both are checked. Resolves true (OpenCode 2), false (OpenCode 1) +// or null when the binary gave no usable answer; it never rejects. export async function openCodeSupportsStandalone(binary) { if (!binary) return null; try { const { stdout, stderr } = await execFileAsync(binary, ['--help'], { timeout: 5000 }); return `${stdout}${stderr}`.includes('--standalone'); } catch (error) { - // A nonzero exit or a killed timeout still delivers everything the binary printed, and the flag - // in that output answers the question; a spawn that never ran prints nothing and stays unknown. - // The await also catches what execFile throws synchronously (a .cmd launcher on Windows since - // CVE-2024-27980): a probe that cannot ask must never reject on its caller. + // Covers nonzero exits, timeouts, spawn failures and synchronous throws (e.g. a .cmd on + // Windows). Output printed before a failure still counts; without the flag it proves nothing. return `${error.stdout}${error.stderr}`.includes('--standalone') ? true : null; } } @@ -283,10 +278,8 @@ export function launchToken(env, provider) { return token; } -// `openCodeStandalone` is openCodeSupportsStandalone's answer: true launches the OpenCode 2 way, -// false the OpenCode 1 way, and null — no answer — launches with no model flag at all rather than -// guessing a generation and dying on a flag it rejects. The default is the launch Apex CLI has -// always used for a caller that never probed. +// `openCodeStandalone` is the result of openCodeSupportsStandalone: true for OpenCode 2, false for +// OpenCode 1, null when unknown. Unknown adds no model flag, since a wrong one makes OpenCode exit. export function launchOptions(id, env = process.env, provider = defaultProvider(env), { openCodeStandalone = false } = {}) { const { model } = provider; const token = launchToken(env, provider); @@ -295,9 +288,7 @@ export function launchOptions(id, env = process.env, provider = defaultProvider( case 'opencode': { const selected = `${provider.opencode}/${model}`; if (openCodeStandalone === null) { - // Both generations launch fine without our flag, so the assistant still starts, just on - // the user's own model. `warning` is what the launch says on stderr; `apex run` prints it - // rather than writing its own version of the same sentence. + // Both generations start without a model flag, on the user's own configured model. return { args: [], env: childEnv, note: '(OpenCode interface unknown: no model flag added)', diff --git a/src/cli.js b/src/cli.js index e7cdaca..84dfcc4 100644 --- a/src/cli.js +++ b/src/cli.js @@ -379,9 +379,7 @@ async function commandInit(rest) { ui.dim('Apex CLI never reads, stores or logs the key itself.'), ]); const runnable = active.map(entry => entry.id).filter(id => RUNNABLE.includes(id)); - // Only a probe that ran can answer "unknown interface". An OpenCode that is not on PATH yet has - // nothing to ask and `apex run` cannot launch it at all, so its line says just the command; - // `apex run opencode` itself is what tells them to install it. + // OpenCode not on PATH can't be probed; show the bare command and let `apex run` report it. const opencodeBinary = active.find(entry => entry.id === 'opencode')?.binary; const probed = opencodeBinary ? await openCodeSupportsStandalone(opencodeBinary) : undefined; const installed = { openCodeStandalone: probed }; @@ -575,8 +573,7 @@ async function commandRun(rest) { throw new Error('Windows .cmd/.bat launchers are not executed through a shell. Use WSL or launch the configured assistant directly.'); } // OpenCode 1 and 2 take the model in different ways, so the launch depends on which is installed. - // An unknown answer adds no flag at all; the launch's own warning says so on stderr, because the - // child owns stdout from here on. + // The warning goes to stderr because the child owns stdout. const standalone = id === 'opencode' ? await openCodeSupportsStandalone(binary) : false; const options = launchOptions(id, process.env, provider, { openCodeStandalone: standalone }); if (options.warning) console.error(styleText('yellow', options.warning)); diff --git a/test/apex.test.js b/test/apex.test.js index 23c32ad..794731b 100644 --- a/test/apex.test.js +++ b/test/apex.test.js @@ -33,9 +33,9 @@ async function fixture(context) { // `npm test` and `npx` set this, and it decides how follow-up commands are spelled. delete env.CI; delete env.npm_command; - // A gateway key in the developer's shell would change the default gateway, and their own - // XDG_DATA_HOME would point OpenCode's key store at their real one. Their inline config would - // reach a launched OpenCode the same way it reaches their shell. + // A gateway key in the developer's shell would change the default gateway, their own + // XDG_DATA_HOME would point OpenCode's key store at their real one, and their inline OpenCode + // config would reach the fake binaries. for (const provider of Object.values(PROVIDERS)) if (provider !== CALLSTACK) delete env[provider.envVar]; delete env.XDG_DATA_HOME; delete env.OPENCODE_CONFIG_CONTENT; @@ -902,13 +902,12 @@ process.exit(0); `; await writeFile(join(bin, 'help-stdout'), script('stdout'), { mode: 0o755 }); await writeFile(join(bin, 'help-stderr'), script('stderr'), { mode: 0o755 }); - // OpenCode 1 prints its help without --standalone; a v1 dev build is indistinguishable by - // --version alone, which is exactly why the probe asks about the flag instead. + // OpenCode 1's help has no --standalone. await writeFile(join(bin, 'v1-help'), `#!${process.execPath} process.stderr.write('Usage: opencode [flags]\\n --model model to use\\n'); process.exit(0); `, { mode: 0o755 }); - // What a wrapper printed before it died is still the answer; a failure that printed nothing is not. + // Output from a failing --help still counts; a failure with no output is unknown. await writeFile(join(bin, 'exit-3-flag'), `#!${process.execPath}\nprocess.stdout.write(' --standalone\\n');\nprocess.exit(3);\n`, { mode: 0o755 }); await writeFile(join(bin, 'exit-1-silent'), `#!${process.execPath}\nprocess.exit(1);\n`, { mode: 0o755 }); const paths = name => join(bin, name); @@ -919,9 +918,7 @@ process.exit(0); assert.equal(await openCodeSupportsStandalone(paths('exit-1-silent')), null, 'a failing --help that printed nothing is no answer'); assert.equal(await openCodeSupportsStandalone(join(home, 'missing')), null, 'a binary that is not there'); assert.equal(await openCodeSupportsStandalone(bin), null, 'a directory is not a binary'); - // execFile rejects outright on some truthy paths (one with a NUL byte; on Windows, any .cmd - // launcher since CVE-2024-27980). A probe that cannot ask must answer "no answer", not reject on - // the caller. An empty or missing path answers the same way, from the guard that runs first. + // execFile throws synchronously on some paths (a NUL byte here, a .cmd on Windows). assert.equal(await openCodeSupportsStandalone('a\0b'), null, 'a path execFile throws synchronously on'); assert.equal(await openCodeSupportsStandalone(''), null, 'no path, no answer'); assert.equal(await openCodeSupportsStandalone(null), null, 'no binary, no answer'); @@ -931,8 +928,7 @@ test('CLI run picks the model the way the installed OpenCode accepts it', async const { home, env } = await fixture(context); const bin = join(home, 'bin'); await mkdir(bin); - // The fake answers the probe from one variable. Apex CLI never reads --version, so the fake - // has no --version branch at all: which generation a build is comes only from what --help prints. + // The fake's --help output, and so the detected generation, is driven by environment variables. await writeFile(join(bin, 'opencode'), `#!${process.execPath} if (process.argv[2] === '--help') { if (process.env.FAKE_OPENCODE_HELP_FAILS === '1') process.exit(1); @@ -960,7 +956,7 @@ console.log(JSON.stringify({ args: process.argv.slice(2), inline: process.env.OP assert.deepEqual(run(V2, {}, ['mini']).args, ['mini', '--standalone']); assert.deepEqual(run(V2, {}, ['auth', 'list']).args, ['auth', 'list'], 'subcommands without --standalone pass through'); assert.deepEqual(JSON.parse(v2.inline), { theme: 'dark', model: `callstack.ai/${MODEL}` }); - // A binary that cannot be asked adds no flag rather than guessing a generation and dying on it. + // An unknown generation gets neither flag, and a warning. const unknown = run({ FAKE_OPENCODE_HELP_FAILS: '1' }); assert.deepEqual(unknown.args, ['--x'], 'an unanswerable probe launches with neither --model nor --standalone'); assert.equal(unknown.inline, undefined, 'and the inline config is left alone'); @@ -1247,8 +1243,7 @@ test('the closing block shows what each run command expands to, and when undo is const applied = cli([...INIT, '--apply'], env); assert.equal(applied.status, 0, applied.stderr); assert.match(applied.stdout, at(/^Use these commands to run callstack\/Apex with your selected harnesses:$/)); - // The fixture has no opencode on PATH, so there is no interface to ask about and the line says - // just the command; `apex run opencode` itself is what reports the missing binary. + // The fixture has no opencode on PATH, so nothing is probed and the line is the bare command. assert.match(applied.stdout, at(/^apex run opencode {2}opencode$/)); assert.match(applied.stdout, at(/^apex run codex +codex --profile callstack_ai$/)); assert.match(applied.stdout, at(/^apex run pi +pi --provider callstack --model callstack\/Apex --thinking medium$/));