open-design/apps/daemon/tests/runtimes/env-and-detection.test.ts
lefarcen 98a2c63973
feat(daemon): add Antigravity agent adapter (#3157)
* feat(daemon): add Antigravity agent adapter

Adds Google Antigravity (`agy` CLI) as a coding-agent runtime. Detection
picks up `agy` on PATH, the daemon spawns `agy -p "<prompt>"` for a
single non-interactive turn, and the assistant text reply streams back
on stdout. OAuth is shared with the Antigravity IDE through the system
keyring, so users who have signed into the desktop app are authenticated
on first run with no extra step.

`agy` v1.0.3 has no JSON / stream-json / ACP output mode (upstream issue
#119), no `--model` flag (issue #35), and no MCP forwarding hook yet —
the adapter ships with `streamFormat: 'plain'` and a single `default`
fallback model so the model picker doesn't mislead users into thinking
their choice is wired through. We will upgrade buildArgs + add a
dedicated event parser when upstream ships structured output.

Also gitignores `.antigravitycli/`, the project-local config directory
`agy` auto-creates on every run (upstream issue #175).

* fix(daemon): Antigravity adapter — stdin prompt, brand icon, form loop, empty-output guard

- Switch prompt delivery from argv to stdin (`agy -p -`) to avoid the
  30KB maxPromptArgBytes limit that blocked real-world composed prompts
- Add official Antigravity brand SVG icon to agent picker
- Fix repeated question-form loop for plain agents by injecting an
  OVERRIDE block when form answers are already present in the transcript
- Add empty-output guard for plain agents so expired auth or silent
  failures surface a user-visible error instead of a blank "Done" turn

* feat(daemon): expand Antigravity adapter — model picker, form-loop fix, OAuth launcher, log-file classification

PR #3157 follow-up integrating four iterations from end-to-end manual
testing on Gemini 3.5 Flash + GPT-OSS 120B Medium through `agy` v1.0.3.
Each section is independently verifiable; combined they're what made
the first successful artifact generation work end-to-end.

## Model picker via settings.json (agy has no --model flag)

agy v1.0.3 ships no `--model` CLI flag (upstream issue #35), but the
TUI Switch-Model picker writes the chosen label to
`~/.gemini/antigravity-cli/settings.json`'s `"model"` field, and every
`-p` invocation re-reads that file on startup — verified by capturing
the `--log-file` line `Propagating selected model override to backend:
label="<model>"`. Antigravity's `fallbackModels` now lists the 8
labels its TUI exposes (Gemini 3.1 Pro / 3.5 Flash variants, Claude
Sonnet/Opus 4.6 Thinking, GPT-OSS 120B Medium) and `buildArgs`
persists the user's choice to settings.json right before spawn. The
synthetic `default` id is preserved — picking it leaves settings.json
untouched so a user who switches models from agy's own TUI keeps
their choice.

Introduces `RuntimeAgentDef.supportsCustomModel?: boolean`. AMR's
hardcoded blocklist in `SettingsDialog.tsx` migrates to the
declarative flag (it rejects free-form ids at the ACP layer), and
antigravity opts out because its label set is a server-side enum that
silently fails on unrecognised strings.

## Form-loop fix (transcript sanitizer + stronger OVERRIDE)

The discovery form loop on weak/medium plain-stream models (GPT-OSS
120B Medium, Gemini 3.5 Flash) had two reinforcing causes:

  1. `buildDaemonTranscript` packed the prior assistant turn's
     literal `<question-form>` markup into the user request on the
     next turn, giving the model a template to echo. New
     `sanitizePriorAssistantTurnForTranscript` strips
     `<question-form>...</question-form>` blocks and ```json fences
     that match form-schema shape, replacing them with a brief
     placeholder. User content is preserved verbatim (a user who
     legitimately mentions `<question-form>` in chat keeps their
     message intact).
  2. The OVERRIDE block on form-answered turns was 4 lines and only
     banned the bare `<question-form>` tag — models still emitted the
     fenced JSON, form-asking prose ("Got it — tell me the following"),
     and fake system events ("subagents stopped"). The new
     `FORM_ANSWERED_SYSTEM_OVERRIDE` enumerates each anti-pattern and
     pins them via tests, so silently weakening any line reintroduces
     the regression.

Also adds RuntimeAgentDef.resumesSessionViaCli + RuntimeContext.
hasPriorAssistantTurn as forward-looking abstractions (skipTranscript
option on composeChatUserRequestForAgent). Antigravity does NOT opt
in — agy's `-c` resume activates an internal agentic loop with tool
retries and fallback-to-cached-response on tool errors that the OD
system prompt cannot steer; reverted after seeing byte-identical
form re-emissions caused by agy's own retry logic, not OD's transcript.

## One-click OAuth via system terminal

agy print mode can't complete Google Sign-In on its own (the OAuth
callback page asks the user to paste an auth code back into agy, but
`-p` has no input field). Before this commit the auth banner only
told the user to "open a terminal yourself."

Adds `POST /api/agents/antigravity/oauth-launch` and a cross-platform
launcher in `runtimes/terminal-launch.ts`:

  - macOS:    osascript → Terminal.app `do script "agy"` + activate
  - Linux:    tries x-terminal-emulator, gnome-terminal, konsole,
              xfce4-terminal, xterm in order
  - Windows:  `cmd /c start "Open Design" cmd /k agy`

The endpoint hardcodes the `agy` command (no user input → no shell
injection surface) and is loopback-gated like the other daemon
endpoints. The chat's `AGENT_AUTH_REQUIRED` banner now renders a
"Sign in via terminal" button next to Retry; clicking it spawns the
terminal so the user can finish OAuth in one click.

## Silent-failure classification (auth vs quota via --log-file)

agy print mode is silent on stdout/stderr for both missing-OAuth AND
quota-exhausted failures — the upstream
`RESOURCE_EXHAUSTED (code 429): Individual quota reached` and the
`not logged into Antigravity` line only surface in agy's
`--log-file`. Without log inspection the daemon misread quota as
"auth required" and showed the wrong banner.

`RuntimeContext.agentLogFilePath` carries a daemon-owned per-run temp
path that antigravity's buildArgs translates to `--log-file <path>`.
The empty-output guard now reads that log on a `code === 0 &&
!childStdoutSeen` exit, feeds the tail to
`classifyAgentServiceFailure`, and routes:

  - "not logged into Antigravity"     → AGENT_AUTH_REQUIRED with
                                        antigravityAuthGuidance
  - "RESOURCE_EXHAUSTED" / "quota" /  → RATE_LIMITED with
    "Individual quota reached"          antigravityQuotaGuidance
  - none of the above (rare)          → fall back to auth guidance
                                        as the most likely cause

Both surface a terminal launcher in the auth banner: auth gets "Sign
in via terminal", quota gets "Switch model in terminal" — same
endpoint, contextual label. The handler is identical (open agy in a
terminal); the user either signs in or uses agy's Switch Model
picker to pick a model with available quota.

## Validation

- `pnpm guard` pass
- `pnpm --filter @open-design/daemon` runtime + telemetry suites:
  192 passed, 1 skipped (the 1 pre-existing `task-type` failure on
  origin/main is unrelated to this change)
- `pnpm --filter @open-design/web` typecheck pass; sse / amr-guidance
  / AgentIcon suites pass (51 web tests)
- Manual end-to-end on darwin + Gemini 3.5 Flash and GPT-OSS 120B
  Medium: turn-1 question-form rendered correctly, turn-2 produced
  `<artifact>` with full HTML (3.3KB Modern Minimal design) instead
  of re-emitting the form. agy `--log-file` content correctly
  classified as RATE_LIMITED when Gemini Pro quota was exhausted,
  and as AGENT_AUTH_REQUIRED when keychain was cleared.

* fix(web/test): align amrAgent fixture with supportsCustomModel contract

The AMR agent definition in the daemon ships `supportsCustomModel: false`
so the Settings model picker hides the free-text "Custom…" option. The
PR changed `allowCustomModel` from `selected.id !== 'amr'` (hardcoded)
to `selected.supportsCustomModel !== false` (declarative), but the test
fixture was not updated to carry the same field — causing the
`__custom__` sentinel to appear in the picker under test.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

* fix(daemon): align formAnswerTransition wording with main + scope build directive to discovery

CI surfaced two failures on the merge with main:
- chat-route.test marks submitted discovery form answers ... expected
  the main-version wording 'Do not emit another <formId> form.'
- telemetry-message-finalization keeps non-discovery form answers
  active ... expected task-type to fall through the else branch
  ('Treat these form answers as the active user turn'), not the
  discovery RULE 2/RULE 3 build branch.

The colleague's earlier fba1e40b form-loop fix tightened both pieces
(stronger wording + grouped discovery|task-type into the build branch)
but didn't update the tests that pin the contract. Revert the
transition wording to main and re-scope the build directive to
'discovery' only. The aggressive form-loop suppression we added in
this PR now lives in the system-prompt FORM_ANSWERED_SYSTEM_OVERRIDE
block, which is far stronger than the user-request transition text
this commit reverts.

* fix(daemon): scope formOverride by form id, detach Linux terminal, move agy log cleanup to finally

- FORM_ANSWERED_GENERIC_OVERRIDE: new exported constant for non-discovery/
  non-task-type form ids; contains only the "do not re-ask" suppression
  without the RULE 2 / RULE 3 / artifact directive.
- formAnswerTransitionForCurrentPrompt: extend build-transition branch to
  include task-type alongside discovery, keeping user-turn and system
  override consistent.
- Prompt assembly (server.ts ~10848): derive formOverride from the parsed
  form id — FORM_ANSWERED_SYSTEM_OVERRIDE for discovery/task-type,
  FORM_ANSWERED_GENERIC_OVERRIDE for all other form ids, empty otherwise.
- launchOnLinux: replace execFileAsync (waited for terminal exit, 3 s cap)
  with spawn({ detached: true, stdio: 'ignore' }) + unref(); resolve on
  the 'spawn' event so long-lived interactive terminals (xterm, konsole)
  are not killed mid-OAuth-flow.
- Antigravity log cleanup: move fs.promises.unlink(agentLogFilePath) into
  a try/finally wrapper around the close handler so every exit path
  (success, failure, cancel, non-zero exit) cleans up the per-run temp
  file, preventing unbounded /tmp accumulation.
- Tests: rename task-type case to assert build-transition behaviour; add
  generic-form-id case (preferences) pinning the non-build path; add
  FORM_ANSWERED_GENERIC_OVERRIDE content assertions.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

* fix(daemon): switch Antigravity buildArgs to chat subcommand invocation

Replace top-level `-p -` with `agy chat [--log-file …] -` so the adapter
uses the documented chat subcommand and stdin sentinel instead of the
unrecognised global -p flag.  Update the agent-args test description and
all four deepEqual assertions to assert the ['chat', '-'] shape.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

* test(daemon): drop real-platform default-launch assertion from terminal-launch suite

The removed test called launchAgentInSystemTerminal('agy') with no
platform override, which invokes the real system terminal on every
developer machine running the daemon test suite (Terminal.app on macOS,
cmd.exe on Windows, xterm/gnome-terminal on Linux). That is an
unacceptable OS side effect for a unit test.

The behaviour being asserted — that omitting platform selects
process.platform — is a TypeScript default-parameter guarantee, not a
runtime invariant that needs an integration test. The remaining 'aix'
case continues to pin the unsupported-platform failure shape.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

* fix(daemon): buffer Antigravity stdout to suppress auth URL before close-time classifier

The plain-stream close handler at code===0 can detect an agy OAuth
prompt in agentStdoutTail and emit AGENT_AUTH_REQUIRED, but by the
time close fires the stdout chunk has already been forwarded to the
client via the plain-stream `send('stdout', { chunk })` path. This
leaves both the raw OAuth URL and the terminal-launch guidance visible
in chat.

Buffer all stdout chunks for the `antigravity` agent instead of
forwarding them immediately. The existing close-time auth-prompt guard
(code===0, !trackingSubstantiveOutput, childStdoutSeen) returns early
when it detects the auth pattern, leaving the buffer unflushed and the
OAuth URL out of the SSE stream. For legitimate assistant output the
buffer is flushed in order just before design.runs.finish so the
chunks still arrive before the run's finished event.

Adds a chat-route integration test using a fake `agy` that exits 0
after printing the canonical auth prompt; asserts that the run emits
AGENT_AUTH_REQUIRED with no event: stdout delta containing the URL.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

* test(daemon): isolate antigravity buildArgs argv test from real settings file

Pass a temp antigravitySettingsPath in the RuntimeContext for the
withModel argv assertion so unit tests do not touch
~/.gemini/antigravity-cli/settings.json. Adds the optional
antigravitySettingsPath field to RuntimeContext and threads it
through buildArgs to writeAntigravityModelSelection; production
callers leave it undefined, preserving the existing default path.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

* fix(daemon): revert Antigravity buildArgs to `-p -` (the only working agy v1.0.3 invocation)

The looper-reviewer-bot reported `chat` as agy's headless subcommand
based on its environment's agy build, and looper-fixer applied that
shape. The installed CLI (`agy --version` reports `1.0.3`) does NOT
expose a `chat` subcommand — `agy --help`'s `Available subcommands`
section lists only `changelog / help / install / plugin / update`,
and `agy chat - < prompt` exits 0 with empty stdout (the daemon then
forwards it as a 'successful' empty reply, exactly the failure mode
the auth/quota guard at server.ts ~12090 is meant to catch — for the
wrong reason).

`-p` is the documented print-mode flag (`Short alias for --print`)
and `agy -p -` reads the prompt from stdin and prints the model
reply, which the entire end-to-end test sequence in this PR has
verified against (form-loop fix, settings.json model routing,
log-file classification all confirmed working on Gemini 3.5 Flash
+ GPT-OSS 120B Medium with this invocation).

Updates the agent-args test to pin `['-p', '-']` instead of
`['chat', '-']` and adds an inline comment in antigravity.ts noting
that `chat` may exist in a future agy build but is not the contract
on the installed CLI today.

* fix(daemon): serialize Antigravity concrete-model spawns to dodge settings.json race

Reviewer (looper) flagged a concurrency race in the model-routing path:
~/.gemini/antigravity-cli/settings.json is process-global, so two OD
runs starting close together with different concrete models can race
the file — run A writes model A, run B writes model B, then A's agy
finally reads settings.json and executes on model B. The Settings
model picker becomes nondeterministic under parallel conversations.

Adds a per-process promise chain in antigravity.ts:
  - acquireAntigravityModelLock(): chain-await + return release fn
  - waitForAgyToReadModel(logPath, expected): polls agy's --log-file
    for the upstream signal
      'Propagating selected model override to backend: label="<X>"'
    which model_config_manager.go emits once agy has finished reading
    settings.json. Returns true on observed match, false on timeout.
    Regex-escapes the expected label so '(' / ')' in 'GPT-OSS 120B
    (Medium)' match literally, not as a capture group.

server.ts spawn pipeline now acquires the lock BEFORE buildArgs (which
performs the settings.json write) and schedules a release-once handler
that fires when EITHER (a) the log-file confirms agy read the model
or (b) the child exits — the exit fallback prevents a stuck/crashed
agy from starving the queue for every subsequent antigravity spawn.

Default-model spawns bypass the lock entirely: their buildArgs doesn't
touch settings.json, so there's nothing to serialize.

Tests pin:
  - FIFO ordering across 2 / 3 concurrent acquirers
  - Wait helper's regex correctly matches parenthesized labels
  - Wait helper does NOT match a different model with shared prefix
  - Wait helper swallows missing-log-file errors and returns false on
    timeout (no spawn-pipeline crash if the log never appears)

194 → 198 passing runtime tests, 0 regressions.

* fix(daemon): close Antigravity lock release race on slow agy startup (looper #263fd2fe7)

Reviewer flagged that the previous serialization scheduled
`releaseOnce` in `.finally()` on waitForAgyToReadModel — meaning the
helper's `false` timeout return ALSO released the lock. If agy took
longer than the 15s polling window to read settings.json (cold start,
swap-thrash, slow network handshake to the upstream backend), run A's
lock dropped at 15s, run B rewrote settings.json with model B, and
run A's still-starting agy then read the wrong model. Same race the
original mutex was meant to close.

Fix the release semantics to be release-on-confirmation-only:

  - waitForAgyToReadModel: `false` now strictly means 'I gave up
    polling,' not 'agy definitely did not read this.' Document the
    contract so a future caller can't conflate the two. Add an
    optional AbortSignal so server.ts can stop polling when the child
    exits — without it, the leftover watcher could outlive the run
    and accidentally match a later concurrent run's log content,
    releasing the wrong lock.
  - server.ts: schedule `releaseOnce` only when waitForAgyToReadModel
    returns true. The exit handler (which fires for crashes, fast
    exits, normal completion) is now the canonical fallback that
    releases the lock no matter what — the queue can't starve
    permanently because agy always exits eventually. The exit
    handler also fires the AbortController so the watcher cleans up.

New tests pin:
  - timeout returns false WITHOUT any release-implying side effect
  - already-aborted signal short-circuits (no readFile calls)
  - abort mid-poll wakes the helper from its setTimeout (no
    multi-hundred-ms hang waiting out a poll interval that no longer
    matters)

198 → 201 passing runtime tests, 0 regressions.

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-05-29 05:43:37 +00:00

1059 lines
39 KiB
TypeScript

import { symlinkSync } from 'node:fs';
import { test, vi } from 'vitest';
import { homedir } from 'node:os';
import * as platform from '@open-design/platform';
import {
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
const fsTest = process.platform === 'win32' ? test.skip : test;
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
// credentials, silently billing API usage. Strip it for the claude
// adapter so the user's subscription wins.
test('spawnEnvForAgent strips ANTHROPIC_API_KEY for the claude adapter', () => {
const env = spawnEnvForAgent('claude', {
ANTHROPIC_API_KEY: 'sk-leak',
PATH: '/usr/bin',
OD_DAEMON_URL: 'http://127.0.0.1:7456',
});
assert.equal('ANTHROPIC_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.OD_DAEMON_URL, 'http://127.0.0.1:7456');
});
test('spawnEnvForAgent applies configured Claude Code env before auth stripping', () => {
const env = spawnEnvForAgent(
'claude',
{
ANTHROPIC_API_KEY: 'sk-leak',
PATH: '/usr/bin',
},
{
CLAUDE_CONFIG_DIR: '/Users/test/.claude-2',
},
);
assert.equal(env.CLAUDE_CONFIG_DIR, '/Users/test/.claude-2');
assert.equal('ANTHROPIC_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent applies configured Codex env without mutating the base env', () => {
const base = { PATH: '/usr/bin' };
const env = spawnEnvForAgent('codex', base, {
CODEX_HOME: '/Users/test/.codex-alt',
CODEX_BIN: '/Users/test/bin/codex',
});
assert.equal(env.CODEX_HOME, '/Users/test/.codex-alt');
assert.equal(env.CODEX_BIN, '/Users/test/bin/codex');
assert.equal(env.PATH, '/usr/bin');
assert.equal('CODEX_HOME' in base, false);
assert.equal('CODEX_BIN' in base, false);
});
test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => {
const env = spawnEnvForAgent(
'gemini',
{
HTTPS_PROXY: 'http://user-env:9000',
PATH: '/usr/bin',
},
{},
{
HTTP_PROXY: 'http://system-http:7890',
HTTPS_PROXY: 'http://system-https:7891',
ALL_PROXY: 'socks5://system-socks:1080',
NO_PROXY: '.local,localhost',
NODE_USE_ENV_PROXY: '1',
},
);
assert.equal(env.HTTP_PROXY, 'http://system-http:7890');
assert.equal(env.HTTPS_PROXY, 'http://user-env:9000');
assert.equal(env.ALL_PROXY, 'socks5://system-socks:1080');
assert.equal(env.NO_PROXY, '.local,localhost');
assert.equal(env.NODE_USE_ENV_PROXY, '1');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent resolves system proxy env for each default agent launch', () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTPS_PROXY: 'http://system-https:7891',
NODE_USE_ENV_PROXY: '1',
});
try {
const env = spawnEnvForAgent('gemini', { PATH: '/usr/bin' });
assert.deepEqual(proxySpy.mock.calls, [[]]);
assert.equal(env.HTTPS_PROXY, 'http://system-https:7891');
assert.equal(env.PATH, '/usr/bin');
} finally {
proxySpy.mockRestore();
}
});
test('spawnEnvForAgent lets explicit lowercase proxy env override system uppercase proxy env', () => {
const env = spawnEnvForAgent(
'gemini',
{
https_proxy: 'http://user-lowercase:9000',
PATH: '/usr/bin',
},
{},
{
HTTPS_PROXY: 'http://system-uppercase:7891',
NODE_USE_ENV_PROXY: '1',
},
);
assert.equal(env.HTTPS_PROXY, 'http://user-lowercase:9000');
if (process.platform !== 'win32') {
assert.equal(env.https_proxy, 'http://user-lowercase:9000');
}
});
test('spawnEnvForAgent enables Node env proxy support for inherited lowercase proxy env', () => {
const env = spawnEnvForAgent(
'gemini',
{
http_proxy: 'http://user-lowercase:9000',
PATH: '/usr/bin',
},
{},
{},
);
assert.equal(env.HTTP_PROXY, 'http://user-lowercase:9000');
assert.equal(env.NODE_USE_ENV_PROXY, '1');
if (process.platform !== 'win32') {
assert.equal(env.http_proxy, 'http://user-lowercase:9000');
}
});
test('spawnEnvForAgent expands configured env home paths', () => {
const env = spawnEnvForAgent('codex', { PATH: '/usr/bin' }, {
CODEX_HOME: '~/.codex-alt',
CODEX_CACHE: '~',
});
assert.equal(env.CODEX_HOME, join(homedir(), '.codex-alt'));
assert.equal(env.CODEX_CACHE, homedir());
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent injects the resolved AMR profile after configured env', () => {
const env = spawnEnvForAgent(
'amr',
{
OPEN_DESIGN_AMR_PROFILE: 'test',
VELA_PROFILE: 'prod',
PATH: '/usr/bin',
},
{
VELA_PROFILE: 'local',
},
);
assert.equal(env.VELA_PROFILE, 'test');
assert.equal(env.OPEN_DESIGN_AMR_PROFILE, 'test');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent gives AMR a stable OpenCode home under OD_DATA_DIR', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-amr-data-'));
try {
const env = spawnEnvForAgent('amr', {
OD_DATA_DIR: dataDir,
PATH: '/usr/bin',
});
assert.equal(
env.OPENCODE_TEST_HOME,
join(dataDir, 'amr', 'opencode-home'),
);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent preserves a configured AMR OpenCode home override', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-amr-data-'));
try {
const configuredHome = join(dataDir, 'custom-opencode-home');
const env = spawnEnvForAgent(
'amr',
{
OD_DATA_DIR: dataDir,
PATH: '/usr/bin',
},
{
OPENCODE_TEST_HOME: configuredHome,
},
);
assert.equal(env.OPENCODE_TEST_HOME, configuredHome);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
fsTest('spawnEnvForAgent gives AMR a discovered OpenCode binary under a minimal child PATH', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-amr-opencode-home-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
const opencodeBinDir = join(dir, '.opencode', 'bin');
const opencodeBin = join(opencodeBinDir, 'opencode');
mkdirSync(opencodeBinDir, { recursive: true });
writeFileSync(opencodeBin, '#!/bin/sh\nexit 0\n');
chmodSync(opencodeBin, 0o755);
process.env.PATH = '/usr/bin';
process.env.OD_AGENT_HOME = dir;
const env = spawnEnvForAgent('amr', { PATH: '/usr/bin' });
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.VELA_OPENCODE_BIN, opencodeBin);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable prefers a configured CODEX_BIN override over PATH resolution', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
const configured = join(dir, 'codex-custom');
writeFileSync(configured, '#!/bin/sh\nexit 0\n');
chmodSync(configured, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = dir;
const resolved = resolveAgentExecutable(
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
);
assert.equal(resolved, configured);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('inspectAgentExecutableResolution reports configured and PATH Codex binaries separately', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-inspect-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
const configured = join(dir, 'codex-custom');
const fallback = join(dir, 'codex');
writeFileSync(configured, '#!/bin/sh\nexit 0\n');
writeFileSync(fallback, '#!/bin/sh\nexit 0\n');
chmodSync(configured, 0o755);
chmodSync(fallback, 0o755);
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const resolution = inspectAgentExecutableResolution(
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
);
assert.deepEqual(resolution, {
configuredOverridePath: configured,
pathResolvedPath: fallback,
selectedPath: configured,
});
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable supports configured binary overrides for non-Codex adapters', () => {
const cases: Array<[string, string, string]> = [
['claude', 'claude', 'CLAUDE_BIN'],
['gemini', 'gemini', 'GEMINI_BIN'],
['opencode', 'opencode', 'OPENCODE_BIN'],
['cursor-agent', 'cursor-agent', 'CURSOR_AGENT_BIN'],
['qwen', 'qwen', 'QWEN_BIN'],
['qoder', 'qodercli', 'QODER_BIN'],
['copilot', 'copilot', 'COPILOT_BIN'],
['deepseek', 'deepseek', 'DEEPSEEK_BIN'],
['trae-cli', 'traecli', 'TRAE_CLI_BIN'],
['aider', 'aider', 'AIDER_BIN'],
];
const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-overrides-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
process.env.PATH = '';
process.env.OD_AGENT_HOME = dir;
for (const [id, binName, envKey] of cases) {
const configured = join(dir, `${binName}-custom`);
writeFileSync(configured, '#!/bin/sh\nexit 0\n');
chmodSync(configured, 0o755);
const resolved = resolveAgentExecutable(
minimalAgentDef({ id, bin: binName }),
{ [envKey]: configured },
);
assert.equal(resolved, configured, `expected ${id} to use ${envKey}`);
}
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable prefers opencode-cli before desktop opencode fallback', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-opencode-cli-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
const cli = join(dir, 'opencode-cli');
const desktop = join(dir, 'opencode');
writeFileSync(cli, '#!/bin/sh\nexit 0\n');
writeFileSync(desktop, '#!/bin/sh\nexit 0\n');
chmodSync(cli, 0o755);
chmodSync(desktop, 0o755);
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
assert.equal(resolveAgentExecutable(opencode), cli);
rmSync(cli, { force: true });
assert.equal(resolveAgentExecutable(opencode), desktop);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents includes sanitized install and docs metadata from split runtime metadata', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agent-install-meta-'));
try {
return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const qoder = agents.find((agent) => agent.id === 'qoder');
const deepseek = agents.find((agent) => agent.id === 'deepseek');
assert.ok(qoder);
assert.equal(qoder.available, false);
assert.equal(qoder.installUrl, 'https://qoder.com/download');
assert.equal(qoder.docsUrl, 'https://docs.qoder.com/');
assert.ok(deepseek);
assert.equal(
deepseek.docsUrl,
'https://github.com/Hmbown/CodeWhale/blob/main/README.md',
);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
fsTest('detectAgents marks Codex available when nvm exposes a node shim but launch resolution upgrades it to the native binary', async () => {
const home = mkdtempSync(join(tmpdir(), 'od-detect-codex-nvm-native-'));
try {
return await withEnvSnapshot(['HOME', 'PATH', 'OD_AGENT_HOME'], async () => {
const wrapperBinDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'bin');
const wrapperPkgDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'lib', 'node_modules', '@openai', 'codex');
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
const wrapperLinkPath = join(wrapperBinDir, 'codex');
const nativePkgDir = join(
wrapperPkgDir,
'node_modules',
'@openai',
`codex-${process.platform}-${process.arch}`,
);
const nativeTargetTriple = codexNativeTargetTriple();
const nativePathDir = join(nativePkgDir, 'vendor', nativeTargetTriple, 'path');
const nativeBin = join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex', 'codex');
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
mkdirSync(wrapperBinDir, { recursive: true });
mkdirSync(join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex'), { recursive: true });
mkdirSync(nativePathDir, { recursive: true });
writeFileSync(
wrapperRealPath,
'#!/usr/bin/env node\nconsole.log("wrapper should not be probed");\n',
);
writeFileSync(nativeBin, '#!/bin/sh\necho "codex 9.9.9"\n');
chmodSync(wrapperRealPath, 0o755);
chmodSync(nativeBin, 0o755);
symlinkSync(wrapperRealPath, wrapperLinkPath);
process.env.HOME = home;
process.env.PATH = '/usr/bin:/bin';
process.env.OD_AGENT_HOME = home;
const agents = await detectAgents();
const codexAgent = agents.find((agent) => agent.id === 'codex');
assert.ok(codexAgent);
assert.equal(codexAgent.available, true);
assert.equal(codexAgent.path, wrapperLinkPath);
assert.equal(codexAgent.version, 'codex 9.9.9');
});
} finally {
rmSync(home, { recursive: true, force: true });
}
});
fsTest('detectAgents keeps packaged built-in AMR unavailable when OpenCode cannot be resolved', async () => {
const root = mkdtempSync(join(tmpdir(), 'od-detect-amr-built-in-'));
try {
return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], async () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
writeFileSync(
builtInVela,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "vela manual-amr"; exit 0; fi\nexit 0\n',
);
chmodSync(builtInVela, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const agents = await detectAgents();
const amrAgent = agents.find((agent) => agent.id === 'amr');
assert.ok(amrAgent);
assert.equal(amrAgent.available, false);
assert.equal(amrAgent.path, undefined);
assert.equal(amrAgent.version, undefined);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
fsTest('detectAgents marks AMR available from packaged built-in Vela with the bundled OpenCode companion tree', async () => {
const root = mkdtempSync(join(tmpdir(), 'od-detect-amr-built-in-'));
try {
return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], async () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
const companionTree = join(resourceRoot, 'bin', 'libexec', 'opencode');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
mkdirSync(companionTree, { recursive: true });
writeFileSync(
builtInVela,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "vela manual-amr"; exit 0; fi\nexit 0\n',
);
chmodSync(builtInVela, 0o755);
// The companion tree is only "valid" when an actual `opencode`
// executable lives inside — directory-only checks were treating an
// empty/partial copy as available and the first real run had nothing
// to launch. Match the resources.test.ts packaging contract.
const companionExe = join(companionTree, 'opencode');
writeFileSync(companionExe, '#!/bin/sh\nexit 0\n');
chmodSync(companionExe, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const agents = await detectAgents();
const amrAgent = agents.find((agent) => agent.id === 'amr');
assert.ok(amrAgent);
assert.equal(amrAgent.available, true);
assert.equal(amrAgent.path, builtInVela);
assert.equal(amrAgent.version, 'vela manual-amr');
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
function codexNativeTargetTriple(): string {
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
return `${process.platform}-${process.arch}`;
}
test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-rel-'));
const oldCwd = process.cwd();
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
const configured = 'codex-custom';
writeFileSync(join(dir, configured), '#!/bin/sh\nexit 0\n');
chmodSync(join(dir, configured), 0o755);
process.chdir(dir);
process.env.PATH = '';
process.env.OD_AGENT_HOME = dir;
const resolved = resolveAgentExecutable(
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
);
assert.equal(resolved, null);
});
} finally {
process.chdir(oldCwd);
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable ignores configured binary overrides that are not executable files', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-invalid-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
const directoryOverride = join(dir, 'as-directory');
mkdirSync(directoryOverride);
const fileOverride = join(dir, 'not-executable');
writeFileSync(fileOverride, '#!/bin/sh\nexit 0\n');
if (process.platform !== 'win32') chmodSync(fileOverride, 0o644);
process.env.PATH = '';
process.env.OD_AGENT_HOME = dir;
assert.equal(
resolveAgentExecutable(minimalAgentDef({ id: 'codex', bin: 'codex' }), { CODEX_BIN: directoryOverride }),
null,
);
if (process.platform !== 'win32') {
assert.equal(
resolveAgentExecutable(minimalAgentDef({ id: 'codex', bin: 'codex' }), { CODEX_BIN: fileOverride }),
null,
);
}
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable ignores Windows CODEX_BIN overrides without executable PATHEXT extension', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-win-invalid-'));
try {
return withEnvSnapshot(['PATH', 'PATHEXT', 'OD_AGENT_HOME'], () => {
const invalidOverride = join(dir, 'codex-custom.txt');
const fallback = join(dir, 'codex.CMD');
writeFileSync(invalidOverride, '@echo off\r\nexit /b 0\r\n');
writeFileSync(fallback, '@echo off\r\nexit /b 0\r\n');
process.env.PATH = dir;
process.env.PATHEXT = '.EXE;.CMD;.BAT';
process.env.OD_AGENT_HOME = dir;
const resolved = withPlatform('win32', () =>
resolveAgentExecutable(
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: invalidOverride },
),
);
assert.equal(resolved, fallback);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable accepts Windows CODEX_BIN overrides with executable PATHEXT extension', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-win-valid-'));
try {
return withEnvSnapshot(['PATH', 'PATHEXT', 'OD_AGENT_HOME'], () => {
const configured = join(dir, 'codex-custom.CMD');
writeFileSync(configured, '@echo off\r\nexit /b 0\r\n');
process.env.PATH = '';
process.env.PATHEXT = '.EXE;.CMD;.BAT';
process.env.OD_AGENT_HOME = dir;
const resolved = withPlatform('win32', () =>
resolveAgentExecutable(
minimalAgentDef({ id: 'codex', bin: 'codex' }),
{ CODEX_BIN: configured },
),
);
assert.equal(resolved, configured);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents applies configured env while probing the CLI', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agent-env-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'claude.cmd' : 'claude');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" (\r\n echo %CLAUDE_CONFIG_DIR%\r\n exit /b 0\r\n)\r\nif "%~1"=="-p" (\r\n echo --add-dir --include-partial-messages\r\n exit /b 0\r\n)\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "$CLAUDE_CONFIG_DIR"; exit 0; fi\nif [ "$1" = "-p" ]; then echo "--add-dir --include-partial-messages"; exit 0; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents({
claude: { CLAUDE_CONFIG_DIR: '/tmp/claude-config-probe' },
});
const detected = agents.find((agent) => agent.id === 'claude');
assert.equal(detected?.available, true);
assert.equal(detected?.version, '/tmp/claude-config-probe');
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents marks Cursor Agent auth ok when cursor-agent status succeeds', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-cursor-auth-ok-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" echo 2026.05.07-test& exit /b 0\r\nif "%~1"=="models" echo auto& exit /b 0\r\nif "%~1"=="status" echo Authenticated& exit /b 0\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2026.05.07-test"; exit 0; fi\nif [ "$1" = "models" ]; then echo "auto"; exit 0; fi\nif [ "$1" = "status" ]; then echo "Authenticated"; exit 0; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'cursor-agent');
assert.equal(detected?.available, true);
assert.equal(detected?.authStatus, 'ok');
assert.equal(detected?.authMessage, undefined);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents surfaces Cursor Agent model labels without putting labels in ids', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-cursor-model-labels-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" echo 2026.05.16-test& exit /b 0\r\nif "%~1"=="models" (\r\n echo Available models\r\n echo auto - Auto\r\n echo composer-2.5 - Composer 2.5 (current)\r\n exit /b 0\r\n)\r\nif "%~1"=="status" echo Authenticated& exit /b 0\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2026.05.16-test"; exit 0; fi\nif [ "$1" = "models" ]; then printf "%s\\n" "Available models" "auto - Auto" "composer-2.5 - Composer 2.5 (current)"; exit 0; fi\nif [ "$1" = "status" ]; then echo "Authenticated"; exit 0; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'cursor-agent');
assert.equal(detected?.available, true);
assert.equal(detected?.modelsSource, 'live');
assert.deepEqual(detected?.models, [
{ id: 'default', label: 'Default (CLI config)' },
{ id: 'auto', label: 'Auto' },
{ id: 'composer-2.5', label: 'Composer 2.5 (current)' },
]);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents keeps Cursor Agent available when auth is missing', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-cursor-auth-missing-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" echo 2026.05.07-test& exit /b 0\r\nif "%~1"=="models" echo No models available for this account.& exit /b 0\r\nif "%~1"=="status" echo Authentication required. Please run agent login first, or set CURSOR_API_KEY environment variable. 1>&2& exit /b 1\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2026.05.07-test"; exit 0; fi\nif [ "$1" = "models" ]; then echo "No models available for this account."; exit 0; fi\nif [ "$1" = "status" ]; then echo "Authentication required. Please run agent login first, or set CURSOR_API_KEY environment variable." >&2; exit 1; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'cursor-agent');
assert.equal(detected?.available, true);
assert.equal(detected?.authStatus, 'missing');
assert.match(detected?.authMessage ?? '', /cursor-agent login/);
assert.deepEqual(
detected?.models.map((model) => model.id),
['default', 'auto', 'sonnet-4', 'sonnet-4-thinking', 'gpt-5'],
);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents treats Cursor Agent Not logged in status as missing auth', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-cursor-not-logged-in-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" echo 2026.05.07-test& exit /b 0\r\nif "%~1"=="models" echo No models available for this account.& exit /b 0\r\nif "%~1"=="status" echo Not logged in 1>&2& exit /b 1\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2026.05.07-test"; exit 0; fi\nif [ "$1" = "models" ]; then echo "No models available for this account."; exit 0; fi\nif [ "$1" = "status" ]; then echo "Not logged in" >&2; exit 1; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'cursor-agent');
assert.equal(detected?.available, true);
assert.equal(detected?.authStatus, 'missing');
assert.match(detected?.authMessage ?? '', /cursor-agent login/);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('Cursor auth matcher covers current unauthenticated Cursor error records', () => {
assert.equal(isCursorAuthFailureText('ConnectError: [unauthenticated]'), true);
assert.equal(isCursorAuthFailureText('Error: [unauthenticated] Error'), true);
});
// agy's print mode (`-p -`) exits with code 0 but emits one of these
// shapes when the keyring entry is missing or expired. Without the
// matcher, the daemon treats this as a successful turn and shows the
// raw OAuth URL as the agent's "reply" — but the user has no way to
// complete OAuth from inside chat (agy `-p` has no input field to
// paste the auth code into). The matcher converts each shape into
// AGENT_AUTH_REQUIRED with actionable guidance.
test('antigravity auth matcher covers agy print-mode + log-file auth signals', async () => {
const { isAntigravityAuthFailureText, antigravityAuthGuidance, classifyAgentAuthFailure } =
await import('../../src/runtimes/auth.js');
// print-mode stdout shape — user-visible
assert.equal(
isAntigravityAuthFailureText(
'Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?…',
),
true,
);
assert.equal(
isAntigravityAuthFailureText('Waiting for authentication (timeout 30s)...\nError: authentication timed out.'),
true,
);
// `agy --log-file` shape — surfaces in stderr / log-file probes
assert.equal(
isAntigravityAuthFailureText(
'E log.go:398] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.',
),
true,
);
// Negative: prose mentioning "authentication" must not false-fire
assert.equal(
isAntigravityAuthFailureText('I added two-factor authentication to the login flow.'),
false,
);
assert.equal(isAntigravityAuthFailureText(''), false);
// Classifier wires the agy detector to the user-actionable guidance
// text so the chat surfaces a re-auth message rather than the raw
// OAuth URL the user can't act on from inside OD.
const cls = classifyAgentAuthFailure(
'antigravity',
'Authentication required. Please visit the URL to log in: https://example',
);
assert.ok(cls);
assert.equal(cls.status, 'missing');
assert.equal(cls.message, antigravityAuthGuidance());
assert.ok(
antigravityAuthGuidance().includes('open a terminal and run `agy` once'),
'guidance must tell the user exactly what one-time command to run',
);
assert.ok(
antigravityAuthGuidance().includes('keyring'),
'guidance must mention the keyring so users understand it persists',
);
// Non-matching text → null (don't claim auth failure on unrelated errors)
assert.equal(
classifyAgentAuthFailure('antigravity', 'rate limit exceeded'),
null,
);
});
// Windows env-var names are case-insensitive at the kernel level, but
// spreading process.env into a plain object loses Node's case-insensitive
// accessor — a `Anthropic_Api_Key` key would survive a literal
// `delete env.ANTHROPIC_API_KEY` and still reach Claude Code on Windows.
test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claude adapter', () => {
const env = spawnEnvForAgent('claude', {
Anthropic_Api_Key: 'sk-mixed-case',
anthropic_api_key: 'sk-lower-case',
PATH: '/usr/bin',
});
const remaining = Object.keys(env).filter(
(k) => k.toUpperCase() === 'ANTHROPIC_API_KEY',
);
assert.deepEqual(remaining, []);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, {
ANTHROPIC_API_KEY: 'sk-keep',
PATH: '/usr/bin',
});
assert.equal(
env.ANTHROPIC_API_KEY,
'sk-keep',
`expected ${agentId} to preserve ANTHROPIC_API_KEY`,
);
}
});
// Issue #2420: Codex CLI prefers OPENAI_API_KEY / CODEX_API_KEY over its own
// `codex login` OAuth credentials when both are set. When the user has not
// pointed Codex at a custom proxy via OPENAI_BASE_URL, a stale BYOK key
// silently outranks `~/.codex/auth.json` and trips 401 invalid_api_key.
// Strip the API keys in that case so Codex CLI's own auth resolution wins —
// mirroring the existing ANTHROPIC_API_KEY behavior the claude adapter has
// for issue #398.
test('spawnEnvForAgent strips OPENAI_API_KEY for the codex adapter when OPENAI_BASE_URL is absent', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-stale-byok',
PATH: '/usr/bin',
OD_DAEMON_URL: 'http://127.0.0.1:7456',
});
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.OD_DAEMON_URL, 'http://127.0.0.1:7456');
});
test('spawnEnvForAgent strips CODEX_API_KEY for the codex adapter when OPENAI_BASE_URL is absent', () => {
const env = spawnEnvForAgent('codex', {
CODEX_API_KEY: 'sk-stale-byok',
PATH: '/usr/bin',
});
assert.equal('CODEX_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent strips Codex API keys when OPENAI_BASE_URL is empty', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-stale-byok',
CODEX_API_KEY: 'sk-stale-byok',
OPENAI_BASE_URL: '',
PATH: '/usr/bin',
});
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal('CODEX_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent strips Codex API keys when OPENAI_BASE_URL is whitespace', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-stale-byok',
OPENAI_BASE_URL: ' ',
PATH: '/usr/bin',
});
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves Codex API keys when OPENAI_BASE_URL is set to a custom proxy', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-proxy',
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
PATH: '/usr/bin',
});
assert.equal(env.OPENAI_API_KEY, 'sk-proxy');
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves CODEX_API_KEY when OPENAI_BASE_URL is set to a custom proxy', () => {
const env = spawnEnvForAgent('codex', {
CODEX_API_KEY: 'sk-proxy',
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
PATH: '/usr/bin',
});
assert.equal(env.CODEX_API_KEY, 'sk-proxy');
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1');
});
test('spawnEnvForAgent strips Codex API keys case-insensitively when OPENAI_BASE_URL is absent', () => {
const env = spawnEnvForAgent('codex', {
Openai_Api_Key: 'sk-mixed-case',
openai_api_key: 'sk-lower-case',
Codex_Api_Key: 'sk-mixed-case',
PATH: '/usr/bin',
});
const remainingOpenAi = Object.keys(env).filter(
(k) => k.toUpperCase() === 'OPENAI_API_KEY',
);
const remainingCodex = Object.keys(env).filter(
(k) => k.toUpperCase() === 'CODEX_API_KEY',
);
assert.deepEqual(remainingOpenAi, []);
assert.deepEqual(remainingCodex, []);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves Codex API keys for non-codex adapters', () => {
for (const agentId of ['claude', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, {
OPENAI_API_KEY: 'sk-keep',
CODEX_API_KEY: 'sk-keep',
PATH: '/usr/bin',
});
assert.equal(
env.OPENAI_API_KEY,
'sk-keep',
`expected ${agentId} to preserve OPENAI_API_KEY`,
);
assert.equal(
env.CODEX_API_KEY,
'sk-keep',
`expected ${agentId} to preserve CODEX_API_KEY`,
);
}
});
// When the user has explicitly configured a BYOK Codex base URL through the
// Settings → Execution mode → Local CLI form, the configured API key in
// `agentCliEnv.codex.OPENAI_API_KEY` (or CODEX_API_KEY) flows through to the
// spawn alongside the base URL. The stripping helper must keep both in sync
// so the configured proxy actually authenticates.
test('spawnEnvForAgent applies configured codex env and preserves API key when base URL is configured', () => {
const env = spawnEnvForAgent(
'codex',
{ PATH: '/usr/bin' },
{
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
OPENAI_API_KEY: 'sk-configured',
},
);
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1');
assert.equal(env.OPENAI_API_KEY, 'sk-configured');
});
// The dual-key shape every BYOK Codex user hits in production: prior session
// left OPENAI_API_KEY in the daemon's app-config, the user cleared the BYOK
// dialog but never opened Settings → Local CLI → Codex env to also clear
// OPENAI_API_KEY, then switched execution mode back to Local CLI. spawnEnv
// must strip the stale BYOK key so Codex CLI's own `codex login` wins.
test('spawnEnvForAgent strips stale configured OPENAI_API_KEY when configured base URL was also cleared', () => {
const env = spawnEnvForAgent(
'codex',
{ PATH: '/usr/bin' },
{
// Empty OPENAI_BASE_URL — i.e. user is on Local CLI mode without a
// custom proxy. validateAgentCliEnv would drop the empty string in
// practice; we pass it explicitly here to lock the spawn-side guard.
OPENAI_API_KEY: 'sk-stale-byok',
},
);
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when ANTHROPIC_BASE_URL is set', () => {
const env = spawnEnvForAgent('claude', {
ANTHROPIC_API_KEY: 'sk-kimi',
ANTHROPIC_BASE_URL: 'https://api.moonshot.cn/v1',
PATH: '/usr/bin',
});
assert.equal(env.ANTHROPIC_API_KEY, 'sk-kimi');
assert.equal(env.ANTHROPIC_BASE_URL, 'https://api.moonshot.cn/v1');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent strips ANTHROPIC_API_KEY when ANTHROPIC_BASE_URL is empty', () => {
const env = spawnEnvForAgent('claude', {
ANTHROPIC_API_KEY: 'sk-leak',
ANTHROPIC_BASE_URL: '',
PATH: '/usr/bin',
});
assert.equal('ANTHROPIC_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent strips ANTHROPIC_API_KEY when ANTHROPIC_BASE_URL is whitespace', () => {
const env = spawnEnvForAgent('claude', {
ANTHROPIC_API_KEY: 'sk-leak',
ANTHROPIC_BASE_URL: ' ',
PATH: '/usr/bin',
});
assert.equal('ANTHROPIC_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent does not mutate the input env', () => {
const original = { ANTHROPIC_API_KEY: 'sk-leak', PATH: '/usr/bin' };
const env = spawnEnvForAgent('claude', original);
assert.equal(original.ANTHROPIC_API_KEY, 'sk-leak');
assert.notEqual(env, original);
});